feat: add auth and dual-stack tls management
This commit is contained in:
+257
-118
@@ -5,13 +5,15 @@ import {
|
||||
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']);
|
||||
const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults', 'tls']);
|
||||
|
||||
const state = {
|
||||
sources: [],
|
||||
serverDb: null,
|
||||
globalDefaults: null,
|
||||
tls: null,
|
||||
currentView: 'overview'
|
||||
};
|
||||
|
||||
@@ -23,11 +25,19 @@ 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'));
|
||||
@@ -62,6 +72,23 @@ function setCurrentView(nextView, { syncHash = true, replace = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
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() ?? '',
|
||||
@@ -80,131 +107,115 @@ function writeGlobalDefaultsForm(defaults) {
|
||||
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;
|
||||
const serverDbAvailable = Boolean(state.serverDb?.available);
|
||||
const defaultsConfigured = Boolean(state.globalDefaults?.configured);
|
||||
|
||||
overviewSummaryElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>当前 source 数量</span>
|
||||
<strong>${escapeHtml(String(sourceCount))}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>已增强完成</span>
|
||||
<strong>${escapeHtml(String(readyCount))}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>增强失败</span>
|
||||
<strong>${escapeHtml(String(failedCount))}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>已映射任务名</span>
|
||||
<strong>${escapeHtml(String(mappedCount))}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>Server DB</span>
|
||||
<strong>${serverDbAvailable ? escapeHtml(state.serverDb.originalFilename) : '未上传'}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>全局 WebDAV 默认值</span>
|
||||
<strong>${defaultsConfigured ? '已配置' : '未配置'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
overviewSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Total sources', value: String(sourceCount) },
|
||||
{ label: 'Enhanced sources', value: String(readyCount) },
|
||||
{ label: 'Failed enhancements', value: String(failedCount) },
|
||||
{ label: 'Mapped task names', value: String(mappedCount) },
|
||||
{ label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : 'Not uploaded' },
|
||||
{ label: 'Global WebDAV', value: state.globalDefaults?.configured ? 'Configured' : 'Not configured' },
|
||||
{ label: 'Active certificate', value: state.tls?.activeSource ?? 'Unavailable' }
|
||||
]);
|
||||
}
|
||||
|
||||
function renderServerDbSummary() {
|
||||
if (!state.serverDb || !state.serverDb.available) {
|
||||
serverDbSummaryElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>当前状态</span>
|
||||
<strong>尚未上传 Duplicati-server.sqlite</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>说明</span>
|
||||
<strong>没有它也能浏览和增强,只是任务名和默认目标 URL 无法自动映射。</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
if (!state.serverDb?.available) {
|
||||
serverDbSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Status', value: 'No server database uploaded yet' },
|
||||
{ label: 'Note', value: 'Task names and inferred target URLs stay unavailable until you upload Duplicati-server.sqlite.' }
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
serverDbSummaryElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>当前文件</span>
|
||||
<strong>${escapeHtml(state.serverDb.originalFilename)}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>上传时间</span>
|
||||
<strong>${escapeHtml(state.serverDb.uploadedAt)}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>任务数量</span>
|
||||
<strong>${escapeHtml(String(state.serverDb.backupCount))}</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
serverDbSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Current file', value: state.serverDb.originalFilename },
|
||||
{ label: 'Uploaded at', value: state.serverDb.uploadedAt },
|
||||
{ label: 'Backup entries', value: String(state.serverDb.backupCount) }
|
||||
]);
|
||||
}
|
||||
|
||||
function renderGlobalDefaultsSummary() {
|
||||
const defaults = state.globalDefaults;
|
||||
if (!defaults?.configured) {
|
||||
globalDefaultsSummaryElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>当前状态</span>
|
||||
<strong>尚未保存全局默认值</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>说明</span>
|
||||
<strong>保存一次后,后续 source 可以继承这里的认证信息和备份口令。</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
writeGlobalDefaultsForm(null);
|
||||
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Status', value: 'No global defaults saved' },
|
||||
{ label: 'Note', value: 'Save shared credentials here if most tasks use the same WebDAV account and passphrase.' }
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
writeGlobalDefaultsForm(defaults);
|
||||
globalDefaultsSummaryElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>Fallback URL</span>
|
||||
<strong>${escapeHtml(defaults.webdavBaseUrl ?? '未设置')}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>认证方式</span>
|
||||
<strong>${escapeHtml(defaults.authMode ?? '自动')}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>已保存凭据</span>
|
||||
<strong>
|
||||
username=${defaults.hasUsername ? 'yes' : 'no'},
|
||||
password=${defaults.hasPassword ? 'yes' : 'no'},
|
||||
passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>更新时间</span>
|
||||
<strong>${escapeHtml(defaults.updatedAt ?? '未知')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Fallback URL', value: defaults.webdavBaseUrl ?? 'Not set' },
|
||||
{ label: 'Auth mode', value: defaults.authMode ?? 'Auto' },
|
||||
{
|
||||
label: 'Stored secrets',
|
||||
value: `username=${defaults.hasUsername ? 'yes' : 'no'}, password=${defaults.hasPassword ? 'yes' : 'no'}, passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}`
|
||||
},
|
||||
{ label: 'Updated at', value: defaults.updatedAt ?? 'Unknown' }
|
||||
]);
|
||||
}
|
||||
|
||||
function renderTlsSummary() {
|
||||
if (!state.tls) {
|
||||
tlsSummaryElement.innerHTML = renderSummaryRows([{ label: 'Status', value: 'TLS settings not loaded yet' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
writeTlsForm(state.tls);
|
||||
tlsDeleteCustomButton.disabled = !state.tls.hasCustomCertificate;
|
||||
tlsSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'HTTP endpoint', value: state.tls.access?.httpBaseUrl ?? 'Unknown' },
|
||||
{ label: 'HTTPS endpoint', value: state.tls.access?.httpsBaseUrl ?? 'Unknown' },
|
||||
{ label: 'Configured mode', value: state.tls.mode ?? 'self-signed' },
|
||||
{ label: 'Active certificate', value: state.tls.activeSource ?? 'self-signed' },
|
||||
{ label: 'Primary domain', value: state.tls.primaryDomain || 'localhost' },
|
||||
{ label: 'Extra SAN entries', value: (state.tls.subjectAltNames ?? []).join(', ') || 'None' },
|
||||
{
|
||||
label: 'Validity',
|
||||
value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : 'Unknown'
|
||||
},
|
||||
{ label: 'Fingerprint', value: state.tls.certificate?.fingerprint256 ?? 'Unknown' },
|
||||
{ label: 'Last error', value: state.tls.lastErrorMessage ?? 'None' },
|
||||
{ label: 'Cookie policy', value: state.tls.cookieNote ?? 'Shared between HTTP and HTTPS' }
|
||||
]);
|
||||
}
|
||||
|
||||
function renderSources() {
|
||||
if (state.sources.length === 0) {
|
||||
sourceListElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>还没有任何任务数据源。</p>
|
||||
<p>先上传一个随机名任务库,然后再进入工作台浏览目录、做增强或进入文件页预览下载。</p>
|
||||
<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;
|
||||
@@ -213,16 +224,16 @@ function renderSources() {
|
||||
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>';
|
||||
? `<div class="summary-chip">Default target URL source: ${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
|
||||
: '<div class="summary-chip">No effective default target URL yet</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>
|
||||
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">Open workspace</a>
|
||||
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">Delete source</button>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
@@ -244,6 +255,13 @@ async function loadGlobalDefaults() {
|
||||
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;
|
||||
@@ -257,13 +275,13 @@ async function handleTaskDbUpload(event) {
|
||||
|
||||
const file = databaseInput.files?.[0];
|
||||
if (!file) {
|
||||
feedback.set('error', '请先选择一个任务 SQLite 文件。');
|
||||
feedback.set('error', 'Choose a task SQLite file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
uploadButton.disabled = true;
|
||||
refreshButton.disabled = true;
|
||||
feedback.set('info', '正在上传任务数据库...');
|
||||
feedback.set('info', 'Uploading task database...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -284,8 +302,8 @@ async function handleTaskDbUpload(event) {
|
||||
feedback.set(
|
||||
payload.reused ? 'info' : 'success',
|
||||
payload.reused
|
||||
? '这个任务库之前已经上传过,系统复用了现有 source。'
|
||||
: '任务数据库上传成功,可以进入对应工作台继续操作。'
|
||||
? 'That task database was already uploaded, so the existing source was reused.'
|
||||
: 'Task database uploaded successfully.'
|
||||
);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
@@ -301,12 +319,12 @@ async function handleServerDbUpload(event) {
|
||||
|
||||
const file = serverDbInput.files?.[0];
|
||||
if (!file) {
|
||||
feedback.set('error', '请先选择 Duplicati-server.sqlite。');
|
||||
feedback.set('error', 'Choose Duplicati-server.sqlite first.');
|
||||
return;
|
||||
}
|
||||
|
||||
serverDbUploadButton.disabled = true;
|
||||
feedback.set('info', '正在上传 Server DB 并刷新任务名与目标 URL 映射...');
|
||||
feedback.set('info', 'Uploading Server DB and refreshing task-name mappings...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -323,7 +341,7 @@ async function handleServerDbUpload(event) {
|
||||
|
||||
await Promise.all([loadSources(), loadServerDbSummary()]);
|
||||
serverDbForm.reset();
|
||||
feedback.set('success', 'Server DB 上传成功,任务名和默认目标 URL 已刷新。');
|
||||
feedback.set('success', 'Server DB uploaded successfully.');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
@@ -335,7 +353,7 @@ async function handleGlobalDefaultsSave(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
globalDefaultsSaveButton.disabled = true;
|
||||
feedback.set('info', '正在保存全局 WebDAV 默认值...');
|
||||
feedback.set('info', 'Saving global WebDAV defaults...');
|
||||
|
||||
try {
|
||||
const payload = await fetchJson('/api/webdav-defaults', {
|
||||
@@ -352,7 +370,7 @@ async function handleGlobalDefaultsSave(event) {
|
||||
await loadSources();
|
||||
feedback.set(
|
||||
payload.defaults.configured ? 'success' : 'info',
|
||||
payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。'
|
||||
payload.defaults.configured ? 'Global defaults saved.' : 'Global defaults cleared.'
|
||||
);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
@@ -361,23 +379,125 @@ async function handleGlobalDefaultsSave(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTlsJsonSave(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
tlsSaveButton.disabled = true;
|
||||
tlsUploadButton.disabled = true;
|
||||
feedback.set('info', 'Saving TLS configuration and applying certificate...');
|
||||
|
||||
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 settings applied. Active certificate source: ${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', 'Upload both the certificate file and the private key file.');
|
||||
return;
|
||||
}
|
||||
|
||||
tlsSaveButton.disabled = true;
|
||||
tlsUploadButton.disabled = true;
|
||||
feedback.set('info', 'Uploading custom certificate...');
|
||||
|
||||
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', 'Custom certificate uploaded and applied.');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
tlsSaveButton.disabled = false;
|
||||
tlsUploadButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCustomTls() {
|
||||
feedback.clear();
|
||||
if (!window.confirm('Delete the stored custom certificate and fall back to a self-signed certificate?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
tlsDeleteCustomButton.disabled = true;
|
||||
feedback.set('info', 'Deleting custom certificate and switching back to self-signed...');
|
||||
|
||||
try {
|
||||
const payload = await fetchJson('/api/system/tls/custom-certificate', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
state.tls = payload.tls;
|
||||
renderTlsSummary();
|
||||
renderOverviewSummary();
|
||||
feedback.set('success', 'Custom certificate removed. The service is back on a self-signed certificate.');
|
||||
} 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这只会删除当前任务库和本地增强副本,不会删除 Duplicati-server.sqlite。`);
|
||||
const confirmed = window.confirm(
|
||||
`Delete this source?\n\n${label}\n\nThis removes only the selected source and its local enhancement data. It does not delete Duplicati-server.sqlite.`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.set('info', `正在删除 ${label}...`);
|
||||
feedback.set('info', `Deleting ${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 保持不变。`);
|
||||
feedback.set('success', `Deleted ${payload.deleted.originalFilename}. Server DB was kept untouched.`);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
}
|
||||
@@ -395,10 +515,22 @@ 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()]).catch((error) => {
|
||||
sourceListElement.textContent = 'Refreshing sources...';
|
||||
void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary()]).catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
});
|
||||
});
|
||||
@@ -436,14 +568,21 @@ window.addEventListener('hashchange', () => {
|
||||
async function bootstrap() {
|
||||
const initialView = normalizeView(window.location.hash.slice(1) || 'overview');
|
||||
setCurrentView(initialView, { replace: true });
|
||||
await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadSources()]);
|
||||
|
||||
const authentication = await loadAuthState();
|
||||
authStatusElement.textContent = authentication.authenticated
|
||||
? `Signed in as ${authentication.username}`
|
||||
: 'Not signed in';
|
||||
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>
|
||||
<p>Could not load the control center.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
+7
-2
@@ -22,10 +22,15 @@
|
||||
<h1 id="fileTitle">文件预览与下载</h1>
|
||||
</div>
|
||||
<p class="lead">
|
||||
这里负责浏览器直连 WebDAV 的视频预览、下载、本地缓存和预览图回写。大流量文件流仍然只走前端,不经过后端文件代理。
|
||||
这里负责浏览器直连 WebDAV 的视频预览、下载、本地缓存和预览图回写。大流量文件流依然只走前端,不经过后端文件代理。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="auth-toolbar">
|
||||
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
|
||||
<button id="logoutButton" class="ghost-button" type="button">退出登录</button>
|
||||
</div>
|
||||
|
||||
<div id="feedback" class="feedback" hidden></div>
|
||||
|
||||
<section class="workspace-shell">
|
||||
@@ -154,7 +159,7 @@
|
||||
<button id="deleteThumbnailButton" class="ghost-button danger-button" type="button">删除当前预览图</button>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
视频第一次成功预览后,会自动截取首个已解码画面并回写到后端。目录浏览页会直接使用这里缓存的缩略图。
|
||||
视频第一次成功预览后,会自动截取可用画面并回写到后端。目录浏览页会直接使用这里缓存的缩略图。
|
||||
</p>
|
||||
<div id="thumbnailStatus" class="summary-card">正在读取预览图状态...</div>
|
||||
<div id="thumbnailPreview" class="thumbnail-card">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
linkToSource,
|
||||
optionalQueryParam
|
||||
} from './modules/common.js';
|
||||
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
|
||||
import {
|
||||
loadClientSecrets,
|
||||
loadGlobalClientSecrets,
|
||||
@@ -87,6 +88,8 @@ const previewThumbnailHintElement = document.querySelector('#previewThumbnailHin
|
||||
const thumbnailStatusElement = document.querySelector('#thumbnailStatus');
|
||||
const thumbnailPreviewElement = document.querySelector('#thumbnailPreview');
|
||||
const thumbnailCaptureCanvas = document.querySelector('#thumbnailCaptureCanvas');
|
||||
const authStatusElement = document.querySelector('#authStatus');
|
||||
const logoutButton = document.querySelector('#logoutButton');
|
||||
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
|
||||
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
|
||||
|
||||
@@ -1277,6 +1280,11 @@ window.addEventListener('beforeunload', () => {
|
||||
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);
|
||||
|
||||
if (!state.sourceId || !state.fileId) {
|
||||
renderMissingRouteContext();
|
||||
|
||||
+115
-37
@@ -1,9 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>数据源控制台</title>
|
||||
<title>Duplicati Control Center</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -11,22 +11,29 @@
|
||||
<section class="hero hero-compact">
|
||||
<div>
|
||||
<p class="eyebrow">Zero-Bandwidth Duplicati Web Client</p>
|
||||
<h1>数据源控制台</h1>
|
||||
<h1>Duplicati Control Center</h1>
|
||||
</div>
|
||||
<p class="lead">
|
||||
首页现在只负责数据源级管理。任务上传、Server DB、全局 WebDAV 默认值和源列表已经拆成菜单式子面板,不再全部挤在同一屏。
|
||||
Manage task databases, Server DB metadata, global WebDAV defaults, and site-wide HTTPS/TLS settings here.
|
||||
Browsing, enhancement, preview, and download stay in the per-source and per-file workspaces.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="auth-toolbar">
|
||||
<span id="authStatus" class="auth-status">Loading login state...</span>
|
||||
<button id="logoutButton" class="ghost-button" type="button">Sign out</button>
|
||||
</div>
|
||||
|
||||
<div id="feedback" class="feedback" hidden></div>
|
||||
|
||||
<section class="workspace-shell">
|
||||
<aside class="workspace-nav" aria-label="数据源控制台菜单">
|
||||
<a class="workspace-nav-link" href="#overview" data-view-link="overview">总览</a>
|
||||
<a class="workspace-nav-link" href="#sources" data-view-link="sources">数据源列表</a>
|
||||
<a class="workspace-nav-link" href="#upload" data-view-link="upload">新增任务库</a>
|
||||
<aside class="workspace-nav" aria-label="Control Center menu">
|
||||
<a class="workspace-nav-link" href="#overview" data-view-link="overview">Overview</a>
|
||||
<a class="workspace-nav-link" href="#sources" data-view-link="sources">Sources</a>
|
||||
<a class="workspace-nav-link" href="#upload" data-view-link="upload">Upload task DB</a>
|
||||
<a class="workspace-nav-link" href="#server-db" data-view-link="server-db">Server DB</a>
|
||||
<a class="workspace-nav-link" href="#defaults" data-view-link="defaults">全局 WebDAV</a>
|
||||
<a class="workspace-nav-link" href="#defaults" data-view-link="defaults">Global WebDAV</a>
|
||||
<a class="workspace-nav-link" href="#tls" data-view-link="tls">HTTPS / TLS</a>
|
||||
</aside>
|
||||
|
||||
<div class="workspace-content">
|
||||
@@ -35,10 +42,10 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Overview</p>
|
||||
<h2>控制台总览</h2>
|
||||
<h2>System summary</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="overviewSummary" class="summary-card">正在汇总控制台状态...</div>
|
||||
<div id="overviewSummary" class="summary-card">Loading overview...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -47,14 +54,15 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Sources</p>
|
||||
<h2>数据源列表</h2>
|
||||
<h2>Uploaded task databases</h2>
|
||||
</div>
|
||||
<button id="refreshButton" class="ghost-button" type="button">刷新列表</button>
|
||||
<button id="refreshButton" class="ghost-button" type="button">Refresh</button>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
这里显示当前所有任务库 source。点击“进入工作台”会进入该任务自己的管理页;删除只会删除该 source,不会删除 <code>Duplicati-server.sqlite</code>。
|
||||
Each uploaded Duplicati task database becomes one source. Open a source workbench to browse files,
|
||||
start enhancement, or jump into the file workspace.
|
||||
</p>
|
||||
<div id="sourceList" class="source-list empty-state">正在读取数据源列表...</div>
|
||||
<div id="sourceList" class="source-list empty-state">Loading sources...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -63,19 +71,20 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Task DB</p>
|
||||
<h2>新增任务数据源</h2>
|
||||
<h2>Add a new source</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
上传随机名任务库,例如 <code>TMQRJYNADS.sqlite</code>。每个任务库都会保留成独立 source,不会互相覆盖。
|
||||
Upload a Duplicati task database such as <code>TMQRJYNADS.sqlite</code>. Re-uploading the same file
|
||||
reuses the existing source instead of creating a duplicate.
|
||||
</p>
|
||||
<form id="uploadForm" class="upload-form">
|
||||
<label class="file-picker">
|
||||
<span>选择任务 SQLite 文件</span>
|
||||
<span>Select task SQLite file</span>
|
||||
<input id="databaseInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="uploadButton" class="primary-button" type="submit">上传为新数据源</button>
|
||||
<button id="uploadButton" class="primary-button" type="submit">Upload as new source</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -86,22 +95,23 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Server DB</p>
|
||||
<h2>上传服务器数据库</h2>
|
||||
<h2>Upload Duplicati-server.sqlite</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
上传 <code>Duplicati-server.sqlite</code> 后,系统会把随机任务库名映射回友好的任务名,并尝试推导每个任务的默认 WebDAV 目标 URL。
|
||||
The server database lets the UI resolve friendly task names and infer default target URLs from
|
||||
Duplicati metadata.
|
||||
</p>
|
||||
<form id="serverDbForm" class="upload-form">
|
||||
<label class="file-picker">
|
||||
<span>选择服务器数据库</span>
|
||||
<span>Select server database</span>
|
||||
<input id="serverDbInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="serverDbUploadButton" class="ghost-button" type="submit">上传 Server DB</button>
|
||||
<button id="serverDbUploadButton" class="ghost-button" type="submit">Upload Server DB</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="serverDbSummary" class="summary-card">正在读取当前 Server DB 状态...</div>
|
||||
<div id="serverDbSummary" class="summary-card">Loading Server DB status...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -110,42 +120,110 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Global WebDAV</p>
|
||||
<h2>保存全局默认值</h2>
|
||||
<h2>Shared defaults</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
WebDAV 用户名、密码和备份口令如果大多数任务都共用,可以在这里全局保存一次。单个任务的目标 URL 会优先从 <code>Duplicati-server.sqlite</code> 自动推导。
|
||||
Save credentials and a fallback base URL once when most tasks share the same WebDAV settings.
|
||||
</p>
|
||||
<form id="globalDefaultsForm" class="secret-form">
|
||||
<label>
|
||||
<span>Fallback WebDAV Base URL</span>
|
||||
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="可选,全局兜底 URL">
|
||||
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="Optional shared base URL">
|
||||
</label>
|
||||
<label>
|
||||
<span>认证方式</span>
|
||||
<span>Authentication mode</span>
|
||||
<select id="globalAuthMode" name="authMode">
|
||||
<option value="">自动 / 继承</option>
|
||||
<option value="">Auto / inherited</option>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="anonymous">Anonymous</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input id="globalUsername" name="username" type="text" placeholder="可选,共用用户名">
|
||||
<span>Username</span>
|
||||
<input id="globalUsername" name="username" type="text" placeholder="Optional shared username">
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input id="globalPassword" name="password" type="password" placeholder="可选,共用密码">
|
||||
<span>Password</span>
|
||||
<input id="globalPassword" name="password" type="password" placeholder="Optional shared password">
|
||||
</label>
|
||||
<label>
|
||||
<span>备份口令</span>
|
||||
<input id="globalPassphrase" name="passphrase" type="password" placeholder="共用的备份口令">
|
||||
<span>Backup passphrase</span>
|
||||
<input id="globalPassphrase" name="passphrase" type="password" placeholder="Optional shared passphrase">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">保存全局默认值</button>
|
||||
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">Save defaults</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="globalDefaultsSummary" class="summary-card">Loading WebDAV defaults...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-panel" data-view-panel="tls" hidden>
|
||||
<div class="panel stack-gap">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">HTTPS / TLS</p>
|
||||
<h2>Domain and certificate settings</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
HTTP and HTTPS stay enabled side-by-side. If no custom certificate is configured, the service keeps a
|
||||
self-signed certificate ready so HTTPS is always available.
|
||||
</p>
|
||||
<div id="tlsSummary" class="summary-card">Loading TLS configuration...</div>
|
||||
|
||||
<form id="tlsJsonForm" class="secret-form">
|
||||
<label>
|
||||
<span>Certificate mode</span>
|
||||
<select id="tlsMode" name="mode">
|
||||
<option value="self-signed">Self-signed</option>
|
||||
<option value="custom-pem">Custom PEM</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Primary domain</span>
|
||||
<input id="tlsPrimaryDomain" name="primaryDomain" type="text" placeholder="Example: preview.example.com">
|
||||
</label>
|
||||
<label>
|
||||
<span>Additional SAN entries</span>
|
||||
<textarea id="tlsSubjectAltNames" name="subjectAltNames" rows="3" placeholder="One hostname or IP per line. Comma-separated values also work."></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Certificate PEM</span>
|
||||
<textarea id="tlsCertPem" name="certPem" rows="8" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Private key PEM</span>
|
||||
<textarea id="tlsKeyPem" name="keyPem" rows="8" placeholder="-----BEGIN PRIVATE KEY-----"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Optional chain PEM</span>
|
||||
<textarea id="tlsChainPem" name="chainPem" rows="6" placeholder="Optional intermediate certificates"></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="tlsSaveButton" class="primary-button" type="submit">Save and apply</button>
|
||||
<button id="tlsDeleteCustomButton" class="ghost-button danger-button" type="button">Delete custom cert and fall back</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="tlsUploadForm" class="upload-form">
|
||||
<label class="file-picker">
|
||||
<span>Upload certificate file</span>
|
||||
<input id="tlsCertificateInput" name="certificate" type="file" accept=".pem,.crt,.cer,text/plain">
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
<span>Upload private key file</span>
|
||||
<input id="tlsPrivateKeyInput" name="privateKey" type="file" accept=".pem,.key,text/plain">
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
<span>Upload optional chain file</span>
|
||||
<input id="tlsChainInput" name="chain" type="file" accept=".pem,.crt,.cer,text/plain">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="tlsUploadButton" class="ghost-button" type="submit">Apply uploaded certificate</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="globalDefaultsSummary" class="summary-card">正在读取全局默认值...</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>系统登录</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell shell-auth">
|
||||
<section class="hero hero-compact">
|
||||
<div>
|
||||
<p class="eyebrow">Authentication</p>
|
||||
<h1 id="authHeading">系统登录</h1>
|
||||
</div>
|
||||
<p id="authLead" class="lead">
|
||||
登录后才能访问数据源管理、增强任务、视频预览和文件下载。首次启用时,需要先设置管理员账号。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div id="feedback" class="feedback" hidden></div>
|
||||
|
||||
<section class="auth-layout">
|
||||
<div class="panel stack-gap">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Account</p>
|
||||
<h2 id="authFormTitle">正在检查系统状态...</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="authSummary" class="summary-card">正在读取认证状态...</div>
|
||||
|
||||
<form id="loginForm" class="secret-form" hidden>
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input id="loginUsername" name="username" type="text" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input id="loginPassword" name="password" type="password" autocomplete="current-password">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="loginButton" class="primary-button" type="submit">登录</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="setupForm" class="secret-form" hidden>
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input id="setupUsername" name="username" type="text" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input id="setupPassword" name="password" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
<span>再次输入密码</span>
|
||||
<input id="setupPasswordConfirm" name="passwordConfirm" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="setupButton" class="primary-button" type="submit">完成初始化并登录</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { createFeedbackController, escapeHtml } from './modules/common.js';
|
||||
import { loadAuthState, redirectToNextPath } from './modules/auth-client.js';
|
||||
|
||||
const feedback = createFeedbackController(document.querySelector('#feedback'));
|
||||
const authHeadingElement = document.querySelector('#authHeading');
|
||||
const authLeadElement = document.querySelector('#authLead');
|
||||
const authFormTitleElement = document.querySelector('#authFormTitle');
|
||||
const authSummaryElement = document.querySelector('#authSummary');
|
||||
const loginForm = document.querySelector('#loginForm');
|
||||
const setupForm = document.querySelector('#setupForm');
|
||||
const loginButton = document.querySelector('#loginButton');
|
||||
const setupButton = document.querySelector('#setupButton');
|
||||
|
||||
const state = {
|
||||
auth: null
|
||||
};
|
||||
|
||||
function setSummaryRows(rows) {
|
||||
authSummaryElement.innerHTML = `
|
||||
<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 renderAuthMode() {
|
||||
const auth = state.auth;
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.authenticated) {
|
||||
authHeadingElement.textContent = '登录成功';
|
||||
authLeadElement.textContent = '当前会话已验证,正在跳转到你的目标页面。';
|
||||
authFormTitleElement.textContent = '正在跳转...';
|
||||
setSummaryRows([
|
||||
{ label: '状态', value: `已登录:${auth.username}` },
|
||||
{ label: '会话有效期', value: auth.expiresAt ?? '未知' }
|
||||
]);
|
||||
loginForm.hidden = true;
|
||||
setupForm.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.setupRequired) {
|
||||
authHeadingElement.textContent = '初始化管理员账号';
|
||||
authLeadElement.textContent = '系统第一次运行时,需要先创建管理员用户名和密码。创建完成后会自动登录。';
|
||||
authFormTitleElement.textContent = '首次初始化';
|
||||
setSummaryRows([
|
||||
{ label: '系统状态', value: '尚未配置管理员账号' },
|
||||
{ label: '下一步', value: '创建管理员并进入系统' }
|
||||
]);
|
||||
loginForm.hidden = true;
|
||||
setupForm.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
authHeadingElement.textContent = '系统登录';
|
||||
authLeadElement.textContent = '登录后才能访问数据源管理、增强任务、视频预览和文件下载。';
|
||||
authFormTitleElement.textContent = '请输入账号密码';
|
||||
setSummaryRows([
|
||||
{ label: '系统状态', value: '管理员账号已配置' },
|
||||
{ label: '当前状态', value: '未登录' }
|
||||
]);
|
||||
loginForm.hidden = false;
|
||||
setupForm.hidden = true;
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`${body.error?.code ?? 'REQUEST_FAILED'}: ${body.error?.message ?? 'Request failed.'}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function refreshAuthState() {
|
||||
state.auth = await loadAuthState();
|
||||
renderAuthMode();
|
||||
if (state.auth.authenticated) {
|
||||
redirectToNextPath('/');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
loginButton.disabled = true;
|
||||
|
||||
try {
|
||||
await postJson('/api/auth/login', {
|
||||
username: document.querySelector('#loginUsername').value.trim(),
|
||||
password: document.querySelector('#loginPassword').value
|
||||
});
|
||||
await refreshAuthState();
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
loginButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetup(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
|
||||
const password = document.querySelector('#setupPassword').value;
|
||||
const passwordConfirm = document.querySelector('#setupPasswordConfirm').value;
|
||||
if (password !== passwordConfirm) {
|
||||
feedback.set('error', '两次输入的密码不一致。');
|
||||
return;
|
||||
}
|
||||
|
||||
setupButton.disabled = true;
|
||||
|
||||
try {
|
||||
await postJson('/api/auth/setup', {
|
||||
username: document.querySelector('#setupUsername').value.trim(),
|
||||
password
|
||||
});
|
||||
await refreshAuthState();
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
setupButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
loginForm.addEventListener('submit', (event) => {
|
||||
void handleLogin(event);
|
||||
});
|
||||
|
||||
setupForm.addEventListener('submit', (event) => {
|
||||
void handleSetup(event);
|
||||
});
|
||||
|
||||
void refreshAuthState().catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
setSummaryRows([
|
||||
{ label: '状态', value: '无法读取认证信息' },
|
||||
{ label: '错误', value: error.message }
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
export function getCurrentRelativeUrl() {
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
export function buildLoginUrl(nextPath = getCurrentRelativeUrl()) {
|
||||
const loginUrl = new URL('/login.html', window.location.origin);
|
||||
if (nextPath) {
|
||||
loginUrl.searchParams.set('next', nextPath);
|
||||
}
|
||||
|
||||
return `${loginUrl.pathname}${loginUrl.search}`;
|
||||
}
|
||||
|
||||
export function redirectToLogin(nextPath = getCurrentRelativeUrl()) {
|
||||
window.location.assign(buildLoginUrl(nextPath));
|
||||
}
|
||||
|
||||
export function readNextPath(fallback = '/') {
|
||||
const nextPath = new URLSearchParams(window.location.search).get('next');
|
||||
if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return nextPath;
|
||||
}
|
||||
|
||||
export function redirectToNextPath(fallback = '/') {
|
||||
window.location.assign(readNextPath(fallback));
|
||||
}
|
||||
|
||||
export async function loadAuthState() {
|
||||
const response = await fetch('/api/auth/state');
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`${payload.error?.code ?? 'AUTH_STATE_FAILED'}: ${payload.error?.message ?? 'Failed to load auth state.'}`);
|
||||
}
|
||||
|
||||
return payload.auth;
|
||||
}
|
||||
|
||||
export async function logoutCurrentSession() {
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
});
|
||||
const payload = await response.json().catch(() => ({ ok: false }));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${payload.error?.code ?? 'LOGOUT_FAILED'}: ${payload.error?.message ?? 'Logout failed.'}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function bindLogoutButton(button, labelElement, fallbackLabel = 'Signed in') {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
if (labelElement) {
|
||||
labelElement.textContent = 'Signing out...';
|
||||
}
|
||||
|
||||
try {
|
||||
await logoutCurrentSession();
|
||||
} finally {
|
||||
redirectToLogin('/');
|
||||
}
|
||||
});
|
||||
|
||||
if (labelElement && !labelElement.textContent.trim()) {
|
||||
labelElement.textContent = fallbackLabel;
|
||||
}
|
||||
}
|
||||
+20
-14
@@ -1,3 +1,5 @@
|
||||
import { redirectToLogin } from './auth-client.js';
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
@@ -34,6 +36,10 @@ export async function fetchJson(url, options = {}) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && window.location.pathname !== '/login.html') {
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
throw new Error(`${payload.error?.code ?? 'REQUEST_FAILED'}: ${payload.error?.message ?? 'Request failed.'}`);
|
||||
}
|
||||
|
||||
@@ -81,15 +87,15 @@ export function linkToFile(sourceId, fileId) {
|
||||
export function inferEnhancementLabel(source) {
|
||||
switch (source?.enhancement?.status) {
|
||||
case 'ready':
|
||||
return '已增强';
|
||||
return 'Enhanced';
|
||||
case 'running':
|
||||
return '增强中';
|
||||
return 'Running';
|
||||
case 'queued':
|
||||
return '排队中';
|
||||
return 'Queued';
|
||||
case 'failed':
|
||||
return '增强失败';
|
||||
return 'Failed';
|
||||
default:
|
||||
return '未增强';
|
||||
return 'Not ready';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +116,7 @@ export function inferEnhancementClass(source) {
|
||||
export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
const title = escapeHtml(source.displayName || source.originalFilename);
|
||||
const originalFilename = escapeHtml(source.originalFilename);
|
||||
const matchedHint = source.matchedBackupName ? '任务名来自 Server DB' : '未匹配任务名';
|
||||
const matchedHint = source.matchedBackupName ? 'Task name resolved from Server DB' : 'Task name not mapped yet';
|
||||
const progress =
|
||||
source.enhancement.totalVolumes > 0
|
||||
? `${source.enhancement.processedVolumes}/${source.enhancement.totalVolumes}`
|
||||
@@ -121,8 +127,8 @@ export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
<div class="source-main">
|
||||
<div>
|
||||
<p class="source-name">${title}</p>
|
||||
<p class="source-meta">原始库名: ${originalFilename}</p>
|
||||
<p class="source-meta">sourceId: ${escapeHtml(source.id)}</p>
|
||||
<p class="source-meta">Database file: ${originalFilename}</p>
|
||||
<p class="source-meta">Source ID: ${escapeHtml(source.id)}</p>
|
||||
</div>
|
||||
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
|
||||
</div>
|
||||
@@ -131,19 +137,19 @@ export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat">
|
||||
<span>上传大小</span>
|
||||
<span>Uploaded size</span>
|
||||
<strong>${formatBytes(source.fileSize)}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>最近快照</span>
|
||||
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '无快照')}</strong>
|
||||
<span>Latest snapshot</span>
|
||||
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? 'None')}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>目录浏览</span>
|
||||
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
|
||||
<span>Browse</span>
|
||||
<strong>${source.canBrowse ? 'Available' : 'Unavailable'}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>增强进度</span>
|
||||
<span>Enhancement</span>
|
||||
<strong>${escapeHtml(progress)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+9
-4
@@ -20,10 +20,15 @@
|
||||
<h1 id="pageTitle">数据源工作台</h1>
|
||||
</div>
|
||||
<p class="lead">
|
||||
这里处理单个数据源的浏览、增强和预览图缓存。页面改成了左侧菜单式工作台,避免所有功能继续堆在一个长页面里。
|
||||
这里负责单个数据源的目录浏览、增强配置和预览图缓存管理。页面已经拆成菜单式工作台,避免所有功能都堆在一个长页面里。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="auth-toolbar">
|
||||
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
|
||||
<button id="logoutButton" class="ghost-button" type="button">退出登录</button>
|
||||
</div>
|
||||
|
||||
<div id="feedback" class="feedback" hidden></div>
|
||||
|
||||
<section class="workspace-shell">
|
||||
@@ -76,8 +81,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
默认情况下,增强会优先使用全局默认值,并尽量从 <code>Duplicati-server.sqlite</code> 自动推导当前任务的目标 URL。
|
||||
这里只需要填写与全局不同的部分;全部留空并保存,会清除当前 source 的覆盖设置。
|
||||
默认情况下,增强会优先使用全局 WebDAV 默认值,并尽量从 <code>Duplicati-server.sqlite</code> 自动推导当前任务的目标 URL。
|
||||
这里只需要填写与全局不同的部分;全部留空并保存,会清掉当前 source 的覆盖设置。
|
||||
</p>
|
||||
<div id="webdavHint" class="summary-card">正在计算这个 source 的默认 WebDAV 目标...</div>
|
||||
<form id="secretForm" class="secret-form">
|
||||
@@ -123,7 +128,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
这里展示当前 source 已缓存到后端的预览图数量。清空后,目录里的视频文件会退回占位图;后续再次成功预览时会自动重新生成。
|
||||
这里展示当前 source 已缓存到后端的预览图数量。清空后,目录里的视频文件会退回占位图,后续再次成功预览时会自动重建。
|
||||
</p>
|
||||
<div id="sourceThumbnailSummary" class="summary-card">正在统计这个 source 的预览图缓存...</div>
|
||||
<div class="form-actions">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
linkToFile,
|
||||
optionalQueryParam
|
||||
} from './modules/common.js';
|
||||
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
|
||||
|
||||
const SOURCE_VIEWS = new Set(['overview', 'browse', 'enhance', 'thumbnails']);
|
||||
|
||||
@@ -33,6 +34,8 @@ const webdavHintElement = document.querySelector('#webdavHint');
|
||||
const deleteSourceButton = document.querySelector('#deleteSourceButton');
|
||||
const sourceThumbnailSummaryElement = document.querySelector('#sourceThumbnailSummary');
|
||||
const clearSourceThumbnailsButton = document.querySelector('#clearSourceThumbnailsButton');
|
||||
const authStatusElement = document.querySelector('#authStatus');
|
||||
const logoutButton = document.querySelector('#logoutButton');
|
||||
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
|
||||
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
|
||||
|
||||
@@ -554,6 +557,12 @@ window.addEventListener('hashchange', () => {
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const authentication = await loadAuthState();
|
||||
authStatusElement.textContent = authentication.authenticated
|
||||
? `已登录:${authentication.username}`
|
||||
: '未登录';
|
||||
bindLogoutButton(logoutButton, authStatusElement);
|
||||
|
||||
if (!state.sourceId) {
|
||||
renderMissingRouteContext();
|
||||
return;
|
||||
|
||||
+32
-2
@@ -51,6 +51,10 @@ code {
|
||||
padding: 28px 0 72px;
|
||||
}
|
||||
|
||||
.shell-auth {
|
||||
width: min(720px, calc(100vw - 30px));
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -164,6 +168,25 @@ h3 {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.auth-layout {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.auth-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: -4px 0 18px;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.workspace-content,
|
||||
.workspace-panel {
|
||||
display: grid;
|
||||
@@ -329,7 +352,8 @@ h3 {
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(26, 36, 31, 0.14);
|
||||
@@ -339,8 +363,14 @@ select {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid rgba(15, 118, 110, 0.18);
|
||||
border-color: rgba(15, 118, 110, 0.4);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user