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 `
${rows
.map(
(row) => `
${escapeHtml(row.label)}
${escapeHtml(row.value)}
`
)
.join('')}
`;
}
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 = `
No task sources uploaded yet.
Upload a Duplicati task database first, then open the source workbench to browse, enhance, preview, or download.
`;
return;
}
sourceListElement.innerHTML = state.sources
.map((source) => {
const targetHint = source.webdav?.effectiveWebdavBaseUrl
? `默认目标 URL 来源:${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}
`
: '暂无可用的默认目标 URL
';
return renderSourceSummaryCard(
source,
`
${targetHint}
`
);
})
.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 = `
`;
});