The listInstances and listBackups RPC declarations use expect which unwraps the response array directly. Changed results[0].instances to results[0] and results[3].backups to results[3]. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
434 lines
17 KiB
JavaScript
434 lines
17 KiB
JavaScript
'use strict';
|
|
'require view';
|
|
'require poll';
|
|
'require ui';
|
|
'require rpc';
|
|
'require hexojs/api as api';
|
|
'require secubox/kiss-theme';
|
|
|
|
return view.extend({
|
|
title: _('Hexo CMS'),
|
|
pollInterval: 10,
|
|
pollActive: true,
|
|
currentInstance: null,
|
|
|
|
load: function() {
|
|
return Promise.all([
|
|
api.listInstances(),
|
|
api.getStatus(),
|
|
api.getSiteStats(),
|
|
api.listBackups()
|
|
]).then(function(results) {
|
|
return {
|
|
instances: results[0] || [],
|
|
status: results[1],
|
|
stats: results[2],
|
|
backups: results[3] || []
|
|
};
|
|
});
|
|
},
|
|
|
|
// ─── Instance Management ───
|
|
handleCreateInstance: function() {
|
|
var self = this;
|
|
ui.showModal(_('Create Instance'), [
|
|
E('div', { 'class': 'k-form' }, [
|
|
E('div', { 'class': 'k-form-group' }, [
|
|
E('label', {}, _('Name (lowercase, no spaces)')),
|
|
E('input', { 'type': 'text', 'id': 'new-instance-name', 'placeholder': 'myblog',
|
|
'pattern': '^[a-z][a-z0-9_]*$', 'style': 'width: 100%' })
|
|
]),
|
|
E('div', { 'class': 'k-form-group' }, [
|
|
E('label', {}, _('Title')),
|
|
E('input', { 'type': 'text', 'id': 'new-instance-title', 'placeholder': 'My Blog',
|
|
'style': 'width: 100%' })
|
|
]),
|
|
E('div', { 'class': 'k-form-group' }, [
|
|
E('label', {}, _('Port (auto if empty)')),
|
|
E('input', { 'type': 'number', 'id': 'new-instance-port', 'placeholder': '4000',
|
|
'style': 'width: 100%' })
|
|
])
|
|
]),
|
|
E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [
|
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
|
|
E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px',
|
|
'click': function() {
|
|
var name = document.getElementById('new-instance-name').value;
|
|
var title = document.getElementById('new-instance-title').value;
|
|
var port = document.getElementById('new-instance-port').value;
|
|
if (!name) { ui.addNotification(null, E('p', _('Name required')), 'error'); return; }
|
|
ui.showModal(_('Creating...'), [E('p', { 'class': 'spinning' }, _('Creating instance...'))]);
|
|
api.createInstance(name, title || null, port ? parseInt(port) : null).then(function(r) {
|
|
ui.hideModal();
|
|
if (r.success) {
|
|
ui.addNotification(null, E('p', _('Instance created: %s').format(name)), 'info');
|
|
window.location.reload();
|
|
} else {
|
|
ui.addNotification(null, E('p', r.error || _('Failed')), 'error');
|
|
}
|
|
});
|
|
}
|
|
}, _('Create'))
|
|
])
|
|
]);
|
|
},
|
|
|
|
handleDeleteInstance: function(name) {
|
|
var self = this;
|
|
ui.showModal(_('Delete Instance'), [
|
|
E('p', {}, _('Delete instance "%s"?').format(name)),
|
|
E('label', { 'style': 'display: block; margin: 12px 0' }, [
|
|
E('input', { 'type': 'checkbox', 'id': 'delete-data-check' }),
|
|
E('span', { 'style': 'margin-left: 8px' }, _('Also delete site data'))
|
|
]),
|
|
E('div', { 'class': 'right' }, [
|
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
|
|
E('button', { 'class': 'cbi-button cbi-button-negative', 'style': 'margin-left: 8px',
|
|
'click': function() {
|
|
var deleteData = document.getElementById('delete-data-check').checked;
|
|
ui.showModal(_('Deleting...'), [E('p', { 'class': 'spinning' }, _('Deleting...'))]);
|
|
api.deleteInstance(name, deleteData).then(function() {
|
|
ui.hideModal();
|
|
window.location.reload();
|
|
});
|
|
}
|
|
}, _('Delete'))
|
|
])
|
|
]);
|
|
},
|
|
|
|
handleToggleInstance: function(inst) {
|
|
var self = this;
|
|
var action = inst.running ? api.stopInstance : api.startInstance;
|
|
var msg = inst.running ? _('Stopping...') : _('Starting...');
|
|
ui.showModal(msg, [E('p', { 'class': 'spinning' }, msg)]);
|
|
action(inst.name).then(function(r) {
|
|
ui.hideModal();
|
|
if (r.success) {
|
|
ui.addNotification(null, E('p', r.message), 'info');
|
|
setTimeout(function() { window.location.reload(); }, 1500);
|
|
} else {
|
|
ui.addNotification(null, E('p', r.error || _('Failed')), 'error');
|
|
}
|
|
});
|
|
},
|
|
|
|
// ─── Backup/Restore ───
|
|
handleBackup: function(instance) {
|
|
ui.showModal(_('Create Backup'), [E('p', { 'class': 'spinning' }, _('Creating backup...'))]);
|
|
api.createBackup(instance || 'default', null).then(function(r) {
|
|
ui.hideModal();
|
|
if (r.success) {
|
|
ui.addNotification(null, E('p', _('Backup created: %s').format(r.name)), 'info');
|
|
window.location.reload();
|
|
} else {
|
|
ui.addNotification(null, E('p', r.error || _('Backup failed')), 'error');
|
|
}
|
|
});
|
|
},
|
|
|
|
handleRestore: function(backupName, instance) {
|
|
ui.showModal(_('Restore Backup'), [
|
|
E('p', {}, _('Restore backup "%s"?').format(backupName)),
|
|
E('p', { 'style': 'color: var(--k-warning)' }, _('This will overwrite current site data!')),
|
|
E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [
|
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
|
|
E('button', { 'class': 'cbi-button cbi-button-action', 'style': 'margin-left: 8px',
|
|
'click': function() {
|
|
ui.showModal(_('Restoring...'), [E('p', { 'class': 'spinning' }, _('Restoring...'))]);
|
|
api.restoreBackup(backupName, instance || 'default').then(function(r) {
|
|
ui.hideModal();
|
|
if (r.success) {
|
|
ui.addNotification(null, E('p', _('Backup restored')), 'info');
|
|
} else {
|
|
ui.addNotification(null, E('p', r.error || _('Restore failed')), 'error');
|
|
}
|
|
});
|
|
}
|
|
}, _('Restore'))
|
|
])
|
|
]);
|
|
},
|
|
|
|
handleDeleteBackup: function(name) {
|
|
ui.showModal(_('Delete Backup'), [
|
|
E('p', {}, _('Delete backup "%s"?').format(name)),
|
|
E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [
|
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
|
|
E('button', { 'class': 'cbi-button cbi-button-negative', 'style': 'margin-left: 8px',
|
|
'click': function() {
|
|
api.deleteBackup(name).then(function(r) {
|
|
ui.hideModal();
|
|
if (r.success) window.location.reload();
|
|
});
|
|
}
|
|
}, _('Delete'))
|
|
])
|
|
]);
|
|
},
|
|
|
|
// ─── GitHub/Gitea Clone ───
|
|
handleGitClone: function(source) {
|
|
var self = this;
|
|
var title = source === 'github' ? _('Clone from GitHub') : _('Clone from Gitea');
|
|
ui.showModal(title, [
|
|
E('div', { 'class': 'k-form' }, [
|
|
E('div', { 'class': 'k-form-group' }, [
|
|
E('label', {}, _('Repository URL')),
|
|
E('input', { 'type': 'text', 'id': 'clone-repo', 'style': 'width: 100%',
|
|
'placeholder': source === 'github' ? 'https://github.com/user/repo.git' : 'http://gitea.local/user/repo.git' })
|
|
]),
|
|
E('div', { 'class': 'k-form-group' }, [
|
|
E('label', {}, _('Instance (existing or new)')),
|
|
E('input', { 'type': 'text', 'id': 'clone-instance', 'style': 'width: 100%',
|
|
'placeholder': 'default' })
|
|
]),
|
|
E('div', { 'class': 'k-form-group' }, [
|
|
E('label', {}, _('Branch')),
|
|
E('input', { 'type': 'text', 'id': 'clone-branch', 'style': 'width: 100%',
|
|
'placeholder': 'main' })
|
|
])
|
|
]),
|
|
E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [
|
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
|
|
E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px',
|
|
'click': function() {
|
|
var repo = document.getElementById('clone-repo').value;
|
|
var instance = document.getElementById('clone-instance').value || 'default';
|
|
var branch = document.getElementById('clone-branch').value || 'main';
|
|
if (!repo) { ui.addNotification(null, E('p', _('Repo URL required')), 'error'); return; }
|
|
ui.showModal(_('Cloning...'), [E('p', { 'class': 'spinning' }, _('Cloning repository...'))]);
|
|
var cloneFn = source === 'github' ? api.gitHubClone : api.gitClone;
|
|
cloneFn(repo, instance, branch).then(function(r) {
|
|
ui.hideModal();
|
|
if (r.success) {
|
|
ui.addNotification(null, E('p', r.message || _('Clone successful')), 'info');
|
|
window.location.reload();
|
|
} else {
|
|
ui.addNotification(null, E('p', r.error || _('Clone failed')), 'error');
|
|
}
|
|
});
|
|
}
|
|
}, _('Clone'))
|
|
])
|
|
]);
|
|
},
|
|
|
|
// ─── Quick Publish ───
|
|
handleQuickPublish: function(instance) {
|
|
ui.showModal(_('Quick Publish'), [
|
|
E('p', { 'class': 'spinning' }, _('Building and publishing...'))
|
|
]);
|
|
api.quickPublish(instance || 'default').then(function(r) {
|
|
ui.hideModal();
|
|
if (r.success) {
|
|
ui.addNotification(null, E('p', _('Published successfully!')), 'info');
|
|
} else {
|
|
ui.addNotification(null, E('p', r.error || _('Publish failed')), 'error');
|
|
}
|
|
});
|
|
},
|
|
|
|
// ─── Gitea Push ───
|
|
handleGiteaPush: function(instance) {
|
|
ui.showModal(_('Push to Gitea'), [
|
|
E('div', { 'class': 'k-form' }, [
|
|
E('div', { 'class': 'k-form-group' }, [
|
|
E('label', {}, _('Commit Message')),
|
|
E('input', { 'type': 'text', 'id': 'push-message', 'style': 'width: 100%',
|
|
'placeholder': 'Update content' })
|
|
])
|
|
]),
|
|
E('div', { 'class': 'right', 'style': 'margin-top: 16px' }, [
|
|
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')),
|
|
E('button', { 'class': 'cbi-button cbi-button-positive', 'style': 'margin-left: 8px',
|
|
'click': function() {
|
|
var msg = document.getElementById('push-message').value || 'Update from SecuBox';
|
|
ui.showModal(_('Pushing...'), [E('p', { 'class': 'spinning' }, _('Pushing to Gitea...'))]);
|
|
api.giteaPush(instance || 'default', msg).then(function(r) {
|
|
ui.hideModal();
|
|
if (r.success) {
|
|
ui.addNotification(null, E('p', r.message || _('Push successful')), 'info');
|
|
} else {
|
|
ui.addNotification(null, E('p', r.error || _('Push failed')), 'error');
|
|
}
|
|
});
|
|
}
|
|
}, _('Push'))
|
|
])
|
|
]);
|
|
},
|
|
|
|
// ─── Service Control ───
|
|
handleServiceToggle: function(status) {
|
|
var self = this;
|
|
var action = status.running ? api.serviceStop : api.serviceStart;
|
|
var msg = status.running ? _('Stopping...') : _('Starting...');
|
|
ui.showModal(msg, [E('p', { 'class': 'spinning' }, msg)]);
|
|
action().then(function(r) {
|
|
ui.hideModal();
|
|
if (r.success) {
|
|
ui.addNotification(null, E('p', r.message), 'info');
|
|
setTimeout(function() { window.location.reload(); }, 2000);
|
|
}
|
|
});
|
|
},
|
|
|
|
// ─── Render ───
|
|
render: function(data) {
|
|
var self = this;
|
|
var instances = data.instances || [];
|
|
var status = data.status || {};
|
|
var stats = data.stats || {};
|
|
var backups = data.backups || [];
|
|
|
|
// ─── Stat Card Helper ───
|
|
var statCard = function(icon, value, label, color) {
|
|
return E('div', { 'class': 'k-stat' }, [
|
|
E('div', { 'class': 'k-stat-icon', 'style': 'color: ' + (color || 'var(--k-accent)') }, icon),
|
|
E('div', { 'class': 'k-stat-value' }, String(value)),
|
|
E('div', { 'class': 'k-stat-label' }, label)
|
|
]);
|
|
};
|
|
|
|
// ─── Instance Card Helper ───
|
|
var instanceCard = function(inst) {
|
|
var statusColor = inst.running ? 'var(--k-green)' : 'var(--k-muted)';
|
|
var statusText = inst.running ? _('Running') : _('Stopped');
|
|
return E('div', { 'class': 'k-card', 'style': 'border-left: 3px solid ' + statusColor },
|
|
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center' }, [
|
|
E('div', {}, [
|
|
E('div', { 'class': 'k-card-title', 'style': 'margin-bottom: 4px' }, [
|
|
inst.title || inst.name,
|
|
E('span', { 'class': 'k-badge', 'style': 'margin-left: 8px; background: ' + statusColor }, statusText)
|
|
]),
|
|
E('div', { 'style': 'color: var(--k-muted); font-size: 12px' }, [
|
|
'Port: ', String(inst.port),
|
|
inst.domain ? [' | ', E('a', { 'href': 'https://' + inst.domain, 'target': '_blank' }, inst.domain)] : ''
|
|
])
|
|
]),
|
|
E('div', { 'style': 'display: flex; gap: 8px' }, [
|
|
E('button', {
|
|
'class': 'k-btn k-btn-sm ' + (inst.running ? 'k-btn-danger' : 'k-btn-success'),
|
|
'click': function() { self.handleToggleInstance(inst); }
|
|
}, inst.running ? '\u25A0' : '\u25B6'),
|
|
E('button', { 'class': 'k-btn k-btn-sm', 'title': _('Quick Publish'),
|
|
'click': function() { self.handleQuickPublish(inst.name); }
|
|
}, '\uD83D\uDE80'),
|
|
E('button', { 'class': 'k-btn k-btn-sm', 'title': _('Backup'),
|
|
'click': function() { self.handleBackup(inst.name); }
|
|
}, '\uD83D\uDCBE'),
|
|
E('a', { 'class': 'k-btn k-btn-sm', 'title': _('Editor'),
|
|
'href': L.url('admin', 'services', 'hexojs', 'editor') + '?instance=' + inst.name
|
|
}, '\u270F'),
|
|
inst.running ? E('a', { 'class': 'k-btn k-btn-sm', 'title': _('Preview'),
|
|
'href': 'http://' + window.location.hostname + ':' + inst.port,
|
|
'target': '_blank'
|
|
}, '\uD83D\uDC41') : '',
|
|
E('button', { 'class': 'k-btn k-btn-sm k-btn-danger', 'title': _('Delete'),
|
|
'click': function() { self.handleDeleteInstance(inst.name); }
|
|
}, '\u2715')
|
|
])
|
|
])
|
|
);
|
|
};
|
|
|
|
// ─── Backup Row Helper ───
|
|
var backupRow = function(bk) {
|
|
var date = bk.timestamp ? new Date(bk.timestamp * 1000).toLocaleString() : '-';
|
|
return E('tr', {}, [
|
|
E('td', {}, bk.name),
|
|
E('td', {}, bk.size),
|
|
E('td', {}, date),
|
|
E('td', { 'style': 'text-align: right' }, [
|
|
E('button', { 'class': 'k-btn k-btn-sm', 'click': function() { self.handleRestore(bk.name); } }, '\u21BA'),
|
|
E('button', { 'class': 'k-btn k-btn-sm k-btn-danger', 'style': 'margin-left: 4px',
|
|
'click': function() { self.handleDeleteBackup(bk.name); }
|
|
}, '\u2715')
|
|
])
|
|
]);
|
|
};
|
|
|
|
// ─── Main Layout ───
|
|
var content = [
|
|
// Header
|
|
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px' }, [
|
|
E('div', {}, [
|
|
E('h2', { 'style': 'margin: 0' }, ['\uD83D\uDCDD ', _('Hexo CMS')]),
|
|
E('p', { 'style': 'color: var(--k-muted); margin: 4px 0 0' }, _('Multi-instance static site generator'))
|
|
]),
|
|
E('div', { 'style': 'display: flex; gap: 8px' }, [
|
|
E('button', {
|
|
'class': 'k-btn ' + (status.running ? 'k-btn-danger' : 'k-btn-success'),
|
|
'click': function() { self.handleServiceToggle(status); }
|
|
}, status.running ? ['\u25A0 ', _('Stop Container')] : ['\u25B6 ', _('Start Container')])
|
|
])
|
|
]),
|
|
|
|
// Stats Grid
|
|
E('div', { 'class': 'k-grid k-grid-4', 'style': 'margin-bottom: 20px' }, [
|
|
statCard('\uD83D\uDCE6', instances.length, _('Instances'), 'var(--k-blue)'),
|
|
statCard('\uD83D\uDCDD', stats.posts || 0, _('Posts'), 'var(--k-green)'),
|
|
statCard('\uD83D\uDCCB', stats.drafts || 0, _('Drafts'), 'var(--k-yellow)'),
|
|
statCard('\uD83D\uDCBE', backups.length, _('Backups'), 'var(--k-purple)')
|
|
]),
|
|
|
|
// Quick Actions
|
|
E('div', { 'class': 'k-card', 'style': 'margin-bottom: 20px' }, [
|
|
E('div', { 'class': 'k-card-title' }, ['\u26A1 ', _('Quick Actions')]),
|
|
E('div', { 'style': 'display: flex; gap: 8px; flex-wrap: wrap' }, [
|
|
E('button', { 'class': 'k-btn k-btn-success', 'click': function() { self.handleCreateInstance(); } },
|
|
['\u2795 ', _('New Instance')]),
|
|
E('button', { 'class': 'k-btn', 'click': function() { self.handleGitClone('github'); } },
|
|
['\uD83D\uDC19 ', _('Clone from GitHub')]),
|
|
E('button', { 'class': 'k-btn', 'click': function() { self.handleGitClone('gitea'); } },
|
|
['\uD83C\uDF75 ', _('Clone from Gitea')]),
|
|
E('a', { 'class': 'k-btn', 'href': L.url('admin', 'services', 'hexojs', 'editor') },
|
|
['\u270F ', _('New Post')]),
|
|
E('a', { 'class': 'k-btn', 'href': L.url('admin', 'services', 'hexojs', 'settings') },
|
|
['\u2699 ', _('Settings')])
|
|
])
|
|
]),
|
|
|
|
// Instances Section
|
|
E('div', { 'class': 'k-card', 'style': 'margin-bottom: 20px' }, [
|
|
E('div', { 'class': 'k-card-title' }, ['\uD83D\uDCE6 ', _('Instances')]),
|
|
instances.length > 0
|
|
? E('div', { 'style': 'display: flex; flex-direction: column; gap: 12px' },
|
|
instances.map(instanceCard))
|
|
: E('div', { 'class': 'k-empty' }, [
|
|
E('div', { 'style': 'font-size: 48px; margin-bottom: 12px' }, '\uD83D\uDCE6'),
|
|
E('p', {}, _('No instances yet. Create your first instance!')),
|
|
E('button', { 'class': 'k-btn k-btn-success', 'click': function() { self.handleCreateInstance(); } },
|
|
['\u2795 ', _('Create Instance')])
|
|
])
|
|
]),
|
|
|
|
// Backups Section
|
|
E('div', { 'class': 'k-card' }, [
|
|
E('div', { 'class': 'k-card-title' }, ['\uD83D\uDCBE ', _('Backups')]),
|
|
backups.length > 0
|
|
? E('table', { 'class': 'k-table' }, [
|
|
E('thead', {}, E('tr', {}, [
|
|
E('th', {}, _('Name')),
|
|
E('th', {}, _('Size')),
|
|
E('th', {}, _('Date')),
|
|
E('th', { 'style': 'text-align: right' }, _('Actions'))
|
|
])),
|
|
E('tbody', {}, backups.map(backupRow))
|
|
])
|
|
: E('div', { 'class': 'k-empty' }, [
|
|
E('div', { 'style': 'font-size: 48px; margin-bottom: 12px' }, '\uD83D\uDCBE'),
|
|
E('p', {}, _('No backups yet.'))
|
|
])
|
|
])
|
|
];
|
|
|
|
return KissTheme.wrap(content, 'admin/services/hexojs');
|
|
},
|
|
|
|
handleSaveApply: null,
|
|
handleSave: null,
|
|
handleReset: null
|
|
});
|