Files
duplicati_preview/public/app.js
T

589 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
`;
});