feat: add nas storage status panel

This commit is contained in:
kris
2026-03-23 10:36:23 +08:00
parent a5f82bd0aa
commit 3ecf6c1916
2 changed files with 115 additions and 0 deletions

View File

@@ -72,6 +72,10 @@
- 录制源按当前账号和项目归属保存
- 录像文件只通过当前租户的后端代理访问
- 前端不再直接暴露 NAS 全局配置和下载根地址
- 存储状态面板已接上:
- 当前项目和当前账号的缓存占用
- 数据库本机 / 分析缓存 NAS / 下载缓存 NAS 的目录策略
- 最近写入 NAS 的缓存样本路径
- 会先识别后端是否具备 `tracking / reviews / integrations` 路由,再决定是否请求,避免不同版本 live collector 刷 404
- 依赖不可达时,自动拦住 AI 视频 / 实拍剪辑动作并展示原因
- 使用 Agent 生成文案
@@ -104,5 +108,9 @@ python3 -m http.server 3918
- 把全局搜索和页内搜索合并成统一搜索体验
-`生产中心 / 发布与复盘` 接入更完整的成片预览与封面对象
- 如果后续要开放外网多租户录像访问,继续沿用 collector 的鉴权代理,不要把 NAS 下载目录直接暴露给浏览器
- 现在的推荐策略是:
- 数据库继续留本机
- `jobs / downloads` 这类大文件缓存优先放 NAS
- 如果后续出现速度或稳定性问题,再切到 OSS
- 不要把这套页面重新塞回 `scripts/douyin-browser-capture/control_panel.mjs`
- 抖音采集控制台仍作为独立工具存在,这里才是正式业务应用壳

View File

@@ -27,6 +27,7 @@ const appState = {
liveRecorderSources: [],
liveRecorderStatus: null,
liveRecorderFiles: [],
storageStatus: null,
integrationHealth: null,
localModelCatalog: null,
backendCapabilities: null,
@@ -288,6 +289,20 @@ function formatNumber(value) {
return String(Math.round(num * 10) / 10);
}
function formatBytes(value) {
const num = Number(value || 0);
if (!Number.isFinite(num) || num <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = num;
let idx = 0;
while (size >= 1024 && idx < units.length - 1) {
size /= 1024;
idx += 1;
}
const fixed = size >= 10 || idx === 0 ? size.toFixed(0) : size.toFixed(1);
return `${fixed}${units[idx]}`;
}
function formatDateTime(value) {
if (!value) return "-";
const date = new Date(value);
@@ -764,6 +779,7 @@ async function logoutSession() {
appState.trackingDigest = null;
appState.reviews = [];
appState.integrationHealth = null;
appState.storageStatus = null;
appState.backendCapabilities = null;
appState.lastAction = null;
appState.lastGeneratedCopy = null;
@@ -784,6 +800,17 @@ async function loadKnowledgeDocuments(knowledgeBases) {
return groups.flat().slice(0, 12);
}
async function loadStorageStatus(projectId = "") {
if (!backendSupports("/v2/storage/status")) {
appState.storageStatus = null;
return null;
}
const suffix = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
const payload = await storyforgeFetch(`/v2/storage/status${suffix}`).catch(() => null);
appState.storageStatus = payload;
return payload;
}
async function loadPlatformAccount(platform, accountId) {
if (!accountId) return;
const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform());
@@ -852,6 +879,7 @@ async function bootstrap() {
const supportsReviews = backendSupports("/v2/reviews");
const supportsIntegrationHealth = backendSupports("/v2/integrations/health");
const supportsLocalModels = backendSupports("/v2/integrations/local-models");
const supportsStorageStatus = backendSupports("/v2/storage/status");
const supportsLiveRecorderSources = backendSupports("/v2/live-recorder/sources");
const supportsLiveRecorderStatus = backendSupports("/v2/live-recorder/status");
const supportsLiveRecorderFiles = backendSupports("/v2/live-recorder/files");
@@ -896,6 +924,11 @@ async function bootstrap() {
appState.localModelCatalog = localModelCatalog;
appState.documents = await loadKnowledgeDocuments(dashboard.knowledge_bases);
appState.selectedProjectId = appState.selectedProjectId || dashboard.projects?.[0]?.id || "";
if (supportsStorageStatus) {
await loadStorageStatus(appState.selectedProjectId || "");
} else {
appState.storageStatus = null;
}
const selectedAssistantExists = safeArray(dashboard.assistants).some((item) => item.id === appState.selectedAssistantId);
appState.selectedAssistantId = selectedAssistantExists ? appState.selectedAssistantId : (dashboard.assistants?.[0]?.id || "");
const selectedAccountExists = appState.accounts.some((item) => item.id === appState.selectedAccountId);
@@ -1352,6 +1385,71 @@ function renderLiveRecorderSummaryHtml() {
`;
}
function renderStorageStatusPanel() {
const storage = appState.storageStatus;
if (!storage) {
return `
<div class="panel pad">
<div class="panel-head"><div><h3>存储状态</h3><div class="panel-subtitle">当前后端暂未接入存储状态接口</div></div></div>
<div class="task-item"><h4>未拉取</h4><p>等 live collector 更新后,这里会显示 NAS 缓存、当前项目占用和目录策略。</p></div>
</div>
`;
}
const strategy = storage.strategy || {};
const disk = storage.disk || {};
const usage = storage.tenant_usage || {};
const recentJobs = safeArray(usage.recent_jobs);
const strategyTags = [
`数据库 ${strategy.database?.mode || "local"}`,
`分析缓存 ${strategy.jobs?.mode || "local"}`,
`下载缓存 ${strategy.downloads?.mode || "local"}`,
`直播录制 ${strategy.live_recorder?.mode || "nas_service"}`,
];
const usageCards = [
{ label: "当前项目缓存", value: formatBytes(usage.project_jobs?.bytes), sub: `文件 ${formatNumber(usage.project_jobs?.file_count)}` },
{ label: "当前项目下载", value: formatBytes(usage.project_downloads?.bytes), sub: `文件 ${formatNumber(usage.project_downloads?.file_count)}` },
{ label: "当前账号缓存", value: formatBytes(usage.account_jobs?.bytes), sub: `文件 ${formatNumber(usage.account_jobs?.file_count)}` },
{ label: "NAS 剩余", value: formatBytes(disk.jobs?.free_bytes), sub: `总量 ${formatBytes(disk.jobs?.total_bytes)}` }
];
return `
<div class="panel pad">
<div class="panel-head"><div><h3>存储状态</h3><div class="panel-subtitle">数据库留本机,大文件缓存优先走 NAS</div></div><span class="tag blue">${escapeHtml((strategy.jobs?.mode || "local").toUpperCase())}</span></div>
<div class="task-meta">
${strategyTags.map((item) => `<span class="tag">${escapeHtml(item)}</span>`).join("")}
</div>
<div class="mini-grid" style="margin-top:14px;">
${usageCards.map((item) => `
<div class="mini-card">
<small>${escapeHtml(item.label)}</small>
<strong>${escapeHtml(item.value)}</strong>
<span>${escapeHtml(item.sub)}</span>
</div>
`).join("")}
</div>
<div class="list" style="margin-top:14px;">
<div class="task-item compact">
<h4>当前项目缓存根目录</h4>
<p>${escapeHtml(usage.project_jobs?.path || strategy.jobs?.path || "-")}</p>
</div>
<div class="task-item compact">
<h4>下载缓存根目录</h4>
<p>${escapeHtml(usage.project_downloads?.path || strategy.downloads?.path || "-")}</p>
</div>
${recentJobs.slice(0, 3).map((item) => `
<div class="task-item compact">
<h4>${escapeHtml(item.title || item.job_id)}</h4>
<p>${escapeHtml(item.paths?.[0]?.path || item.job_id)}</p>
<div class="task-meta">
<span class="tag">${escapeHtml(item.status || "-")}</span>
<span class="tag">${escapeHtml(formatDateTime(item.updated_at))}</span>
</div>
</div>
`).join("") || `<div class="task-item compact"><h4>还没有缓存样本</h4><p>上传视频、导入作品后,这里会显示最近写入 NAS 的缓存路径。</p></div>`}
</div>
</div>
`;
}
function getIntegrationOverview() {
const cards = getIntegrationCards();
const reachableCount = cards.filter((item) => item.detail.available && item.detail.reachable).length;
@@ -1701,6 +1799,7 @@ function renderDashboardScreen() {
<div class="mini-card"><small>来源</small><strong>${escapeHtml(formatNumber(getSelectedProject() ? getProjectStats(getSelectedProject().id).sources.length : 0))}</strong></div>
</div>
</div>
${renderStorageStatusPanel()}
<div class="panel pad">
<div class="panel-head"><div><h3>跟踪摘要</h3><div class="panel-subtitle">按最近同步的账号作品生成</div></div><span class="tag blue">${escapeHtml(daysSince(appState.lastSeenAt))} 天汇总</span></div>
<div class="list">
@@ -3771,6 +3870,14 @@ document.addEventListener("click", async (event) => {
}
if (name === "select-project") {
appState.selectedProjectId = action.dataset.projectId || "";
if (backendSupports("/v2/storage/status")) {
setBusy(true, "正在切换项目存储视图...");
try {
await loadStorageStatus(appState.selectedProjectId || "");
} finally {
setBusy(false, "");
}
}
renderAll();
return;
}