XINGQI · OPERATIONS
星栖运营全景
方向、作品、研究和预算,放在一起看。
PAWPLAY · WEEKLY REVIEW
运营概览
收支台账
现金、分摊、预估分别保留PAWPLAY · EVOLUTION
演变路径
从起因到下一步,回看每次探索的依据与决定。
PAWPLAY · DAILY
每日计划与复盘
按日期查看计划、执行、证据与下一步。
diff --git a/ops-portal/public/app.js b/ops-portal/public/app.js index e8c649a..1fe25ac 100644 --- a/ops-portal/public/app.js +++ b/ops-portal/public/app.js @@ -108,8 +108,8 @@ function renderLedger() { table.append(head, body); wrapper.append(table); host.append(wrapper); } function showView(view) { - for (const name of ['overview', 'report', 'evolution', 'daily']) $(`#${name}-view`).hidden = view !== name; - for (const [selector, active] of [['#show-overview', view === 'overview'], ['#show-report', view === 'report'], ['#show-evolution', view === 'evolution'], ['#show-daily', view === 'daily']]) { + for (const name of ['overview', 'execution', 'report', 'evolution', 'daily']) $(`#${name}-view`).hidden = view !== name; + for (const [selector, active] of [['#show-overview', view === 'overview'], ['#show-execution', view === 'execution'], ['#show-report', view === 'report'], ['#show-evolution', view === 'evolution'], ['#show-daily', view === 'daily']]) { $(selector).classList.toggle('active', active); $(selector).setAttribute('aria-pressed', String(active)); } } @@ -126,6 +126,25 @@ async function openOverview() { const trends = el('section', undefined, 'report-section'); trends.append(el('h2', 'B 站公开榜单快照'), el('p', '单时点观察,基于标题和公开数据,不能代表趋势或保证效果。', 'muted')); const ul = el('ul'); for (const v of market.ranking || []) { const li = el('li'); if (v.bvid) { const a = el('a', v.title); a.href = `https://www.bilibili.com/video/${v.bvid}/`; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.append(a); } else li.append(el('span', v.title)); li.append(document.createTextNode(` · ${v.owner} · ${v.views ?? '未知'} 播放`)); ul.append(li); } trends.append(ul); host.append(trends); } catch (error) { host.replaceChildren(el('p', error.message, 'error')); } } +async function openExecution(updateHistory = true) { + showView('execution'); if (updateHistory) history.replaceState(null, '', '/?view=execution'); + const host = $('#execution-content'); host.replaceChildren(el('p', '正在读取运行状态…', 'muted')); + try { + const [data, queue] = await Promise.all([api('/api/execution'), api('/api/workqueue')]); host.replaceChildren(); + const sampled = Date.parse(data.observed_at || ''); const stale = !Number.isFinite(sampled) || Date.now() - sampled > 3 * 60 * 1000; + host.append(el('p', `状态采样:${data.observed_at ? dateTime(data.observed_at) : '暂无'}${stale ? ' · 状态可能过期,请检查采集服务' : ' · 约每两分钟刷新'}`, stale ? 'error' : 'muted')); + const now = el('section', undefined, 'report-section'); now.append(el('h2', '当前执行')); + const cycle = (data.units || []).find(x => x.id === 'cycle'); const active = cycle && ['active','activating'].includes(cycle.state); + if (data.daily_budget) now.append(el('p', `今日主运营尝试:${data.daily_budget.attempts} / ${data.daily_budget.limit};达到上限后等待次日,避免耗尽共享额度。`, 'muted')); + now.append(el('p', stale ? '快照已过期,不能确定当前运行情况。' : active ? '主运营进程正在运行。' : cycle?.state === 'inactive' ? '主运营当前空闲,等待可执行任务或预算恢复。' : `主运营状态:${cycle?.state || '未知'}`)); + for (const t of data.running_tasks || []) now.append(el('p', `领取任务:${t.title} · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); host.append(now); + const schedules = el('section', undefined, 'report-section'); schedules.append(el('h2', '服务与计划')); const list = el('ul'); + for (const u of data.units || []) list.append(el('li', `${u.label} · ${u.state === 'activating' || u.state === 'active' ? '正在运行' : u.state === 'inactive' ? '当前空闲' : u.state || '未知'} · 定时器${u.timer_state === 'active' ? '已启用' : u.timer_state || '未知'} · 下次 ${u.next_run || '未知'} · 上次结果 ${u.result || '暂无'}`)); schedules.append(list); host.append(schedules); + const tasks = el('section', undefined, 'report-section'); tasks.append(el('h2', '任务列表')); const status = {pending:'待执行或等待退避',running:'已领取',done:'已完成',blocked:'已阻塞'}; const ul = el('ul'); for (const t of queue.tasks || []) ul.append(el('li', `${t.title} · ${status[t.status] || t.status} · 尝试 ${t.attempts} 次 · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); tasks.append(ul); host.append(tasks); + const past = el('section', undefined, 'report-section'); past.append(el('h2', '主运营执行历史'), el('p', '记录来自每轮执行汇总;进程成功不代表整个任务已经完成。详细产出和复盘见每日计划与复盘。', 'muted')); + const results = {complete:'进程成功',failed:'失败',timeout:'超时',incomplete:'未记录进度',preflight_failed:'前置检查失败',unknown:'状态未知'}; const rows = el('ul'); for (const x of data.history || []) rows.append(el('li', `${dateTime(x.started_at)} · ${x.title} · ${results[x.status] || '未知'}${x.elapsed_seconds === null ? '' : ` · ${x.elapsed_seconds} 秒`}`)); if (!(data.history || []).length) rows.append(el('li', '暂无执行汇总')); past.append(rows); host.append(past); + } catch (error) { host.replaceChildren(el('p', error.message, 'error')); } +} async function openDaily() { showView('daily'); const host = $('#daily-content'); host.replaceChildren(el('p', '正在读取每日记录…', 'muted')); @@ -205,8 +224,11 @@ async function loadDashboard() { if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest); if (initialView === 'evolution') await openEvolution(); if (initialView === 'daily') await openDaily(); + if (initialView === 'execution') await openExecution(false); if (!selected && !initialView) await openOverview(); } +$('#show-execution').addEventListener('click', () => openExecution()); +setInterval(() => { if (dashboard && !$('#execution-view').hidden) openExecution(false); }, 60000); $('#show-overview').addEventListener('click', openOverview); $('#show-evolution').addEventListener('click', openEvolution); $('#show-daily').addEventListener('click', openDaily); diff --git a/ops-portal/public/index.html b/ops-portal/public/index.html index 0211b4f..12ebd69 100644 --- a/ops-portal/public/index.html +++ b/ops-portal/public/index.html @@ -16,7 +16,7 @@
XINGQI · OPERATIONS
方向、作品、研究和预算,放在一起看。
PAWPLAY · WEEKLY REVIEW
PAWPLAY · EVOLUTION
从起因到下一步,回看每次探索的依据与决定。
PAWPLAY · DAILY
按日期查看计划、执行、证据与下一步。
XINGQI · OPERATIONS
方向、作品、研究和预算,放在一起看。
XINGQI · EXECUTION
区分实际运行、等待执行和已结束的工作。
PAWPLAY · WEEKLY REVIEW
PAWPLAY · EVOLUTION
从起因到下一步,回看每次探索的依据与决定。
PAWPLAY · DAILY
按日期查看计划、执行、证据与下一步。
正在打开运营台…
diff --git a/ops-portal/server.mjs b/ops-portal/server.mjs index ea88f0c..737fe86 100644 --- a/ops-portal/server.mjs +++ b/ops-portal/server.mjs @@ -154,6 +154,7 @@ export function createApp(options = process.env) { } if (route.startsWith('/api/')) { if (!session(req)) return json(res, 401, { error: '请先登录' }); + if (req.method === 'GET' && route === '/api/execution') return json(res, 200, await jsonFile('ops-status.json', { schema: 1, observed_at: null, units: [], running_tasks: [], history: [] })); if (req.method === 'GET' && route === '/api/overview') { const strategy = await jsonFile('ops-strategy.json', {}), market = await jsonFile('market-latest.json', {}); const short = (v, n = 300) => typeof v === 'string' ? v.slice(0, n) : ''; diff --git a/ops-portal/test/server.test.mjs b/ops-portal/test/server.test.mjs index fb024b8..116d852 100644 --- a/ops-portal/test/server.test.mjs +++ b/ops-portal/test/server.test.mjs @@ -185,3 +185,12 @@ test('overview is authenticated and exposes only selected fields', async t => { assert.doesNotMatch(text, /SECRET|evil/); const data = JSON.parse(text); assert.equal(data.strategy.mission, '星栖'); assert.equal(data.market.ranking[0].bvid, ''); }); + +test('execution status is private and absent data degrades safely', async t => { + const { directory, request, login } = await fixture(t); + await writeFile(path.join(directory, 'ops-status.json'), JSON.stringify({schema:1,observed_at:'2026-11-04T04:00:00Z',units:[],running_tasks:[],history:[{title:'任务',status:'complete'}]})); + assert.equal((await request('/api/execution')).status, 401); + const cookie = (await login()).headers.get('set-cookie').split(';')[0]; + const data = await (await request('/api/execution', { headers:{cookie} })).json(); + assert.equal(data.history[0].title, '任务'); assert.equal(data.history[0].status, 'complete'); +});