589 lines
20 KiB
JavaScript
589 lines
20 KiB
JavaScript
import {
|
||
createFeedbackController,
|
||
escapeHtml,
|
||
fetchJson,
|
||
linkToSource,
|
||
renderSourceSummaryCard
|
||
} from './modules/common.js';
|
||
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
|
||
|
||
const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults', 'tls']);
|
||
|
||
const state = {
|
||
sources: [],
|
||
serverDb: null,
|
||
globalDefaults: null,
|
||
tls: null,
|
||
currentView: 'overview'
|
||
};
|
||
|
||
const uploadForm = document.querySelector('#uploadForm');
|
||
const uploadButton = document.querySelector('#uploadButton');
|
||
const databaseInput = document.querySelector('#databaseInput');
|
||
const serverDbForm = document.querySelector('#serverDbForm');
|
||
const serverDbUploadButton = document.querySelector('#serverDbUploadButton');
|
||
const serverDbInput = document.querySelector('#serverDbInput');
|
||
const globalDefaultsForm = document.querySelector('#globalDefaultsForm');
|
||
const globalDefaultsSaveButton = document.querySelector('#globalDefaultsSaveButton');
|
||
const tlsJsonForm = document.querySelector('#tlsJsonForm');
|
||
const tlsUploadForm = document.querySelector('#tlsUploadForm');
|
||
const tlsSaveButton = document.querySelector('#tlsSaveButton');
|
||
const tlsUploadButton = document.querySelector('#tlsUploadButton');
|
||
const tlsDeleteCustomButton = document.querySelector('#tlsDeleteCustomButton');
|
||
const refreshButton = document.querySelector('#refreshButton');
|
||
const serverDbSummaryElement = document.querySelector('#serverDbSummary');
|
||
const globalDefaultsSummaryElement = document.querySelector('#globalDefaultsSummary');
|
||
const tlsSummaryElement = document.querySelector('#tlsSummary');
|
||
const overviewSummaryElement = document.querySelector('#overviewSummary');
|
||
const sourceListElement = document.querySelector('#sourceList');
|
||
const authStatusElement = document.querySelector('#authStatus');
|
||
const logoutButton = document.querySelector('#logoutButton');
|
||
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
|
||
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
|
||
const feedback = createFeedbackController(document.querySelector('#feedback'));
|
||
|
||
function normalizeView(value) {
|
||
return CONTROL_VIEWS.has(value) ? value : 'overview';
|
||
}
|
||
|
||
function setCurrentView(nextView, { syncHash = true, replace = false } = {}) {
|
||
state.currentView = normalizeView(nextView);
|
||
|
||
for (const panel of viewPanels) {
|
||
panel.hidden = panel.dataset.viewPanel !== state.currentView;
|
||
}
|
||
|
||
for (const link of viewLinks) {
|
||
const active = link.dataset.viewLink === state.currentView;
|
||
link.classList.toggle('is-active', active);
|
||
link.setAttribute('aria-current', active ? 'page' : 'false');
|
||
}
|
||
|
||
if (!syncHash) {
|
||
return;
|
||
}
|
||
|
||
const url = new URL(window.location.href);
|
||
url.hash = `#${state.currentView}`;
|
||
if (replace) {
|
||
window.history.replaceState(null, '', url);
|
||
} else {
|
||
window.history.pushState(null, '', url);
|
||
}
|
||
}
|
||
|
||
function renderSummaryRows(rows) {
|
||
return `
|
||
<div class="summary-stack">
|
||
${rows
|
||
.map(
|
||
(row) => `
|
||
<div class="summary-row">
|
||
<span>${escapeHtml(row.label)}</span>
|
||
<strong>${escapeHtml(row.value)}</strong>
|
||
</div>
|
||
`
|
||
)
|
||
.join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function readGlobalDefaultsForm() {
|
||
return {
|
||
webdavBaseUrl: document.querySelector('#globalWebdavBaseUrl')?.value?.trim() ?? '',
|
||
authMode: document.querySelector('#globalAuthMode')?.value ?? '',
|
||
username: document.querySelector('#globalUsername')?.value?.trim() ?? '',
|
||
password: document.querySelector('#globalPassword')?.value ?? '',
|
||
passphrase: document.querySelector('#globalPassphrase')?.value ?? ''
|
||
};
|
||
}
|
||
|
||
function writeGlobalDefaultsForm(defaults) {
|
||
document.querySelector('#globalWebdavBaseUrl').value = defaults?.webdavBaseUrl ?? '';
|
||
document.querySelector('#globalAuthMode').value = defaults?.authMode ?? '';
|
||
document.querySelector('#globalUsername').value = '';
|
||
document.querySelector('#globalPassword').value = '';
|
||
document.querySelector('#globalPassphrase').value = '';
|
||
}
|
||
|
||
function readTlsJsonForm() {
|
||
return {
|
||
mode: document.querySelector('#tlsMode')?.value ?? 'self-signed',
|
||
primaryDomain: document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? '',
|
||
subjectAltNames: document.querySelector('#tlsSubjectAltNames')?.value ?? '',
|
||
certPem: document.querySelector('#tlsCertPem')?.value ?? '',
|
||
keyPem: document.querySelector('#tlsKeyPem')?.value ?? '',
|
||
chainPem: document.querySelector('#tlsChainPem')?.value ?? ''
|
||
};
|
||
}
|
||
|
||
function writeTlsForm(tlsSummary) {
|
||
document.querySelector('#tlsMode').value = tlsSummary?.mode ?? 'self-signed';
|
||
document.querySelector('#tlsPrimaryDomain').value = tlsSummary?.primaryDomain ?? '';
|
||
document.querySelector('#tlsSubjectAltNames').value = Array.isArray(tlsSummary?.subjectAltNames)
|
||
? tlsSummary.subjectAltNames.join('\n')
|
||
: '';
|
||
document.querySelector('#tlsCertPem').value = '';
|
||
document.querySelector('#tlsKeyPem').value = '';
|
||
document.querySelector('#tlsChainPem').value = '';
|
||
}
|
||
|
||
function renderOverviewSummary() {
|
||
const sourceCount = state.sources.length;
|
||
const readyCount = state.sources.filter((source) => source.enhancement?.status === 'ready').length;
|
||
const failedCount = state.sources.filter((source) => source.enhancement?.status === 'failed').length;
|
||
const mappedCount = state.sources.filter((source) => source.displayNameSource === 'server-db').length;
|
||
|
||
overviewSummaryElement.innerHTML = renderSummaryRows([
|
||
{ label: '数据源总数', value: String(sourceCount) },
|
||
{ label: '已增强数据源', value: String(readyCount) },
|
||
{ label: '增强失败', value: String(failedCount) },
|
||
{ label: '已映射任务名', value: String(mappedCount) },
|
||
{ label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : '未上传' },
|
||
{ label: '全局 WebDAV', value: state.globalDefaults?.configured ? '已配置' : '未配置' },
|
||
{ label: '当前证书来源', value: state.tls?.activeSource ?? '不可用' }
|
||
]);
|
||
}
|
||
|
||
function renderServerDbSummary() {
|
||
if (!state.serverDb?.available) {
|
||
serverDbSummaryElement.innerHTML = renderSummaryRows([
|
||
{ label: 'Status', value: 'No server database uploaded yet' },
|
||
{ label: '说明', value: '未上传 Duplicati-server.sqlite 之前,任务名和推导目标 URL 不可用。' }
|
||
]);
|
||
return;
|
||
}
|
||
|
||
serverDbSummaryElement.innerHTML = renderSummaryRows([
|
||
{ label: '当前文件', value: state.serverDb.originalFilename },
|
||
{ label: '上传时间', value: state.serverDb.uploadedAt },
|
||
{ label: '任务条目数', value: String(state.serverDb.backupCount) }
|
||
]);
|
||
}
|
||
|
||
function renderGlobalDefaultsSummary() {
|
||
const defaults = state.globalDefaults;
|
||
if (!defaults?.configured) {
|
||
writeGlobalDefaultsForm(null);
|
||
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
|
||
{ label: '状态', value: '尚未保存全局默认值' },
|
||
{ label: '说明', value: '如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存。' }
|
||
]);
|
||
return;
|
||
}
|
||
|
||
writeGlobalDefaultsForm(defaults);
|
||
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
|
||
{ label: '兜底 URL', value: defaults.webdavBaseUrl ?? '未设置' },
|
||
{ label: '认证方式', value: defaults.authMode ?? '自动' },
|
||
{
|
||
label: '已保存凭据',
|
||
value: `username=${defaults.hasUsername ? 'yes' : 'no'}, password=${defaults.hasPassword ? 'yes' : 'no'}, passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}`
|
||
},
|
||
{ label: '更新时间', value: defaults.updatedAt ?? '未知' }
|
||
]);
|
||
}
|
||
|
||
function renderTlsSummary() {
|
||
if (!state.tls) {
|
||
tlsSummaryElement.innerHTML = renderSummaryRows([{ label: '状态', value: 'TLS 配置尚未加载' }]);
|
||
return;
|
||
}
|
||
|
||
writeTlsForm(state.tls);
|
||
tlsDeleteCustomButton.disabled = !state.tls.hasCustomCertificate;
|
||
tlsSummaryElement.innerHTML = renderSummaryRows([
|
||
{ label: 'HTTP 访问地址', value: state.tls.access?.httpBaseUrl ?? '未知' },
|
||
{ label: 'HTTPS 访问地址', value: state.tls.access?.httpsBaseUrl ?? '未知' },
|
||
{ label: '配置模式', value: state.tls.mode ?? 'self-signed' },
|
||
{ label: '当前生效证书', value: state.tls.activeSource ?? 'self-signed' },
|
||
{ label: '主域名', value: state.tls.primaryDomain || 'localhost' },
|
||
{ label: '额外 SAN', value: (state.tls.subjectAltNames ?? []).join(', ') || '无' },
|
||
{
|
||
label: '有效期',
|
||
value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : '未知'
|
||
},
|
||
{ label: '指纹', value: state.tls.certificate?.fingerprint256 ?? '未知' },
|
||
{ label: '最近错误', value: state.tls.lastErrorMessage ?? '无' },
|
||
{ label: 'Cookie 说明', value: state.tls.cookieNote ?? 'HTTP/HTTPS 共用登录态' }
|
||
]);
|
||
}
|
||
|
||
function renderSources() {
|
||
if (state.sources.length === 0) {
|
||
sourceListElement.innerHTML = `
|
||
<div class="empty-state-card">
|
||
<p>No task sources uploaded yet.</p>
|
||
<p>Upload a Duplicati task database first, then open the source workbench to browse, enhance, preview, or download.</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
sourceListElement.innerHTML = state.sources
|
||
.map((source) => {
|
||
const targetHint = source.webdav?.effectiveWebdavBaseUrl
|
||
? `<div class="summary-chip">默认目标 URL 来源:${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
|
||
: '<div class="summary-chip">暂无可用的默认目标 URL</div>';
|
||
|
||
return renderSourceSummaryCard(
|
||
source,
|
||
`
|
||
${targetHint}
|
||
<div class="card-actions">
|
||
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">进入工作台</a>
|
||
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">删除 source</button>
|
||
</div>
|
||
`
|
||
);
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
async function loadServerDbSummary() {
|
||
const payload = await fetchJson('/api/server-db');
|
||
state.serverDb = payload.serverDb;
|
||
renderServerDbSummary();
|
||
renderOverviewSummary();
|
||
}
|
||
|
||
async function loadGlobalDefaults() {
|
||
const payload = await fetchJson('/api/webdav-defaults');
|
||
state.globalDefaults = payload.defaults;
|
||
renderGlobalDefaultsSummary();
|
||
renderOverviewSummary();
|
||
}
|
||
|
||
async function loadTlsSummary() {
|
||
const payload = await fetchJson('/api/system/tls');
|
||
state.tls = payload.tls;
|
||
renderTlsSummary();
|
||
renderOverviewSummary();
|
||
}
|
||
|
||
async function loadSources() {
|
||
const payload = await fetchJson('/api/sources');
|
||
state.sources = payload.sources;
|
||
renderSources();
|
||
renderOverviewSummary();
|
||
}
|
||
|
||
async function handleTaskDbUpload(event) {
|
||
event.preventDefault();
|
||
feedback.clear();
|
||
|
||
const file = databaseInput.files?.[0];
|
||
if (!file) {
|
||
feedback.set('error', '请先选择一个任务 SQLite 文件。');
|
||
return;
|
||
}
|
||
|
||
uploadButton.disabled = true;
|
||
refreshButton.disabled = true;
|
||
feedback.set('info', '正在上传任务数据库...');
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.set('database', file);
|
||
const response = await fetch('/api/sources/upload', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const payload = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`);
|
||
}
|
||
|
||
await Promise.all([loadSources(), loadServerDbSummary()]);
|
||
uploadForm.reset();
|
||
setCurrentView('sources');
|
||
feedback.set(
|
||
payload.reused ? 'info' : 'success',
|
||
payload.reused
|
||
? '这个任务数据库之前已经上传过,系统复用了现有 source。'
|
||
: '任务数据库上传成功。'
|
||
);
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
} finally {
|
||
uploadButton.disabled = false;
|
||
refreshButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function handleServerDbUpload(event) {
|
||
event.preventDefault();
|
||
feedback.clear();
|
||
|
||
const file = serverDbInput.files?.[0];
|
||
if (!file) {
|
||
feedback.set('error', '请先选择 Duplicati-server.sqlite。');
|
||
return;
|
||
}
|
||
|
||
serverDbUploadButton.disabled = true;
|
||
feedback.set('info', '正在上传 Server DB 并刷新任务名映射...');
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.set('database', file);
|
||
const response = await fetch('/api/server-db/upload', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const payload = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`);
|
||
}
|
||
|
||
await Promise.all([loadSources(), loadServerDbSummary()]);
|
||
serverDbForm.reset();
|
||
feedback.set('success', 'Server DB 上传成功。');
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
} finally {
|
||
serverDbUploadButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function handleGlobalDefaultsSave(event) {
|
||
event.preventDefault();
|
||
feedback.clear();
|
||
globalDefaultsSaveButton.disabled = true;
|
||
feedback.set('info', '正在保存全局 WebDAV 默认值...');
|
||
|
||
try {
|
||
const payload = await fetchJson('/api/webdav-defaults', {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(readGlobalDefaultsForm())
|
||
});
|
||
|
||
state.globalDefaults = payload.defaults;
|
||
renderGlobalDefaultsSummary();
|
||
renderOverviewSummary();
|
||
await loadSources();
|
||
feedback.set(
|
||
payload.defaults.configured ? 'success' : 'info',
|
||
payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。'
|
||
);
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
} finally {
|
||
globalDefaultsSaveButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function handleTlsJsonSave(event) {
|
||
event.preventDefault();
|
||
feedback.clear();
|
||
tlsSaveButton.disabled = true;
|
||
tlsUploadButton.disabled = true;
|
||
feedback.set('info', '正在保存 TLS 配置并应用证书...');
|
||
|
||
try {
|
||
const payload = await fetchJson('/api/system/tls', {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(readTlsJsonForm())
|
||
});
|
||
state.tls = payload.tls;
|
||
renderTlsSummary();
|
||
renderOverviewSummary();
|
||
feedback.set('success', `TLS 配置已应用,当前证书来源:${payload.tls.activeSource}。`);
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
} finally {
|
||
tlsSaveButton.disabled = false;
|
||
tlsUploadButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function handleTlsUpload(event) {
|
||
event.preventDefault();
|
||
feedback.clear();
|
||
|
||
const certificateFile = document.querySelector('#tlsCertificateInput')?.files?.[0];
|
||
const privateKeyFile = document.querySelector('#tlsPrivateKeyInput')?.files?.[0];
|
||
const chainFile = document.querySelector('#tlsChainInput')?.files?.[0] ?? null;
|
||
|
||
if (!certificateFile || !privateKeyFile) {
|
||
feedback.set('error', '请同时上传证书文件和私钥文件。');
|
||
return;
|
||
}
|
||
|
||
tlsSaveButton.disabled = true;
|
||
tlsUploadButton.disabled = true;
|
||
feedback.set('info', '正在上传自定义证书...');
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.set('primaryDomain', document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? '');
|
||
formData.set('subjectAltNames', document.querySelector('#tlsSubjectAltNames')?.value ?? '');
|
||
formData.set('certificate', certificateFile);
|
||
formData.set('privateKey', privateKeyFile);
|
||
if (chainFile) {
|
||
formData.set('chain', chainFile);
|
||
}
|
||
|
||
const response = await fetch('/api/system/tls/upload', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(`${payload.error?.code ?? 'TLS_UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`);
|
||
}
|
||
|
||
state.tls = payload.tls;
|
||
renderTlsSummary();
|
||
renderOverviewSummary();
|
||
tlsUploadForm.reset();
|
||
feedback.set('success', '自定义证书已上传并生效。');
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
} finally {
|
||
tlsSaveButton.disabled = false;
|
||
tlsUploadButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function handleDeleteCustomTls() {
|
||
feedback.clear();
|
||
if (!window.confirm('确定要删除当前自定义证书,并回退到自签证书吗?')) {
|
||
return;
|
||
}
|
||
|
||
tlsDeleteCustomButton.disabled = true;
|
||
feedback.set('info', '正在删除自定义证书,并切回自签证书...');
|
||
|
||
try {
|
||
const payload = await fetchJson('/api/system/tls/custom-certificate', {
|
||
method: 'DELETE'
|
||
});
|
||
state.tls = payload.tls;
|
||
renderTlsSummary();
|
||
renderOverviewSummary();
|
||
feedback.set('success', '自定义证书已删除,系统已回退到自签证书。');
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
} finally {
|
||
tlsDeleteCustomButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function deleteSource(sourceId) {
|
||
feedback.clear();
|
||
const source = state.sources.find((item) => item.id === sourceId);
|
||
const label = source?.displayName || source?.originalFilename || sourceId;
|
||
const confirmed = window.confirm(
|
||
`确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前 source 及其本地增强数据,不会删除 Duplicati-server.sqlite。`
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
feedback.set('info', `正在删除 ${label}...`);
|
||
|
||
try {
|
||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, {
|
||
method: 'DELETE'
|
||
});
|
||
await Promise.all([loadSources(), loadServerDbSummary()]);
|
||
feedback.set('success', `已删除 ${payload.deleted.originalFilename},Server DB 保持不变。`);
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
}
|
||
}
|
||
|
||
uploadForm.addEventListener('submit', (event) => {
|
||
void handleTaskDbUpload(event);
|
||
});
|
||
|
||
serverDbForm.addEventListener('submit', (event) => {
|
||
void handleServerDbUpload(event);
|
||
});
|
||
|
||
globalDefaultsForm.addEventListener('submit', (event) => {
|
||
void handleGlobalDefaultsSave(event);
|
||
});
|
||
|
||
tlsJsonForm.addEventListener('submit', (event) => {
|
||
void handleTlsJsonSave(event);
|
||
});
|
||
|
||
tlsUploadForm.addEventListener('submit', (event) => {
|
||
void handleTlsUpload(event);
|
||
});
|
||
|
||
tlsDeleteCustomButton.addEventListener('click', () => {
|
||
void handleDeleteCustomTls();
|
||
});
|
||
|
||
refreshButton.addEventListener('click', () => {
|
||
feedback.clear();
|
||
sourceListElement.textContent = '正在刷新数据源列表...';
|
||
void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary()]).catch((error) => {
|
||
feedback.set('error', error.message);
|
||
});
|
||
});
|
||
|
||
sourceListElement.addEventListener('click', (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const actionElement = target.closest('[data-delete-source-id]');
|
||
if (!(actionElement instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const sourceId = actionElement.dataset.deleteSourceId;
|
||
if (!sourceId) {
|
||
return;
|
||
}
|
||
|
||
void deleteSource(sourceId);
|
||
});
|
||
|
||
for (const link of viewLinks) {
|
||
link.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
setCurrentView(normalizeView(link.dataset.viewLink ?? 'overview'));
|
||
});
|
||
}
|
||
|
||
window.addEventListener('hashchange', () => {
|
||
setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false });
|
||
});
|
||
|
||
async function bootstrap() {
|
||
const initialView = normalizeView(window.location.hash.slice(1) || 'overview');
|
||
setCurrentView(initialView, { replace: true });
|
||
|
||
const authentication = await loadAuthState();
|
||
authStatusElement.textContent = authentication.authenticated
|
||
? `已登录:${authentication.username}`
|
||
: '未登录';
|
||
bindLogoutButton(logoutButton, authStatusElement);
|
||
|
||
await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary(), loadSources()]);
|
||
}
|
||
|
||
void bootstrap().catch((error) => {
|
||
feedback.set('error', error.message);
|
||
sourceListElement.innerHTML = `
|
||
<div class="empty-state-card">
|
||
<p>无法加载控制台。</p>
|
||
</div>
|
||
`;
|
||
});
|