- Add backup/restore commands to hexoctl (backup, restore, backup list/delete) - Add GitHub clone support (hexoctl github clone <url> [instance] [branch]) - Add Gitea push support (hexoctl gitea push [instance] [message]) - Add quick-publish command (clean + build + publish in one step) - Add 15 new RPCD methods for instance/backup/git management - Rewrite LuCI dashboard with KISS theme: - Multi-instance management with status cards - Instance controls: start/stop, quick publish, backup, editor, preview - GitHub/Gitea clone modals - Backup table with restore/delete - Stats grid: instances, posts, drafts, backups - Update API with 12 new RPC declarations - Update ACL with new permissions Also includes DNS Master app created in previous session. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
645 lines
24 KiB
JavaScript
645 lines
24 KiB
JavaScript
'use strict';
|
||
'require view';
|
||
'require dom';
|
||
'require poll';
|
||
'require rpc';
|
||
'require ui';
|
||
|
||
var callStatus = rpc.declare({
|
||
object: 'luci.dns-master',
|
||
method: 'status',
|
||
expect: {}
|
||
});
|
||
|
||
var callZones = rpc.declare({
|
||
object: 'luci.dns-master',
|
||
method: 'zones',
|
||
expect: {}
|
||
});
|
||
|
||
var callRecords = rpc.declare({
|
||
object: 'luci.dns-master',
|
||
method: 'records',
|
||
params: ['zone'],
|
||
expect: {}
|
||
});
|
||
|
||
var callAddRecord = rpc.declare({
|
||
object: 'luci.dns-master',
|
||
method: 'add_record',
|
||
params: ['zone', 'type', 'name', 'value', 'ttl'],
|
||
expect: {}
|
||
});
|
||
|
||
var callDelRecord = rpc.declare({
|
||
object: 'luci.dns-master',
|
||
method: 'del_record',
|
||
params: ['zone', 'type', 'name', 'value'],
|
||
expect: {}
|
||
});
|
||
|
||
var callAddZone = rpc.declare({
|
||
object: 'luci.dns-master',
|
||
method: 'add_zone',
|
||
params: ['name'],
|
||
expect: {}
|
||
});
|
||
|
||
var callReload = rpc.declare({
|
||
object: 'luci.dns-master',
|
||
method: 'reload',
|
||
expect: {}
|
||
});
|
||
|
||
var callCheck = rpc.declare({
|
||
object: 'luci.dns-master',
|
||
method: 'check',
|
||
params: ['zone'],
|
||
expect: {}
|
||
});
|
||
|
||
var callBackup = rpc.declare({
|
||
object: 'luci.dns-master',
|
||
method: 'backup',
|
||
params: ['zone'],
|
||
expect: {}
|
||
});
|
||
|
||
// State
|
||
var currentZone = null;
|
||
var currentRecords = [];
|
||
var allZones = [];
|
||
var typeFilter = 'ALL';
|
||
|
||
// Record type definitions
|
||
var RECORD_TYPES = {
|
||
'A': { color: '#3fb950', label: 'IPv4 Address', placeholder: '192.168.1.1' },
|
||
'AAAA': { color: '#58a6ff', label: 'IPv6 Address', placeholder: '2001:db8::1' },
|
||
'CNAME': { color: '#39c5cf', label: 'Canonical Name', placeholder: 'target.example.com.' },
|
||
'MX': { color: '#a371f7', label: 'Mail Exchange', placeholder: '10 mail.example.com.' },
|
||
'TXT': { color: '#d29922', label: 'Text Record', placeholder: '"v=spf1 mx ~all"' },
|
||
'SRV': { color: '#db6d28', label: 'Service', placeholder: '0 0 443 target.example.com.' },
|
||
'NS': { color: '#8b949e', label: 'Nameserver', placeholder: 'ns1.example.com.' },
|
||
'PTR': { color: '#8b949e', label: 'Pointer', placeholder: 'host.example.com.' },
|
||
'CAA': { color: '#f85149', label: 'Cert Authority', placeholder: '0 issue "letsencrypt.org"' }
|
||
};
|
||
|
||
return view.extend({
|
||
load: function() {
|
||
return Promise.all([
|
||
callStatus(),
|
||
callZones()
|
||
]);
|
||
},
|
||
|
||
css: function() {
|
||
return `
|
||
/* KISS DNS Master */
|
||
:root {
|
||
--k-bg: #0d1117; --k-surface: #161b22; --k-card: #1c2128;
|
||
--k-line: #30363d; --k-text: #e6edf3; --k-muted: #8b949e;
|
||
--k-green: #3fb950; --k-red: #f85149; --k-blue: #58a6ff;
|
||
--k-cyan: #39c5cf; --k-purple: #a371f7; --k-yellow: #d29922;
|
||
--k-orange: #db6d28;
|
||
}
|
||
.dns-wrap { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: var(--k-text); }
|
||
.dns-header { margin-bottom: 24px; }
|
||
.dns-header h2 { margin: 0 0 4px 0; font-size: 26px; font-weight: 700; }
|
||
.dns-header p { color: var(--k-muted); margin: 0; font-size: 14px; }
|
||
|
||
/* Stats Grid */
|
||
.dns-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
||
.dns-stat { background: var(--k-surface); border: 1px solid var(--k-line); border-radius: 10px; padding: 16px; text-align: center; }
|
||
.dns-stat-val { font-size: 28px; font-weight: 700; line-height: 1.2; }
|
||
.dns-stat-lbl { font-size: 11px; color: var(--k-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
||
|
||
/* Cards */
|
||
.dns-card { background: var(--k-surface); border: 1px solid var(--k-line); border-radius: 10px; padding: 16px; margin-bottom: 16px; }
|
||
.dns-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||
.dns-card-title { font-size: 13px; font-weight: 600; color: var(--k-muted); text-transform: uppercase; letter-spacing: 0.5px; display: flex; align-items: center; gap: 8px; }
|
||
.dns-card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
|
||
/* Buttons */
|
||
.dns-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--k-line); background: var(--k-card); color: var(--k-text); font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s; }
|
||
.dns-btn:hover { background: var(--k-line); border-color: var(--k-muted); }
|
||
.dns-btn-sm { padding: 4px 10px; font-size: 11px; }
|
||
.dns-btn-green { background: var(--k-green); border-color: var(--k-green); color: #000; }
|
||
.dns-btn-green:hover { background: #2ea043; }
|
||
.dns-btn-blue { background: var(--k-blue); border-color: var(--k-blue); color: #000; }
|
||
.dns-btn-blue:hover { background: #4090e0; }
|
||
.dns-btn-red { background: transparent; border-color: var(--k-red); color: var(--k-red); }
|
||
.dns-btn-red:hover { background: rgba(248,81,73,0.15); }
|
||
.dns-btn-icon { padding: 5px 8px; }
|
||
|
||
/* Table */
|
||
.dns-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.dns-table th { text-align: left; padding: 10px 12px; font-size: 10px; font-weight: 600; color: var(--k-muted); text-transform: uppercase; border-bottom: 1px solid var(--k-line); }
|
||
.dns-table td { padding: 10px 12px; border-bottom: 1px solid var(--k-line); vertical-align: middle; }
|
||
.dns-table tr:hover td { background: rgba(255,255,255,0.02); }
|
||
.dns-table .mono { font-family: 'SF Mono', Monaco, monospace; font-size: 12px; }
|
||
.dns-table .truncate { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.dns-table .actions { display: flex; gap: 6px; justify-content: flex-end; }
|
||
|
||
/* Badges */
|
||
.dns-badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; letter-spacing: 0.3px; }
|
||
.dns-badge-ok { background: rgba(63,185,80,0.15); color: var(--k-green); }
|
||
.dns-badge-err { background: rgba(248,81,73,0.15); color: var(--k-red); }
|
||
|
||
/* Type badge */
|
||
.dns-type { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; font-family: monospace; }
|
||
|
||
/* Filter bar */
|
||
.dns-filter { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
|
||
.dns-filter-group { display: flex; gap: 4px; }
|
||
.dns-filter-btn { padding: 5px 10px; border-radius: 4px; border: 1px solid var(--k-line); background: transparent; color: var(--k-muted); font-size: 11px; cursor: pointer; transition: all 0.15s; }
|
||
.dns-filter-btn:hover { color: var(--k-text); border-color: var(--k-muted); }
|
||
.dns-filter-btn.active { background: var(--k-blue); border-color: var(--k-blue); color: #000; }
|
||
.dns-search { flex: 1; min-width: 150px; max-width: 300px; }
|
||
.dns-search input { width: 100%; padding: 6px 10px; border-radius: 5px; border: 1px solid var(--k-line); background: var(--k-bg); color: var(--k-text); font-size: 12px; }
|
||
.dns-search input:focus { outline: none; border-color: var(--k-blue); }
|
||
.dns-search input::placeholder { color: var(--k-muted); }
|
||
|
||
/* Zone selector */
|
||
.dns-zone-select { display: flex; gap: 8px; align-items: center; }
|
||
.dns-zone-select select { padding: 6px 10px; border-radius: 5px; border: 1px solid var(--k-line); background: var(--k-bg); color: var(--k-text); font-size: 12px; min-width: 180px; }
|
||
.dns-zone-select select:focus { outline: none; border-color: var(--k-blue); }
|
||
|
||
/* Modal */
|
||
.dns-modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.75); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||
.dns-modal { background: var(--k-surface); border: 1px solid var(--k-line); border-radius: 12px; padding: 20px; width: 420px; max-width: 95vw; max-height: 90vh; overflow-y: auto; }
|
||
.dns-modal-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
|
||
.dns-modal-close { margin-left: auto; background: none; border: none; color: var(--k-muted); font-size: 18px; cursor: pointer; padding: 4px; }
|
||
.dns-modal-close:hover { color: var(--k-text); }
|
||
|
||
/* Form */
|
||
.dns-form-row { margin-bottom: 14px; }
|
||
.dns-form-label { display: block; font-size: 11px; color: var(--k-muted); margin-bottom: 5px; text-transform: uppercase; }
|
||
.dns-form-input { width: 100%; padding: 8px 10px; border-radius: 5px; border: 1px solid var(--k-line); background: var(--k-bg); color: var(--k-text); font-size: 13px; box-sizing: border-box; }
|
||
.dns-form-input:focus { outline: none; border-color: var(--k-blue); }
|
||
.dns-form-hint { font-size: 11px; color: var(--k-muted); margin-top: 4px; }
|
||
.dns-form-row-inline { display: flex; gap: 8px; align-items: center; }
|
||
.dns-form-suffix { color: var(--k-muted); font-size: 12px; white-space: nowrap; }
|
||
.dns-form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--k-line); }
|
||
|
||
/* Empty state */
|
||
.dns-empty { text-align: center; padding: 40px 20px; color: var(--k-muted); }
|
||
.dns-empty-icon { font-size: 40px; margin-bottom: 12px; opacity: 0.5; }
|
||
.dns-empty-text { font-size: 13px; }
|
||
|
||
/* Two column layout */
|
||
.dns-cols { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; }
|
||
@media (max-width: 900px) { .dns-cols { grid-template-columns: 1fr; } }
|
||
`;
|
||
},
|
||
|
||
statCard: function(label, value, color, id) {
|
||
return E('div', { 'class': 'dns-stat' }, [
|
||
E('div', { 'class': 'dns-stat-val', 'style': 'color:' + color, 'data-stat': id }, String(value)),
|
||
E('div', { 'class': 'dns-stat-lbl' }, label)
|
||
]);
|
||
},
|
||
|
||
render: function(data) {
|
||
var self = this;
|
||
var status = data[0] || {};
|
||
var zonesData = data[1] || {};
|
||
allZones = zonesData.zones || [];
|
||
|
||
poll.add(function() {
|
||
return Promise.all([callStatus(), callZones()]).then(function(d) {
|
||
self.updateStats(d[0]);
|
||
allZones = (d[1] || {}).zones || [];
|
||
self.updateZonesTable();
|
||
});
|
||
}, 15);
|
||
|
||
var isRunning = status.running === true;
|
||
|
||
return E('div', { 'class': 'dns-wrap' }, [
|
||
E('style', {}, this.css()),
|
||
|
||
// Header
|
||
E('div', { 'class': 'dns-header' }, [
|
||
E('h2', {}, '🌐 DNS Master'),
|
||
E('p', {}, 'BIND DNS Zone Management')
|
||
]),
|
||
|
||
// Stats
|
||
E('div', { 'class': 'dns-stats' }, [
|
||
this.statCard('Status', isRunning ? 'Running' : 'Stopped', isRunning ? 'var(--k-green)' : 'var(--k-red)', 'status'),
|
||
this.statCard('Zones', status.zones || 0, 'var(--k-blue)', 'zones'),
|
||
this.statCard('Records', status.records || 0, 'var(--k-cyan)', 'records'),
|
||
this.statCard('TTL', (status.default_ttl || 300) + 's', 'var(--k-purple)', 'ttl')
|
||
]),
|
||
|
||
// Two column layout
|
||
E('div', { 'class': 'dns-cols' }, [
|
||
// Left: Zones
|
||
E('div', { 'class': 'dns-card' }, [
|
||
E('div', { 'class': 'dns-card-head' }, [
|
||
E('div', { 'class': 'dns-card-title' }, '📁 Zones'),
|
||
E('button', { 'class': 'dns-btn dns-btn-green dns-btn-sm', 'click': L.bind(this.showAddZoneModal, this) }, '+ Add')
|
||
]),
|
||
E('div', { 'id': 'zones-list' }, this.renderZonesList(allZones))
|
||
]),
|
||
|
||
// Right: Records
|
||
E('div', { 'class': 'dns-card' }, [
|
||
E('div', { 'class': 'dns-card-head' }, [
|
||
E('div', { 'class': 'dns-card-title' }, [
|
||
E('span', {}, '📝 Records'),
|
||
E('span', { 'id': 'current-zone-label', 'style': 'color: var(--k-blue); font-weight: 400;' }, '')
|
||
]),
|
||
E('div', { 'class': 'dns-card-actions' }, [
|
||
E('button', { 'class': 'dns-btn dns-btn-sm', 'click': L.bind(this.handleReload, this) }, '🔄 Reload'),
|
||
E('button', { 'class': 'dns-btn dns-btn-green dns-btn-sm', 'id': 'add-record-btn', 'style': 'display:none;', 'click': L.bind(this.showRecordModal, this, null) }, '+ Add')
|
||
])
|
||
]),
|
||
E('div', { 'id': 'records-filter', 'style': 'display:none;' }, this.renderFilter()),
|
||
E('div', { 'id': 'records-container' }, this.renderEmptyRecords())
|
||
])
|
||
])
|
||
]);
|
||
},
|
||
|
||
renderZonesList: function(zones) {
|
||
var self = this;
|
||
if (!zones || zones.length === 0) {
|
||
return E('div', { 'class': 'dns-empty' }, [
|
||
E('div', { 'class': 'dns-empty-icon' }, '📂'),
|
||
E('div', { 'class': 'dns-empty-text' }, 'No zones configured')
|
||
]);
|
||
}
|
||
|
||
return E('div', {}, zones.map(function(zone) {
|
||
var isActive = currentZone === zone.name;
|
||
return E('div', {
|
||
'style': 'display: flex; align-items: center; padding: 10px; border-radius: 6px; margin-bottom: 6px; cursor: pointer; border: 1px solid ' + (isActive ? 'var(--k-blue)' : 'transparent') + '; background: ' + (isActive ? 'rgba(88,166,255,0.1)' : 'var(--k-card)') + ';',
|
||
'click': L.bind(self.selectZone, self, zone.name)
|
||
}, [
|
||
E('div', { 'style': 'flex: 1; min-width: 0;' }, [
|
||
E('div', { 'style': 'font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis;' }, zone.name),
|
||
E('div', { 'style': 'font-size: 11px; color: var(--k-muted); margin-top: 2px;' }, zone.records + ' records')
|
||
]),
|
||
E('div', { 'style': 'display: flex; align-items: center; gap: 6px;' }, [
|
||
zone.valid ?
|
||
E('span', { 'class': 'dns-badge dns-badge-ok' }, '✓') :
|
||
E('span', { 'class': 'dns-badge dns-badge-err' }, '✗'),
|
||
E('button', {
|
||
'class': 'dns-btn dns-btn-icon dns-btn-sm',
|
||
'title': 'Backup',
|
||
'click': function(ev) { ev.stopPropagation(); self.handleBackupZone(zone.name); }
|
||
}, '💾')
|
||
])
|
||
]);
|
||
}));
|
||
},
|
||
|
||
renderEmptyRecords: function() {
|
||
return E('div', { 'class': 'dns-empty' }, [
|
||
E('div', { 'class': 'dns-empty-icon' }, '👈'),
|
||
E('div', { 'class': 'dns-empty-text' }, 'Select a zone to view records')
|
||
]);
|
||
},
|
||
|
||
renderFilter: function() {
|
||
var self = this;
|
||
var types = ['ALL'].concat(Object.keys(RECORD_TYPES));
|
||
|
||
return E('div', { 'class': 'dns-filter' }, [
|
||
E('div', { 'class': 'dns-filter-group' }, types.map(function(t) {
|
||
return E('button', {
|
||
'class': 'dns-filter-btn' + (typeFilter === t ? ' active' : ''),
|
||
'data-type': t,
|
||
'click': function() { self.setTypeFilter(t); }
|
||
}, t);
|
||
})),
|
||
E('div', { 'class': 'dns-search' }, [
|
||
E('input', {
|
||
'type': 'text',
|
||
'placeholder': '🔍 Search records...',
|
||
'id': 'record-search',
|
||
'input': L.bind(this.filterRecords, this)
|
||
})
|
||
])
|
||
]);
|
||
},
|
||
|
||
renderRecordsTable: function(records) {
|
||
var self = this;
|
||
var search = (document.getElementById('record-search') || {}).value || '';
|
||
search = search.toLowerCase();
|
||
|
||
var filtered = records.filter(function(r) {
|
||
if (typeFilter !== 'ALL' && r.type !== typeFilter) return false;
|
||
if (search && r.name.toLowerCase().indexOf(search) === -1 && r.value.toLowerCase().indexOf(search) === -1) return false;
|
||
return true;
|
||
});
|
||
|
||
if (filtered.length === 0) {
|
||
return E('div', { 'class': 'dns-empty' }, [
|
||
E('div', { 'class': 'dns-empty-icon' }, '🔍'),
|
||
E('div', { 'class': 'dns-empty-text' }, typeFilter !== 'ALL' || search ? 'No matching records' : 'No records in this zone')
|
||
]);
|
||
}
|
||
|
||
return E('table', { 'class': 'dns-table' }, [
|
||
E('thead', {}, [
|
||
E('tr', {}, [
|
||
E('th', { 'style': 'width: 60px;' }, 'Type'),
|
||
E('th', {}, 'Name'),
|
||
E('th', {}, 'Value'),
|
||
E('th', { 'style': 'width: 50px;' }, 'TTL'),
|
||
E('th', { 'style': 'width: 80px; text-align: right;' }, '')
|
||
])
|
||
]),
|
||
E('tbody', {}, filtered.map(function(rec) {
|
||
var typeInfo = RECORD_TYPES[rec.type] || { color: 'var(--k-muted)' };
|
||
// Clean up value display (remove extra "IN TYPE" if present)
|
||
var displayValue = rec.value.replace(/^\s*IN\s+\w+\s+/, '').trim();
|
||
|
||
return E('tr', {}, [
|
||
E('td', {}, [
|
||
E('span', {
|
||
'class': 'dns-type',
|
||
'style': 'background: ' + typeInfo.color + '20; color: ' + typeInfo.color + ';'
|
||
}, rec.type)
|
||
]),
|
||
E('td', { 'class': 'mono' }, rec.name),
|
||
E('td', { 'class': 'mono truncate', 'title': displayValue }, displayValue),
|
||
E('td', { 'style': 'color: var(--k-muted);' }, rec.ttl || '-'),
|
||
E('td', { 'class': 'actions' }, [
|
||
E('button', {
|
||
'class': 'dns-btn dns-btn-icon dns-btn-sm',
|
||
'title': 'Edit',
|
||
'click': L.bind(self.showRecordModal, self, rec)
|
||
}, '✏️'),
|
||
E('button', {
|
||
'class': 'dns-btn dns-btn-icon dns-btn-sm dns-btn-red',
|
||
'title': 'Delete',
|
||
'click': L.bind(self.handleDeleteRecord, self, rec)
|
||
}, '✗')
|
||
])
|
||
]);
|
||
}))
|
||
]);
|
||
},
|
||
|
||
selectZone: function(zoneName) {
|
||
var self = this;
|
||
currentZone = zoneName;
|
||
typeFilter = 'ALL';
|
||
|
||
document.getElementById('current-zone-label').textContent = ': ' + zoneName;
|
||
document.getElementById('add-record-btn').style.display = '';
|
||
document.getElementById('records-filter').style.display = '';
|
||
|
||
callRecords(zoneName).then(function(data) {
|
||
currentRecords = data.records || [];
|
||
self.updateRecordsTable();
|
||
self.updateZonesTable();
|
||
});
|
||
},
|
||
|
||
setTypeFilter: function(type) {
|
||
typeFilter = type;
|
||
document.querySelectorAll('.dns-filter-btn').forEach(function(btn) {
|
||
btn.classList.toggle('active', btn.dataset.type === type);
|
||
});
|
||
this.updateRecordsTable();
|
||
},
|
||
|
||
filterRecords: function() {
|
||
this.updateRecordsTable();
|
||
},
|
||
|
||
updateStats: function(status) {
|
||
var isRunning = status.running === true;
|
||
var updates = {
|
||
'status': { val: isRunning ? 'Running' : 'Stopped', color: isRunning ? 'var(--k-green)' : 'var(--k-red)' },
|
||
'zones': { val: status.zones || 0 },
|
||
'records': { val: status.records || 0 },
|
||
'ttl': { val: (status.default_ttl || 300) + 's' }
|
||
};
|
||
Object.keys(updates).forEach(function(k) {
|
||
var el = document.querySelector('[data-stat="' + k + '"]');
|
||
if (el) {
|
||
el.textContent = updates[k].val;
|
||
if (updates[k].color) el.style.color = updates[k].color;
|
||
}
|
||
});
|
||
},
|
||
|
||
updateZonesTable: function() {
|
||
var container = document.getElementById('zones-list');
|
||
if (container) dom.content(container, this.renderZonesList(allZones));
|
||
},
|
||
|
||
updateRecordsTable: function() {
|
||
var container = document.getElementById('records-container');
|
||
if (container) dom.content(container, this.renderRecordsTable(currentRecords));
|
||
},
|
||
|
||
// === Modals ===
|
||
|
||
showAddZoneModal: function() {
|
||
var self = this;
|
||
var modal = E('div', { 'class': 'dns-modal-bg', 'id': 'modal-zone' }, [
|
||
E('div', { 'class': 'dns-modal' }, [
|
||
E('div', { 'class': 'dns-modal-title' }, [
|
||
'📁 Add Zone',
|
||
E('button', { 'class': 'dns-modal-close', 'click': function() { document.getElementById('modal-zone').remove(); } }, '×')
|
||
]),
|
||
E('div', { 'class': 'dns-form-row' }, [
|
||
E('label', { 'class': 'dns-form-label' }, 'Zone Name'),
|
||
E('input', { 'type': 'text', 'class': 'dns-form-input', 'id': 'input-zone-name', 'placeholder': 'example.com' }),
|
||
E('div', { 'class': 'dns-form-hint' }, 'Enter the domain name for this zone')
|
||
]),
|
||
E('div', { 'class': 'dns-form-actions' }, [
|
||
E('button', { 'class': 'dns-btn', 'click': function() { document.getElementById('modal-zone').remove(); } }, 'Cancel'),
|
||
E('button', { 'class': 'dns-btn dns-btn-green', 'click': L.bind(self.handleAddZone, self) }, '+ Create Zone')
|
||
])
|
||
])
|
||
]);
|
||
document.body.appendChild(modal);
|
||
document.getElementById('input-zone-name').focus();
|
||
},
|
||
|
||
showRecordModal: function(record) {
|
||
var self = this;
|
||
var isEdit = !!record;
|
||
var title = isEdit ? '✏️ Edit Record' : '➕ Add Record';
|
||
|
||
var typeOptions = Object.keys(RECORD_TYPES).map(function(t) {
|
||
var info = RECORD_TYPES[t];
|
||
return E('option', { 'value': t, 'selected': isEdit && record.type === t }, t + ' - ' + info.label);
|
||
});
|
||
|
||
var modal = E('div', { 'class': 'dns-modal-bg', 'id': 'modal-record' }, [
|
||
E('div', { 'class': 'dns-modal' }, [
|
||
E('div', { 'class': 'dns-modal-title' }, [
|
||
title,
|
||
E('button', { 'class': 'dns-modal-close', 'click': function() { document.getElementById('modal-record').remove(); } }, '×')
|
||
]),
|
||
E('div', { 'class': 'dns-form-row' }, [
|
||
E('label', { 'class': 'dns-form-label' }, 'Type'),
|
||
E('select', {
|
||
'class': 'dns-form-input',
|
||
'id': 'input-rec-type',
|
||
'disabled': isEdit,
|
||
'change': L.bind(this.updatePlaceholder, this)
|
||
}, typeOptions)
|
||
]),
|
||
E('div', { 'class': 'dns-form-row' }, [
|
||
E('label', { 'class': 'dns-form-label' }, 'Name'),
|
||
E('div', { 'class': 'dns-form-row-inline' }, [
|
||
E('input', {
|
||
'type': 'text',
|
||
'class': 'dns-form-input',
|
||
'id': 'input-rec-name',
|
||
'placeholder': '@ or www',
|
||
'value': isEdit ? record.name : '',
|
||
'style': 'flex: 1;'
|
||
}),
|
||
E('span', { 'class': 'dns-form-suffix' }, '.' + currentZone)
|
||
]),
|
||
E('div', { 'class': 'dns-form-hint' }, 'Use @ for zone root')
|
||
]),
|
||
E('div', { 'class': 'dns-form-row' }, [
|
||
E('label', { 'class': 'dns-form-label' }, 'Value'),
|
||
E('input', {
|
||
'type': 'text',
|
||
'class': 'dns-form-input',
|
||
'id': 'input-rec-value',
|
||
'placeholder': isEdit ? '' : RECORD_TYPES['A'].placeholder,
|
||
'value': isEdit ? record.value.replace(/^\s*IN\s+\w+\s+/, '').trim() : ''
|
||
}),
|
||
E('div', { 'class': 'dns-form-hint', 'id': 'value-hint' }, isEdit ? '' : RECORD_TYPES['A'].label)
|
||
]),
|
||
E('div', { 'class': 'dns-form-row' }, [
|
||
E('label', { 'class': 'dns-form-label' }, 'TTL (seconds)'),
|
||
E('input', {
|
||
'type': 'number',
|
||
'class': 'dns-form-input',
|
||
'id': 'input-rec-ttl',
|
||
'placeholder': '300',
|
||
'value': isEdit && record.ttl ? record.ttl : '',
|
||
'style': 'width: 120px;'
|
||
})
|
||
]),
|
||
E('div', { 'class': 'dns-form-actions' }, [
|
||
E('button', { 'class': 'dns-btn', 'click': function() { document.getElementById('modal-record').remove(); } }, 'Cancel'),
|
||
E('button', {
|
||
'class': 'dns-btn dns-btn-green',
|
||
'click': L.bind(self.handleSaveRecord, self, record)
|
||
}, isEdit ? '💾 Save' : '+ Add')
|
||
])
|
||
])
|
||
]);
|
||
|
||
document.body.appendChild(modal);
|
||
if (!isEdit) document.getElementById('input-rec-name').focus();
|
||
},
|
||
|
||
updatePlaceholder: function() {
|
||
var type = document.getElementById('input-rec-type').value;
|
||
var info = RECORD_TYPES[type] || { placeholder: '', label: '' };
|
||
document.getElementById('input-rec-value').placeholder = info.placeholder;
|
||
document.getElementById('value-hint').textContent = info.label;
|
||
},
|
||
|
||
// === Handlers ===
|
||
|
||
handleAddZone: function() {
|
||
var self = this;
|
||
var name = document.getElementById('input-zone-name').value.trim();
|
||
if (!name) {
|
||
ui.addNotification(null, E('p', {}, 'Zone name required'), 'warning');
|
||
return;
|
||
}
|
||
callAddZone(name).then(function(res) {
|
||
document.getElementById('modal-zone').remove();
|
||
if (res.code === 0) {
|
||
ui.addNotification(null, E('p', {}, 'Zone created: ' + name), 'success');
|
||
callZones().then(function(d) {
|
||
allZones = (d || {}).zones || [];
|
||
self.updateZonesTable();
|
||
self.selectZone(name);
|
||
});
|
||
} else {
|
||
ui.addNotification(null, E('p', {}, res.output || 'Error'), 'error');
|
||
}
|
||
});
|
||
},
|
||
|
||
handleSaveRecord: function(oldRecord) {
|
||
var self = this;
|
||
var type = document.getElementById('input-rec-type').value;
|
||
var name = document.getElementById('input-rec-name').value.trim() || '@';
|
||
var value = document.getElementById('input-rec-value').value.trim();
|
||
var ttl = document.getElementById('input-rec-ttl').value.trim();
|
||
|
||
if (!value) {
|
||
ui.addNotification(null, E('p', {}, 'Value required'), 'warning');
|
||
return;
|
||
}
|
||
|
||
var doAdd = function() {
|
||
callAddRecord(currentZone, type, name, value, ttl ? parseInt(ttl) : null).then(function(res) {
|
||
document.getElementById('modal-record').remove();
|
||
if (res.code === 0) {
|
||
ui.addNotification(null, E('p', {}, 'Record saved'), 'success');
|
||
self.selectZone(currentZone);
|
||
} else {
|
||
ui.addNotification(null, E('p', {}, res.output || 'Error'), 'error');
|
||
}
|
||
});
|
||
};
|
||
|
||
// If editing, delete old record first
|
||
if (oldRecord) {
|
||
callDelRecord(currentZone, oldRecord.type, oldRecord.name, oldRecord.value).then(doAdd);
|
||
} else {
|
||
doAdd();
|
||
}
|
||
},
|
||
|
||
handleDeleteRecord: function(record) {
|
||
var self = this;
|
||
if (!confirm('Delete ' + record.type + ' record for "' + record.name + '"?')) return;
|
||
|
||
callDelRecord(currentZone, record.type, record.name, record.value).then(function(res) {
|
||
if (res.code === 0) {
|
||
ui.addNotification(null, E('p', {}, 'Record deleted'), 'success');
|
||
self.selectZone(currentZone);
|
||
} else {
|
||
ui.addNotification(null, E('p', {}, res.output || 'Error'), 'error');
|
||
}
|
||
});
|
||
},
|
||
|
||
handleReload: function() {
|
||
callReload().then(function(res) {
|
||
if (res.code === 0) {
|
||
ui.addNotification(null, E('p', {}, 'BIND reloaded'), 'success');
|
||
} else {
|
||
ui.addNotification(null, E('p', {}, res.output || 'Reload failed'), 'error');
|
||
}
|
||
});
|
||
},
|
||
|
||
handleBackupZone: function(zoneName) {
|
||
callBackup(zoneName).then(function(res) {
|
||
if (res.code === 0) {
|
||
ui.addNotification(null, E('p', {}, 'Zone backed up: ' + zoneName), 'success');
|
||
} else {
|
||
ui.addNotification(null, E('p', {}, res.output || 'Backup failed'), 'error');
|
||
}
|
||
});
|
||
},
|
||
|
||
handleSaveApply: null,
|
||
handleSave: null,
|
||
handleReset: null
|
||
});
|