feat: add docker pipeline and preview fixes
This commit is contained in:
+449
@@ -0,0 +1,449 @@
|
||||
import {
|
||||
createFeedbackController,
|
||||
escapeHtml,
|
||||
fetchJson,
|
||||
linkToSource,
|
||||
renderSourceSummaryCard
|
||||
} from './modules/common.js';
|
||||
|
||||
const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults']);
|
||||
|
||||
const state = {
|
||||
sources: [],
|
||||
serverDb: null,
|
||||
globalDefaults: 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 refreshButton = document.querySelector('#refreshButton');
|
||||
const serverDbSummaryElement = document.querySelector('#serverDbSummary');
|
||||
const globalDefaultsSummaryElement = document.querySelector('#globalDefaultsSummary');
|
||||
const overviewSummaryElement = document.querySelector('#overviewSummary');
|
||||
const sourceListElement = document.querySelector('#sourceList');
|
||||
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 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 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>
|
||||
`;
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSources() {
|
||||
if (state.sources.length === 0) {
|
||||
sourceListElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>还没有任何任务数据源。</p>
|
||||
<p>先上传一个随机名任务库,然后再进入工作台浏览目录、做增强或进入文件页预览下载。</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 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 并刷新任务名与目标 URL 映射...');
|
||||
|
||||
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 上传成功,任务名和默认目标 URL 已刷新。');
|
||||
} 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 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。`);
|
||||
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);
|
||||
});
|
||||
|
||||
refreshButton.addEventListener('click', () => {
|
||||
feedback.clear();
|
||||
sourceListElement.textContent = '正在刷新数据源列表...';
|
||||
void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults()]).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 });
|
||||
await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadSources()]);
|
||||
}
|
||||
|
||||
void bootstrap().catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
sourceListElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>无法读取数据源列表。</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
Reference in New Issue
Block a user