feat: add docker pipeline and preview fixes
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
import {
|
||||
createFeedbackController,
|
||||
escapeHtml,
|
||||
fetchJson,
|
||||
formatBytes,
|
||||
inferEnhancementClass,
|
||||
inferEnhancementLabel,
|
||||
linkToFile,
|
||||
optionalQueryParam
|
||||
} from './modules/common.js';
|
||||
|
||||
const SOURCE_VIEWS = new Set(['overview', 'browse', 'enhance', 'thumbnails']);
|
||||
|
||||
const state = {
|
||||
sourceId: optionalQueryParam('sourceId', ''),
|
||||
source: null,
|
||||
currentPath: '/',
|
||||
currentParent: null,
|
||||
currentView: 'overview'
|
||||
};
|
||||
|
||||
const feedback = createFeedbackController(document.querySelector('#feedback'));
|
||||
const pageTitleElement = document.querySelector('#pageTitle');
|
||||
const sourceSummaryElement = document.querySelector('#sourceSummary');
|
||||
const browseResultElement = document.querySelector('#browseResult');
|
||||
const pathTrailElement = document.querySelector('#pathTrail');
|
||||
const browseUpButton = document.querySelector('#browseUpButton');
|
||||
const browseRootButton = document.querySelector('#browseRootButton');
|
||||
const browseRefreshButton = document.querySelector('#browseRefreshButton');
|
||||
const secretForm = document.querySelector('#secretForm');
|
||||
const runEnhancementButton = document.querySelector('#runEnhancementButton');
|
||||
const webdavHintElement = document.querySelector('#webdavHint');
|
||||
const deleteSourceButton = document.querySelector('#deleteSourceButton');
|
||||
const sourceThumbnailSummaryElement = document.querySelector('#sourceThumbnailSummary');
|
||||
const clearSourceThumbnailsButton = document.querySelector('#clearSourceThumbnailsButton');
|
||||
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
|
||||
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
|
||||
|
||||
function normalizeView(value) {
|
||||
return SOURCE_VIEWS.has(value) ? value : 'overview';
|
||||
}
|
||||
|
||||
function setControlsDisabled(disabled) {
|
||||
for (const element of [
|
||||
browseUpButton,
|
||||
browseRootButton,
|
||||
browseRefreshButton,
|
||||
runEnhancementButton,
|
||||
deleteSourceButton,
|
||||
clearSourceThumbnailsButton,
|
||||
...secretForm.querySelectorAll('input, select, button')
|
||||
]) {
|
||||
if (element) {
|
||||
element.disabled = disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 renderMissingRouteContext() {
|
||||
setControlsDisabled(true);
|
||||
pageTitleElement.textContent = '数据源工作台未就绪';
|
||||
sourceSummaryElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>这个页面缺少必要的地址参数。</p>
|
||||
<p>请从首页的数据源列表进入,或检查当前地址是否包含 <code>sourceId</code>。</p>
|
||||
</div>
|
||||
`;
|
||||
sourceThumbnailSummaryElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>状态</span>
|
||||
<strong>缺少 sourceId</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
pathTrailElement.textContent = '/';
|
||||
browseResultElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>缺少 <code>sourceId</code>,无法读取目录。</p>
|
||||
</div>
|
||||
`;
|
||||
webdavHintElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>状态</span>
|
||||
<strong>缺少 sourceId</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setCurrentView('overview', { replace: true });
|
||||
}
|
||||
|
||||
function renderPathTrail(pathValue) {
|
||||
const segments = pathValue === '/' ? [] : pathValue.split('/').filter(Boolean);
|
||||
const crumbs = [
|
||||
'<button class="path-chip" data-path="/" type="button">根</button>'
|
||||
];
|
||||
|
||||
let currentPath = '';
|
||||
for (const segment of segments) {
|
||||
currentPath += `/${segment}`;
|
||||
crumbs.push('<span class="path-separator">/</span>');
|
||||
crumbs.push(
|
||||
`<button class="path-chip" data-path="${escapeHtml(currentPath)}" type="button">${escapeHtml(segment)}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
pathTrailElement.innerHTML = crumbs.join('');
|
||||
}
|
||||
|
||||
function renderSourceSummary() {
|
||||
const source = state.source;
|
||||
if (!source) {
|
||||
sourceSummaryElement.innerHTML = '<div class="empty-state-card">未找到对应数据源。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
pageTitleElement.textContent = source.displayName || source.originalFilename;
|
||||
const enhancementError = source.enhancement.lastErrorMessage
|
||||
? `<div class="feedback feedback-error">最近失败: ${escapeHtml(source.enhancement.lastErrorMessage)}</div>`
|
||||
: '';
|
||||
|
||||
sourceSummaryElement.innerHTML = `
|
||||
<article class="source-card">
|
||||
<div class="source-main">
|
||||
<div>
|
||||
<p class="source-name">${escapeHtml(source.displayName || source.originalFilename)}</p>
|
||||
<p class="source-meta">原始库名: ${escapeHtml(source.originalFilename)}</p>
|
||||
<p class="source-meta">sourceId: ${escapeHtml(source.id)}</p>
|
||||
</div>
|
||||
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span>任务名来源</span>
|
||||
<strong>${escapeHtml(source.displayNameSource)}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>匹配任务名</span>
|
||||
<strong>${escapeHtml(source.matchedBackupName ?? '未匹配')}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>源布局</span>
|
||||
<strong>${escapeHtml(source.sourceLayout)}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>上传大小</span>
|
||||
<strong>${formatBytes(source.fileSize)}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>目录浏览</span>
|
||||
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>预览图缓存</span>
|
||||
<strong>${escapeHtml(String(source.thumbnailCache?.count ?? 0))} 张</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>archive_entry_index</span>
|
||||
<strong>${source.capabilities.archiveEntryIndex ? 'Yes' : 'No'}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>volume_crypto_cache</span>
|
||||
<strong>${source.capabilities.volumeCryptoCache ? 'Yes' : 'No'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-chip">最新快照: ${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')}</div>
|
||||
${enhancementError}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWebdavHint() {
|
||||
const webdav = state.source?.webdav;
|
||||
if (!webdav) {
|
||||
webdavHintElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>默认目标 URL</span>
|
||||
<strong>未知</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
webdavHintElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>任务默认 URL</span>
|
||||
<strong>${escapeHtml(webdav.effectiveWebdavBaseUrl ?? webdav.derivedWebdavBaseUrl ?? '未推导到')}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>默认 URL 来源</span>
|
||||
<strong>${escapeHtml(webdav.effectiveWebdavBaseUrlSource ?? 'none')}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>Server DB 推导</span>
|
||||
<strong>${escapeHtml(webdav.derivedTargetUrl ?? '无')}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>全局默认值</span>
|
||||
<strong>${webdav.globalDefaultsConfigured ? '已配置' : '未配置'}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>当前 source 覆盖</span>
|
||||
<strong>${webdav.sourceOverrideSaved ? '已保存' : '未保存'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSourceThumbnailSummary() {
|
||||
const thumbnailCache = state.source?.thumbnailCache ?? {
|
||||
count: 0,
|
||||
totalBytes: 0
|
||||
};
|
||||
|
||||
sourceThumbnailSummaryElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
<div class="summary-row">
|
||||
<span>当前 source 预览图数量</span>
|
||||
<strong>${escapeHtml(String(thumbnailCache.count))}</strong>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span>总占用空间</span>
|
||||
<strong>${formatBytes(thumbnailCache.totalBytes)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBrowse(payload) {
|
||||
state.currentPath = payload.path;
|
||||
state.currentParent = payload.parent;
|
||||
renderPathTrail(payload.path);
|
||||
|
||||
if (payload.entries.length === 0) {
|
||||
browseResultElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>这个目录目前没有内容。</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
browseResultElement.innerHTML = `
|
||||
<div class="browse-list">
|
||||
${payload.entries
|
||||
.map((entry) => {
|
||||
if (entry.type === 'dir') {
|
||||
return `
|
||||
<article class="browse-row browse-row-clickable" data-path="${escapeHtml(entry.path)}" data-entry-type="dir">
|
||||
<div class="browse-row-main">
|
||||
<div class="browse-thumbnail browse-thumbnail-placeholder browse-thumbnail-folder">DIR</div>
|
||||
<div>
|
||||
<strong>${escapeHtml(entry.name)}</strong>
|
||||
<span class="browse-type">dir</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="browse-meta">
|
||||
<span>${escapeHtml(entry.path)}</span>
|
||||
<span>${entry.mtime ? escapeHtml(entry.mtime) : '目录'}</span>
|
||||
<span>点击进入目录</span>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
const thumbnail = entry.thumbnail?.available
|
||||
? `
|
||||
<img
|
||||
class="browse-thumbnail-image"
|
||||
src="${escapeHtml(entry.thumbnail.thumbnailUrl)}"
|
||||
alt="${escapeHtml(entry.name)} 的预览图"
|
||||
loading="lazy"
|
||||
>
|
||||
`
|
||||
: '<div class="browse-thumbnail browse-thumbnail-placeholder">FILE</div>';
|
||||
|
||||
return `
|
||||
<article class="browse-row browse-row-clickable" data-file-id="${escapeHtml(entry.id)}" data-entry-type="file">
|
||||
<div class="browse-row-main">
|
||||
<div class="browse-thumbnail">${thumbnail}</div>
|
||||
<div>
|
||||
<strong>${escapeHtml(entry.name)}</strong>
|
||||
<span class="browse-type">${escapeHtml(entry.mime ?? 'file')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="browse-meta">
|
||||
<span>${escapeHtml(entry.path)}</span>
|
||||
<span>${formatBytes(entry.size)}</span>
|
||||
<span>${entry.thumbnail?.available ? '已缓存预览图' : '点击打开文件页'}</span>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBrowseError(pathValue, error) {
|
||||
renderPathTrail(pathValue);
|
||||
browseResultElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>无法列出目录内容。</p>
|
||||
<p>${escapeHtml(error.message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function readEnhancementForm() {
|
||||
return {
|
||||
webdavBaseUrl: document.querySelector('#webdavBaseUrl')?.value?.trim() ?? '',
|
||||
authMode: document.querySelector('#authMode')?.value ?? '',
|
||||
username: document.querySelector('#username')?.value?.trim() ?? '',
|
||||
password: document.querySelector('#password')?.value ?? '',
|
||||
passphrase: document.querySelector('#passphrase')?.value ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSource() {
|
||||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`);
|
||||
state.source = payload.source;
|
||||
renderSourceSummary();
|
||||
renderWebdavHint();
|
||||
renderSourceThumbnailSummary();
|
||||
}
|
||||
|
||||
async function loadDirectory(pathValue) {
|
||||
state.currentPath = pathValue;
|
||||
browseResultElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>正在读取目录...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
`/api/ls?sourceId=${encodeURIComponent(state.sourceId)}&path=${encodeURIComponent(pathValue)}`
|
||||
);
|
||||
renderBrowse(payload);
|
||||
} catch (error) {
|
||||
renderBrowseError(pathValue, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEnhancementSecrets(event) {
|
||||
event.preventDefault();
|
||||
feedback.set('info', '正在保存当前 source 的覆盖规则...');
|
||||
|
||||
try {
|
||||
await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/secrets`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(readEnhancementForm())
|
||||
});
|
||||
await loadSource();
|
||||
secretForm.reset();
|
||||
feedback.set('success', '覆盖规则已保存。留空项会继续继承全局默认值或 Server DB 推导结果。');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runEnhancement() {
|
||||
feedback.set('info', '正在提交增强任务...');
|
||||
|
||||
try {
|
||||
await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(readEnhancementForm())
|
||||
});
|
||||
await loadSource();
|
||||
secretForm.reset();
|
||||
feedback.set('success', '增强任务已开始。当前规则会自动合并全局默认值、Server DB 推导 URL 和单源覆盖。');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSourceThumbnails() {
|
||||
const confirmed = window.confirm(
|
||||
'确定要清空这个 source 的全部预览图吗?\n\n这不会删除任务库、增强索引或 Duplicati-server.sqlite,只会删除后端缩略图缓存。'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.set('info', '正在清空当前 source 的预览图缓存...');
|
||||
|
||||
try {
|
||||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await loadSource();
|
||||
await loadDirectory(state.currentPath);
|
||||
feedback.set(
|
||||
'success',
|
||||
`当前 source 的预览图已清空,删除了 ${payload.cleared.removedCount} 张,共 ${formatBytes(payload.cleared.removedBytes)}。`
|
||||
);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentSource() {
|
||||
if (!state.source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = state.source.displayName || state.source.originalFilename || state.source.id;
|
||||
const confirmed = window.confirm(
|
||||
`确定要删除这个 source 吗?\n\n${label}\n\n这会删除当前任务库、本地增强副本和该 source 的预览图缓存,但不会删除 Duplicati-server.sqlite。`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.set('info', `正在删除 ${label}...`);
|
||||
|
||||
try {
|
||||
await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
browseUpButton.addEventListener('click', () => {
|
||||
feedback.clear();
|
||||
void loadDirectory(state.currentParent ?? '/').catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
});
|
||||
});
|
||||
|
||||
browseRootButton.addEventListener('click', () => {
|
||||
feedback.clear();
|
||||
void loadDirectory('/').catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
});
|
||||
});
|
||||
|
||||
browseRefreshButton.addEventListener('click', () => {
|
||||
feedback.clear();
|
||||
void loadDirectory(state.currentPath).catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
});
|
||||
});
|
||||
|
||||
browseResultElement.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowElement = target.closest('.browse-row-clickable');
|
||||
if (rowElement instanceof HTMLElement) {
|
||||
const entryType = rowElement.dataset.entryType;
|
||||
if (entryType === 'dir' && rowElement.dataset.path) {
|
||||
feedback.clear();
|
||||
void loadDirectory(rowElement.dataset.path).catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (entryType === 'file' && rowElement.dataset.fileId) {
|
||||
window.location.href = linkToFile(state.sourceId, rowElement.dataset.fileId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pathTrailElement.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionElement = target.closest('[data-path]');
|
||||
if (!(actionElement instanceof HTMLElement) || !actionElement.dataset.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.clear();
|
||||
void loadDirectory(actionElement.dataset.path).catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
});
|
||||
});
|
||||
|
||||
secretForm.addEventListener('submit', (event) => {
|
||||
void saveEnhancementSecrets(event);
|
||||
});
|
||||
|
||||
runEnhancementButton.addEventListener('click', () => {
|
||||
void runEnhancement();
|
||||
});
|
||||
|
||||
clearSourceThumbnailsButton.addEventListener('click', () => {
|
||||
void clearSourceThumbnails();
|
||||
});
|
||||
|
||||
deleteSourceButton.addEventListener('click', () => {
|
||||
void deleteCurrentSource();
|
||||
});
|
||||
|
||||
for (const link of viewLinks) {
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const nextView = normalizeView(link.dataset.viewLink ?? 'browse');
|
||||
setCurrentView(nextView);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false });
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
if (!state.sourceId) {
|
||||
renderMissingRouteContext();
|
||||
return;
|
||||
}
|
||||
|
||||
const initialView = normalizeView(window.location.hash.slice(1) || 'overview');
|
||||
setCurrentView(initialView, { replace: true });
|
||||
|
||||
await loadSource();
|
||||
await loadDirectory('/');
|
||||
}
|
||||
|
||||
void bootstrap().catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
browseResultElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>无法进入数据源工作台。</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
Reference in New Issue
Block a user