@leviyuan/lodestar 0.1.0 → 2.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,733 @@
1
+ /**
2
+ * Status dashboard — scans running processes and DeepSeek threads,
3
+ * renders HTML, and pushes to the static hosting endpoint every 2s.
4
+ *
5
+ * DeepSeek TUI migration: tmux-based Claude monitoring replaced with
6
+ * /proc scanning for deepseek processes and thread status from Runtime API.
7
+ */
8
+ import { readFileSync, readdirSync, readlinkSync, statSync, existsSync } from 'node:fs'
9
+ import { execSync } from 'node:child_process'
10
+ import { homedir } from 'node:os'
11
+ import { createHash, createHmac } from 'node:crypto'
12
+ import { gzipSync } from 'node:zlib'
13
+ import http, { type IncomingMessage, type ServerResponse } from 'node:http'
14
+ import { join } from 'node:path'
15
+
16
+ const HOME = homedir()
17
+ const USER_BASE = HOME + '/'
18
+ const STATE_DIR = join(HOME, '.deepseek', 'lodestar')
19
+ const SLUG = 'feishu-ops'
20
+ const TITLE = '夜航星'
21
+ const VERSION = (() => {
22
+ try { return JSON.parse(readFileSync(join(import.meta.dir, 'package.json'), 'utf-8')).version || '' }
23
+ catch { return '' }
24
+ })()
25
+
26
+ // ── Env / credentials ──────────────────────────────────────────────
27
+ function loadEnv(): Record<string, string> {
28
+ const out: Record<string, string> = {}
29
+ const envPath = join(homedir(), '.deepseek', 'lodestar_status.env')
30
+ try {
31
+ for (const raw of readFileSync(envPath, 'utf-8').split('\n')) {
32
+ const line = raw.trim()
33
+ if (!line || line.startsWith('#') || !line.includes('=')) continue
34
+ const i = line.indexOf('=')
35
+ out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
36
+ }
37
+ } catch {}
38
+ for (const k of ['STATUS_PAGE_HOST', 'STATUS_PAGE_PORT', 'STATUS_PAGE_SECRET', 'STATUS_PAGE_MODE', 'STATUS_PAGE_HTTP_PORT'])
39
+ if (process.env[k]) out[k] = process.env[k]!
40
+ return out
41
+ }
42
+
43
+ const ENV = loadEnv()
44
+ const UPLOAD_PORT = parseInt(ENV.STATUS_PAGE_PORT || '0')
45
+ const UPLOAD_SECRET = ENV.STATUS_PAGE_SECRET || ''
46
+ const STATUS_MODE: 'upload' | 'http' = ENV.STATUS_PAGE_MODE === 'upload' ? 'upload' : 'http'
47
+ const HTTP_PORT = parseInt(ENV.STATUS_PAGE_HTTP_PORT || '3120')
48
+
49
+ // ── Helpers ────────────────────────────────────────────────────────
50
+ const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]/g
51
+ const OMCHUD_RE = /5h:(\d+)%\*?\(~?([^)]+)\)\s+wk:(\d+)%\*?\(~?([^)]+)\)(?:\s+sn:(\d+)%\*?)?(?:\s*\|\s*([a-zA-Z_][\w\- ]*?)(?=\s*\|\s*session:))?(?:\s*\|\s*session:([\w:]+))?.*?ctx:(\d+)%.*?(?:🔧|\ud83d\udd27)(\d+)/
52
+
53
+ function sh(cmd: string, timeout = 3000): string {
54
+ try { return execSync(cmd, { timeout, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }) }
55
+ catch { return '' }
56
+ }
57
+
58
+ function readProc(path: string): string {
59
+ try { return readFileSync(path, 'utf-8') } catch { return '' }
60
+ }
61
+
62
+ function readLink(path: string): string {
63
+ try { return readlinkSync(path) } catch { return '' }
64
+ }
65
+
66
+ function fmtUptime(sec: number): string {
67
+ const s = Math.floor(sec)
68
+ if (s < 60) return `${s}s`
69
+ const m = Math.floor(s / 60)
70
+ if (m < 60) return `${m}m${s % 60}s`
71
+ const h = Math.floor(m / 60)
72
+ if (h < 24) return `${h}h${m % 60}m`
73
+ const d = Math.floor(h / 24)
74
+ return `${d}d${h % 24}h`
75
+ }
76
+
77
+ function fmtBytes(n: number): string {
78
+ if (n < 1024) return `${n}B`
79
+ if (n < 1024 ** 2) return `${Math.round(n / 1024)}K`
80
+ if (n < 1024 ** 3) return `${Math.round(n / 1024 / 1024)}M`
81
+ return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`
82
+ }
83
+
84
+ function fmtLastEvent(sec: number | null): string {
85
+ if (sec == null) return '沉寂'
86
+ if (sec < 60) return `${sec}s 前`
87
+ if (sec < 3600) return `${Math.floor(sec / 60)}m 前`
88
+ if (sec < 86400) return `${Math.floor(sec / 3600)}h 前`
89
+ return `${Math.floor(sec / 86400)}d 前`
90
+ }
91
+
92
+ function parseHudTimeToHours(s: string): number {
93
+ const m = s.trim().match(/^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/)
94
+ if (!m) return 0
95
+ return (parseInt(m[1] || '0') * 24) + parseInt(m[2] || '0') + parseInt(m[3] || '0') / 60
96
+ }
97
+
98
+ function esc(s: any): string {
99
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
100
+ }
101
+
102
+ // ── Boot time ──────────────────────────────────────────────────────
103
+ let BOOT_TIME = 0
104
+ try {
105
+ for (const line of readProc('/proc/stat').split('\n'))
106
+ if (line.startsWith('btime ')) { BOOT_TIME = parseFloat(line.split(' ')[1]); break }
107
+ } catch {}
108
+ const CLK_TCK = 100
109
+
110
+ // ── Process scanning ───────────────────────────────────────────────
111
+ interface ProcInfo {
112
+ pid: number; kind: 'deepseek' | 'python' | 'daemon'; project: string
113
+ script: string; rss: number; uptime: number; lastLogAgo: number | null
114
+ }
115
+
116
+ function procStat(pid: number): { starttime: number; rss: number } | null {
117
+ const raw = readProc(`/proc/${pid}/stat`)
118
+ const ci = raw.lastIndexOf(')')
119
+ if (ci < 0) return null
120
+ const fields = raw.slice(ci + 2).split(' ')
121
+ try {
122
+ return { starttime: parseInt(fields[19]), rss: parseInt(fields[21]) * 4096 }
123
+ } catch { return null }
124
+ }
125
+
126
+ function scanProcs(): ProcInfo[] {
127
+ const uid = process.getuid!()
128
+ const now = Date.now() / 1000
129
+ const out: ProcInfo[] = []
130
+ let entries: string[]
131
+ try { entries = readdirSync('/proc').filter(n => /^\d+$/.test(n)) } catch { return out }
132
+
133
+ for (const name of entries) {
134
+ const pid = parseInt(name)
135
+ try { if (statSync(`/proc/${pid}`).uid !== uid) continue } catch { continue }
136
+ const cwd = readLink(`/proc/${pid}/cwd`)
137
+ if (!cwd.startsWith(USER_BASE)) continue
138
+ const rel = cwd.slice(USER_BASE.length)
139
+ const project = rel.split('/')[0]
140
+ if (!project || project.startsWith('.')) continue
141
+
142
+ const cmdlineRaw = readProc(`/proc/${pid}/cmdline`)
143
+ if (!cmdlineRaw) continue
144
+ const cmdline = cmdlineRaw.split('\0').filter(Boolean)
145
+ if (!cmdline.length) continue
146
+
147
+ const exe = readLink(`/proc/${pid}/exe`)
148
+ const exeBase = exe ? exe.split('/').pop()! : ''
149
+ const cmd0 = cmdline[0].split('/').pop()!
150
+
151
+ let kind: 'deepseek' | 'python' | 'daemon' | null = null
152
+ if (exeBase.startsWith('python') || cmd0.startsWith('python')) kind = 'python'
153
+ else if (cmd0 === 'deepseek' || cmdline[0].includes('/deepseek')) kind = 'deepseek'
154
+ else if ((cmd0 === 'bun' || cmd0.startsWith('bun')) && cwd.includes('.deepseek')) kind = 'daemon'
155
+ if (!kind) continue
156
+
157
+ if (kind === 'python' && cmdline.includes('start-mcp-server')) continue
158
+ if (kind === 'deepseek' && cmdline.includes(' mcp-server ')) continue
159
+
160
+ const st = procStat(pid)
161
+ if (!st) continue
162
+ const uptime = now - (BOOT_TIME + st.starttime / CLK_TCK)
163
+
164
+ let script = ''
165
+ if (kind === 'python') {
166
+ for (const arg of cmdline.slice(1)) {
167
+ if (arg.startsWith('-')) continue
168
+ script = arg.split('/').pop()!
169
+ if (script.endsWith('.py')) script = script.slice(0, -3)
170
+ break
171
+ }
172
+ }
173
+
174
+ let lastLogAgo: number | null = null
175
+ if (kind === 'python') {
176
+ try {
177
+ const st2 = statSync(`/proc/${pid}/fd/1`)
178
+ lastLogAgo = Math.floor(now - st2.mtimeMs / 1000)
179
+ } catch {}
180
+ }
181
+
182
+ out.push({ pid, kind, project, script, rss: st.rss, uptime, lastLogAgo })
183
+ }
184
+ return out
185
+ }
186
+
187
+ // ── Tmux stubs (no longer used in DeepSeek TUI architecture) ───────
188
+ interface TmuxSession { activity: number; created: number; tty: string }
189
+
190
+ function tmuxSessions(): Map<string, TmuxSession> {
191
+ return new Map()
192
+ }
193
+
194
+ export function capturePane(_session: string, _lines = 40): string {
195
+ return ''
196
+ }
197
+
198
+ // ── State detection (simplified for DeepSeek — no Claude HUD) ─────
199
+ interface OmcHud {
200
+ h5Pct: number; h5Left: string; wkPct: number; wkLeft: string
201
+ snPct: number | null; activity: string | null; session: string | null
202
+ ctxPct: number; tools: number
203
+ }
204
+
205
+ export function parseOmcHud(_tail: string): OmcHud | null {
206
+ return null // OMC HUD is Claude-specific
207
+ }
208
+
209
+ export type StateSlug = 'thinking' | 'tool_running' | 'bg_running' | 'waiting_user' | 'menu_select' | 'idle' | 'script' | 'unknown'
210
+
211
+ export function detectPendingMenu(_tail: string): { state: 'menu_select' | 'waiting_user'; menu: { question: string; options: string[]; raw: string } } | null {
212
+ return null
213
+ }
214
+
215
+ export function detectState(_tail: string, _hud: OmcHud | null, _lastOutputAgo?: number | null): [StateSlug, string] {
216
+ return ['script', '运行中']
217
+ }
218
+
219
+ // ── System metrics ─────────────────────────────────────────────────
220
+ function systemMetrics(): { cpuPct: number | null; memPct: number | null } {
221
+ let cpuPct: number | null = null
222
+ let memPct: number | null = null
223
+ try {
224
+ const la = readProc('/proc/loadavg').split(' ')
225
+ const cores = require('os').cpus().length || 1
226
+ cpuPct = Math.min(100, Math.round(parseFloat(la[0]) / cores * 100))
227
+ } catch {}
228
+ try {
229
+ const mem: Record<string, number> = {}
230
+ for (const line of readProc('/proc/meminfo').split('\n')) {
231
+ const [k, v] = line.split(':')
232
+ if (k && v) mem[k.trim()] = parseInt(v.trim())
233
+ }
234
+ memPct = Math.round((mem.MemTotal - mem.MemAvailable) / mem.MemTotal * 100)
235
+ } catch {}
236
+ return { cpuPct, memPct }
237
+ }
238
+
239
+ // ── Feishu / web-proxy state ───────────────────────────────────────
240
+ function serviceState(name: string): { active: boolean; uptime: string | null } {
241
+ const status = sh(`systemctl --user is-active ${name}`).trim()
242
+ const active = status === 'active'
243
+ let uptime: string | null = null
244
+ if (active) {
245
+ const pidRaw = sh(`systemctl --user show -p MainPID ${name}`).trim()
246
+ try {
247
+ const pid = parseInt(pidRaw.split('=')[1])
248
+ if (pid > 0) {
249
+ const st = procStat(pid)
250
+ if (st) uptime = fmtUptime(Date.now() / 1000 - (BOOT_TIME + st.starttime / CLK_TCK))
251
+ }
252
+ } catch {}
253
+ }
254
+ return { active, uptime }
255
+ }
256
+
257
+ function lastDaemonEvent(): number | null {
258
+ const tsFile = join(STATE_DIR, 'last_user_message')
259
+ try {
260
+ const ts = parseInt(readFileSync(tsFile, 'utf-8').trim(), 10)
261
+ if (ts > 0) return Math.floor(Date.now() / 1000 - ts)
262
+ } catch {}
263
+ return null
264
+ }
265
+
266
+ // ── Project self-reported status ───────────────────────────────────
267
+ type ProjectStatusSlug = 'ok' | 'warn' | 'err' | 'build' | 'deploy'
268
+ interface ProjectStatus {
269
+ status?: ProjectStatusSlug
270
+ updated_at?: number
271
+ kv?: Record<string, string>
272
+ }
273
+ const PS_SLUGS: Record<ProjectStatusSlug, true> = { ok: true, warn: true, err: true, build: true, deploy: true }
274
+
275
+ function loadProjectStatus(project: string): ProjectStatus | null {
276
+ const p = join(USER_BASE, project, 'status.json')
277
+ try {
278
+ if (!existsSync(p)) return null
279
+ const st = statSync(p)
280
+ if (st.size > 4096) return null
281
+ const parsed = JSON.parse(readFileSync(p, 'utf-8'))
282
+ if (!parsed || typeof parsed !== 'object') return null
283
+ const out: ProjectStatus = {}
284
+ if (typeof parsed.status === 'string' && (PS_SLUGS as any)[parsed.status]) out.status = parsed.status
285
+ if (typeof parsed.updated_at === 'number' && parsed.updated_at > 0) out.updated_at = parsed.updated_at
286
+ if (parsed.kv && typeof parsed.kv === 'object') {
287
+ const kv: Record<string, string> = {}
288
+ let n = 0
289
+ for (const [k, v] of Object.entries(parsed.kv as Record<string, any>)) {
290
+ if (n >= 5) break
291
+ const key = String(k).slice(0, 10).trim()
292
+ const val = String(v).slice(0, 20).trim()
293
+ if (key && val) { kv[key] = val; n++ }
294
+ }
295
+ if (Object.keys(kv).length) out.kv = kv
296
+ }
297
+ return out
298
+ } catch { return null }
299
+ }
300
+
301
+ // ── Email queue (mirror file) ──────────────────────────────────────
302
+ interface OutsourceInfo { queued?: number; running?: string | null; total?: number }
303
+ function loadOutsourceQueue(): Record<string, OutsourceInfo> {
304
+ const path = join(STATE_DIR, 'email-queue.json')
305
+ try {
306
+ if (existsSync(path)) {
307
+ const d = JSON.parse(readFileSync(path, 'utf-8'))
308
+ return d.projects || {}
309
+ }
310
+ } catch {}
311
+ return {}
312
+ }
313
+
314
+ // ── Build data ─────────────────────────────────────────────────────
315
+ interface Card {
316
+ project: string; deepseek: ProcInfo | null
317
+ pythons: ProcInfo[]; state: StateSlug; stateLabel: string
318
+ outsource?: OutsourceInfo; lastOutput?: number
319
+ projectStatus?: ProjectStatus | null
320
+ }
321
+
322
+ function buildData() {
323
+ const procs = scanProcs()
324
+ const byProject = new Map<string, Card>()
325
+
326
+ for (const p of procs) {
327
+ if (!byProject.has(p.project))
328
+ byProject.set(p.project, { project: p.project, deepseek: null, pythons: [], state: 'script', stateLabel: '运行中' })
329
+ const card = byProject.get(p.project)!
330
+
331
+ if (card.lastOutput == null && p.lastLogAgo != null) {
332
+ card.lastOutput = p.lastLogAgo
333
+ }
334
+
335
+ if (p.kind === 'deepseek') {
336
+ card.deepseek = p
337
+ card.state = 'script'
338
+ card.stateLabel = '运行中'
339
+ } else if (p.kind === 'daemon') {
340
+ // daemon processes don't get their own card — skip
341
+ continue
342
+ } else {
343
+ card.pythons.push(p)
344
+ }
345
+ }
346
+
347
+ const outsource = loadOutsourceQueue()
348
+ const cards = [...byProject.values()]
349
+ for (const c of cards) {
350
+ if (outsource[c.project]) c.outsource = outsource[c.project]
351
+ }
352
+ for (const [name, info] of Object.entries(outsource)) {
353
+ if (byProject.has(name) || (!info.queued && !info.running)) continue
354
+ cards.push({ project: name, deepseek: null, pythons: [], state: 'idle', stateLabel: '分遣中', outsource: info })
355
+ }
356
+
357
+ for (const c of cards) {
358
+ c.projectStatus = loadProjectStatus(c.project)
359
+ }
360
+
361
+ cards.sort((a, b) => {
362
+ const ha = a.deepseek ? 0 : 1, hb = b.deepseek ? 0 : 1
363
+ if (ha !== hb) return ha - hb
364
+ return a.project.localeCompare(b.project)
365
+ })
366
+
367
+ return {
368
+ cards,
369
+ globalUsage: null as { h5Pct: number; h5Left: string; wkPct: number; wkLeft: string; source: string; _stale?: boolean } | null,
370
+ feishu: serviceState('feishu-daemon'),
371
+ webproxy: serviceState('web-proxy'),
372
+ lastEvent: lastDaemonEvent(),
373
+ sys: systemMetrics(),
374
+ ts: Math.floor(Date.now() / 1000),
375
+ }
376
+ }
377
+
378
+ // ── HTML rendering ─────────────────────────────────────────────────
379
+ const STATE_BADGE: Record<string, string> = {
380
+ thinking: 'b-ok', tool_running: 'b-ok', bg_running: 'b-orange',
381
+ waiting_user: 'b-warn', menu_select: 'b-warn', idle: 'b-mute', script: 'b-run', unknown: 'b-mute',
382
+ }
383
+ const STATE_OFFICE: Record<string, string> = {
384
+ thinking: 'thinking', tool_running: 'thinking', bg_running: 'bg',
385
+ waiting_user: 'waiting', menu_select: 'waiting', idle: 'idle', script: 'running', unknown: 'idle',
386
+ }
387
+ const STATE_LETTER: Record<string, string> = {
388
+ thinking: 'T', tool_running: 'T', bg_running: 'B',
389
+ waiting_user: 'W', menu_select: 'M', idle: 'I', script: 'S', unknown: 'X',
390
+ }
391
+
392
+ function mbar(pct: number, color: string): string {
393
+ pct = Math.max(0, Math.min(100, pct))
394
+ return `<span class="mbar"><span class="fill" style="width:${pct}%;--fill:${color}"></span></span>`
395
+ }
396
+
397
+ function fuelGauge(pct: number): string {
398
+ pct = Math.max(0, Math.min(100, pct))
399
+ const remaining = Math.max(0, 100 - pct)
400
+ const lit = Math.max(0, Math.min(10, Math.round(remaining / 10)))
401
+ const level = remaining >= 60 ? 'ok' : remaining >= 30 ? 'mid' : 'crit'
402
+ let cells = ''
403
+ for (let i = 0; i < 10; i++) {
404
+ cells += i < lit ? '<span class="cell"></span>' : '<span class="cell off"></span>'
405
+ }
406
+ return `<span class="fuel-gauge ${level}" title="剩余燃料 ${remaining}%">${cells}</span>`
407
+ }
408
+
409
+ const HUD_CORNERS = '<span class="hc tl"></span><span class="hc tr"></span><span class="hc bl"></span><span class="hc br"></span>'
410
+
411
+ function render(data: ReturnType<typeof buildData>): string {
412
+ const { cards, globalUsage: gu, feishu, webproxy, lastEvent, sys, ts } = data
413
+
414
+ const fhCls = feishu.active ? 'ok' : 'err'
415
+ const fhLabel = feishu.active ? '接入' : '掉线'
416
+ const wpCls = webproxy.active ? 'ok' : 'err'
417
+ const wpLabel = webproxy.active ? '在轨' : '失联'
418
+ const lastEvTxt = fmtLastEvent(lastEvent)
419
+ const daemonUp = feishu.uptime || '—'
420
+ const wpUp = webproxy.uptime || '—'
421
+
422
+ let brainPill = '<span class="pill fuel empty"><span class="k">燃料</span><span class="v muted">未接入</span></span>'
423
+ if (gu) {
424
+ const h5h = parseHudTimeToHours(gu.h5Left)
425
+ const wkRem = Math.max(0, 100 - gu.wkPct)
426
+ const h5Rem = Math.max(0, 100 - gu.h5Pct)
427
+ const level = h5Rem >= 60 ? 'ok' : h5Rem >= 30 ? 'mid' : 'crit'
428
+ const pillCls = gu._stale ? `pill fuel stale ${level}` : `pill fuel ${level}`
429
+ brainPill = `<span class="${pillCls}"><span class="k">燃料</span>${fuelGauge(gu.h5Pct)}<span class="v">${h5h.toFixed(1)}h · ${wkRem}%</span></span>`
430
+ }
431
+
432
+ const powerPill = sys.cpuPct != null
433
+ ? `<span class="pill power"><span class="k">堆芯</span>${mbar(sys.cpuPct, '#ff7b72')}<span class="v">${sys.cpuPct}%</span></span>` : ''
434
+ const warePill = sys.memPct != null
435
+ ? `<span class="pill ware"><span class="k">载舱</span>${mbar(sys.memPct, '#d2a8ff')}<span class="v">${sys.memPct}%</span></span>` : ''
436
+
437
+ const staffCount = cards.filter(c => c.deepseek).length
438
+ const deviceCount = cards.reduce((n, c) => n + c.pythons.length, 0)
439
+
440
+ const frontdesk = `
441
+ <div class="frontdesk">
442
+ <span class="pill p-gw ${fhCls}"><span class="k">信关</span><span class="v">${fhLabel}</span></span>
443
+ <span class="pill p-up"><span class="k">在轨</span><span class="v">${esc(daemonUp)}</span></span>
444
+ <span class="pill p-last"><span class="k">末讯</span><span class="v">${esc(lastEvTxt)}</span></span>
445
+ <span class="pill p-relay ${wpCls}"><span class="k">中继</span><span class="v">${wpLabel} ${esc(wpUp)}</span></span>
446
+ ${powerPill}${warePill}${brainPill}
447
+ <span class="pill p-crew"><span class="k">机组</span><span class="v">${staffCount}/${cards.length}</span></span>
448
+ <span class="pill p-dev"><span class="k">副机</span><span class="v">${deviceCount}</span></span>
449
+ </div>`
450
+
451
+ let officesHtml = ''
452
+ if (!cards.length) {
453
+ officesHtml = '<div class="empty-room">舰桥未载单位 — ~/ 下无运行实例</div>'
454
+ } else {
455
+ const parts: string[] = []
456
+ for (let i = 0; i < cards.length; i++) {
457
+ const c = cards[i]
458
+ const ps = c.projectStatus
459
+ const psCls = ps?.status ? ` ps-${ps.status}` : ''
460
+ const stateBox = `<span class="state-box ${STATE_BADGE[c.state] || 'b-mute'}" title="${esc(c.stateLabel)}">${STATE_LETTER[c.state] || '?'}</span>`
461
+ const officeCls = (STATE_OFFICE[c.state] || 'idle') + psCls
462
+
463
+ let metaBadge = ''
464
+ let kvHtml = ''
465
+ if (ps) {
466
+ metaBadge = '<span class="meta-badge" title="项目自述 status.json">◆META</span>'
467
+ const now = Math.floor(Date.now() / 1000)
468
+ const stale = ps.updated_at != null && (now - ps.updated_at > 3600)
469
+ if (ps.kv && Object.keys(ps.kv).length) {
470
+ const kvTags = Object.entries(ps.kv)
471
+ .map(([k, v]) => `<span class="kv"><span class="kk">${esc(k)}</span><span class="kvv">${esc(v)}</span></span>`)
472
+ .join('')
473
+ const ageTag = ps.updated_at
474
+ ? `<span class="kv-age${stale ? ' stale' : ''}">${esc(fmtLastEvent(now - ps.updated_at))}</span>`
475
+ : ''
476
+ kvHtml = `<div class="kv-row${stale ? ' stale' : ''}">${kvTags}${ageTag}</div>`
477
+ } else if (ps.updated_at) {
478
+ kvHtml = `<div class="kv-row${stale ? ' stale' : ''}"><span class="kv-age${stale ? ' stale' : ''}">${esc(fmtLastEvent(now - ps.updated_at))}</span></div>`
479
+ }
480
+ }
481
+
482
+ const rows: string[] = []
483
+
484
+ const out = c.outsource
485
+ if (out && (out.queued || out.running)) {
486
+ const bits: string[] = []
487
+ if (out.running) bits.push(`<span class="tag out-run">分遣执行·${esc(String(out.running).slice(0, 20))}</span>`)
488
+ if (out.queued) bits.push(`<span class="tag out-q">分遣队列 ${out.queued}</span>`)
489
+ rows.push(`<div class="staff out-row"><span class="icon ico ico-m">E</span><div class="body">${bits.join(' ')}</div></div>`)
490
+ }
491
+
492
+ if (c.deepseek) {
493
+ const tags: string[] = []
494
+ tags.push(`<span class="tag">PID ${c.deepseek.pid}</span>`)
495
+ tags.push(`<span class="tag">载荷 ${fmtBytes(c.deepseek.rss)}</span>`)
496
+ const activeTxt = c.lastOutput != null ? ` · 活跃 ${fmtLastEvent(c.lastOutput)}` : ''
497
+ rows.push(`<div class="staff"><span class="icon">🤖</span><div class="body"><span class="meta">在轨 ${fmtUptime(c.deepseek.uptime)}${activeTxt}</span>${tags.join('')}</div></div>`)
498
+ }
499
+
500
+ const devRows: string[] = []
501
+ for (const p of c.pythons) {
502
+ const logTxt = p.lastLogAgo != null ? ` · 活跃 ${fmtLastEvent(p.lastLogAgo)}` : ''
503
+ devRows.push(`<div class="staff"><span class="icon">⚙️</span><div class="body"><span class="title">${esc(p.script || '设备')}</span><span class="meta">在轨 ${fmtUptime(p.uptime)} · 载荷 ${fmtBytes(p.rss)}${logTxt}</span></div></div>`)
504
+ }
505
+ let devHtml = ''
506
+ if (devRows.length) devHtml = `<div class="devices">${devRows.join('')}</div>`
507
+ else if (c.deepseek) devHtml = '<div class="devices"><div class="empty-dev">无副机</div></div>'
508
+
509
+ parts.push(`<div class="office ${officeCls}">${HUD_CORNERS}<div class="office-hd"><div class="office-name">${stateBox}${esc(c.project)}</div></div>${kvHtml}${rows.join('')}${devHtml}</div>`)
510
+ }
511
+ officesHtml = `<div class="offices">${parts.join('')}</div>`
512
+ }
513
+
514
+ return `<!doctype html><html lang="zh-CN"><head>
515
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
516
+ <title>${esc(TITLE)}</title>
517
+ <link rel="preconnect" href="https://fonts.googleapis.com">
518
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
519
+ <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Chakra+Petch:wght@500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
520
+ <style>${CSS}</style></head><body>
521
+ <div class="topline"><h1><span class="emo">🌍</span>${esc(TITLE)}${VERSION ? `<span class="ver">v${esc(VERSION)}</span>` : ''}</h1>
522
+ <div class="sub">末扫于 <span id="ut" data-ts="${ts}">刚刚</span> · 舰桥·γ 扇区</div></div>
523
+ ${frontdesk}${officesHtml}
524
+ <script>${RELOAD_JS}</script></body></html>`
525
+ }
526
+
527
+ // ── Upload ─────────────────────────────────────────────────────────
528
+ function upload(name: string, content: string): Promise<void> {
529
+ if (!UPLOAD_PORT || !UPLOAD_SECRET) return Promise.resolve()
530
+ const compressed = gzipSync(Buffer.from(content, 'utf-8'))
531
+ const timestamp = String(Date.now())
532
+ const signature = createHmac('sha256', UPLOAD_SECRET).update(content + timestamp).digest('hex')
533
+ const body = JSON.stringify({
534
+ name, compressedContent: compressed.toString('base64'), timestamp, signature,
535
+ })
536
+ return new Promise(resolve => {
537
+ const req = http.request({
538
+ hostname: '127.0.0.1', port: UPLOAD_PORT, path: '/api/upload', method: 'POST',
539
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
540
+ timeout: 15000,
541
+ }, res => { res.resume(); resolve() })
542
+ req.on('error', () => resolve())
543
+ req.on('timeout', () => { req.destroy(); resolve() })
544
+ req.write(body)
545
+ req.end()
546
+ })
547
+ }
548
+
549
+ // ── Public API ─────────────────────────────────────────────────────
550
+ let logFn: (msg: string) => void = console.log
551
+ let latestHtml = ''
552
+
553
+ async function tick() {
554
+ try {
555
+ const t0 = Date.now()
556
+ const data = buildData()
557
+ const t1 = Date.now()
558
+ const html = render(data)
559
+ const t2 = Date.now()
560
+ latestHtml = html
561
+ if (STATUS_MODE === 'upload') {
562
+ await upload(SLUG, html)
563
+ const t3 = Date.now()
564
+ logFn(`[status] pushed (${data.cards.length} cards) build=${t1-t0}ms render=${t2-t1}ms upload=${t3-t2}ms total=${t3-t0}ms`)
565
+ } else {
566
+ const t3 = Date.now()
567
+ logFn(`[status] rendered (${data.cards.length} cards) build=${t1-t0}ms render=${t2-t1}ms total=${t3-t0}ms`)
568
+ }
569
+ } catch (err) {
570
+ logFn(`[status] error: ${err}`)
571
+ }
572
+ }
573
+
574
+ function startHttpServer(log: (msg: string) => void) {
575
+ const server = http.createServer((_req: IncomingMessage, res: ServerResponse) => {
576
+ if (!latestHtml) {
577
+ res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' })
578
+ res.end('Dashboard not ready')
579
+ return
580
+ }
581
+ res.writeHead(200, {
582
+ 'Content-Type': 'text/html; charset=utf-8',
583
+ 'Cache-Control': 'no-cache, no-store',
584
+ })
585
+ res.end(latestHtml)
586
+ })
587
+ server.listen(HTTP_PORT, '0.0.0.0', () => {
588
+ log(`[status] http server listening on 0.0.0.0:${HTTP_PORT}`)
589
+ })
590
+ server.on('error', (err: Error) => {
591
+ log(`[status] http server error: ${err.message}`)
592
+ })
593
+ }
594
+
595
+ export function startStatusDashboard(log: (msg: string) => void) {
596
+ logFn = log
597
+ tick()
598
+ setInterval(tick, 2_000)
599
+ if (STATUS_MODE === 'http') {
600
+ startHttpServer(log)
601
+ log(`[status] dashboard started (2s interval, http mode on port ${HTTP_PORT})`)
602
+ } else {
603
+ log('[status] dashboard started (2s interval, upload mode)')
604
+ }
605
+ }
606
+
607
+ // ── CSS + JS (kept from original, cyberpunk theme) ─────────────────
608
+ const RELOAD_JS = `
609
+ (function(){
610
+ var busy=false;
611
+ function utext(){
612
+ var e=document.getElementById("ut");if(!e)return;
613
+ var ts=parseInt(e.dataset.ts||0)*1000,now=Date.now(),df=Math.floor((now-ts)/1000);
614
+ e.textContent=df<60?df+"秒前":df<3600?Math.floor(df/60)+"分钟前":Math.floor(df/3600)+"小时前";
615
+ }
616
+ function morphNode(o,n){
617
+ if(o.nodeType===3){if(o.textContent!==n.textContent)o.textContent=n.textContent;return}
618
+ if(o.nodeType!==1||n.nodeType!==1)return;
619
+ if(o.tagName!==n.tagName){o.replaceWith(n.cloneNode(true));return}
620
+ var oa=o.attributes,na=n.attributes;
621
+ for(var i=na.length-1;i>=0;i--){var a=na[i];if(o.getAttribute(a.name)!==a.value)o.setAttribute(a.name,a.value)}
622
+ for(var i=oa.length-1;i>=0;i--){if(!n.hasAttribute(oa[i].name))o.removeAttribute(oa[i].name)}
623
+ var oc=o.childNodes,nc=n.childNodes;
624
+ var max=Math.max(oc.length,nc.length);
625
+ for(var i=0;i<max;i++){
626
+ if(i>=oc.length){o.appendChild(nc[i].cloneNode(true))}
627
+ else if(i>=nc.length){while(o.childNodes.length>nc.length)o.removeChild(o.lastChild)}
628
+ else{morphNode(oc[i],nc[i])}
629
+ }
630
+ }
631
+ var tickTimer=null;
632
+ function fetchAndMorph(){
633
+ if(busy)return;busy=true;
634
+ fetch(location.href,{cache:'no-cache'}).then(function(r){
635
+ if(!r.ok)return;
636
+ return r.text();
637
+ }).then(function(html){
638
+ if(!html)return;
639
+ var parser=new DOMParser();
640
+ var doc=parser.parseFromString(html,'text/html');
641
+ var newFrontdesks=doc.querySelectorAll('.frontdesk');
642
+ var oldFrontdesks=document.querySelectorAll('.frontdesk');
643
+ for(var i=0;i<Math.min(newFrontdesks.length,oldFrontdesks.length);i++){
644
+ morphNode(oldFrontdesks[i],newFrontdesks[i]);
645
+ }
646
+ var newOffices=doc.querySelectorAll('.offices');
647
+ var oldOffices=document.querySelectorAll('.offices');
648
+ for(var i=0;i<Math.min(newOffices.length,oldOffices.length);i++){
649
+ morphNode(oldOffices[i],newOffices[i]);
650
+ }
651
+ var ts=parseInt(doc.getElementById('ut')?.dataset.ts||0);
652
+ if(ts){var e=document.getElementById('ut');if(e){e.dataset.ts=String(ts);utext()}}
653
+ if(tickTimer){clearInterval(tickTimer);tickTimer=null}
654
+ tickTimer=setInterval(utext,10000);
655
+ busy=false;
656
+ }).catch(function(){busy=false});
657
+ }
658
+ tickTimer=setInterval(utext,10000);
659
+ setInterval(fetchAndMorph,2500);
660
+ })()`
661
+
662
+ const CSS = `
663
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
664
+ body{background:#090c10;color:#c9d1d9;font-family:"SF Mono",Consolas,monospace;padding:24px 28px;min-height:100vh;-webkit-font-smoothing:antialiased;overflow-x:hidden}
665
+ .topline{margin-bottom:22px;display:flex;align-items:baseline;gap:16px;flex-wrap:wrap}
666
+ .topline h1{font-family:"Orbitron","Chakra Petch",sans-serif;font-size:22px;font-weight:900;color:#f0f6fc;letter-spacing:2px;display:flex;align-items:center;gap:8px;text-shadow:0 0 28px rgba(88,166,255,0.35)}
667
+ .topline .emo{font-size:28px;text-shadow:0 0 16px rgba(88,166,255,0.5);animation:rotate 12s linear infinite;display:inline-block}
668
+ .topline .ver{font-size:12px;color:#8b949e;font-weight:600;letter-spacing:1px;margin-left:4px}
669
+ .topline .sub{font-size:12px;color:#484f58;font-family:"SF Mono",Consolas,monospace}
670
+ .frontdesk{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px;align-items:baseline}
671
+ .pill{display:inline-flex;align-items:center;gap:7px;padding:5px 12px;border-radius:18px;font-size:11px;font-family:"SF Mono",Consolas,monospace;border:1px solid rgba(110,118,129,0.25);background:rgba(22,27,34,0.9);backdrop-filter:blur(8px);transition:all .3s}
672
+ .pill .k{color:#8b949e;font-weight:600;text-transform:uppercase;letter-spacing:.5px;font-size:10px}
673
+ .pill .v{color:#c9d1d9;font-weight:700}
674
+ .pill .v.muted{color:#484f58}
675
+ .pill.ok{border-color:rgba(63,185,80,0.5);box-shadow:0 0 12px rgba(63,185,80,0.15)}
676
+ .pill.err{border-color:rgba(248,81,73,0.5);box-shadow:0 0 12px rgba(248,81,73,0.15)}
677
+ .pill.fuel{min-width:180px}
678
+ .pill.fuel .v{font-size:10px}
679
+ .pill.fuel.empty{opacity:0.5}
680
+ .pill.fuel.ok{border-color:rgba(88,166,255,0.5);box-shadow:0 0 16px rgba(88,166,255,0.2)}
681
+ .pill.fuel.mid{border-color:rgba(210,168,255,0.45);box-shadow:0 0 12px rgba(210,168,255,0.15)}
682
+ .pill.fuel.crit{border-color:rgba(248,81,73,0.5);box-shadow:0 0 12px rgba(248,81,73,0.2);animation:critPulse 2s ease-in-out infinite}
683
+ .pill.fuel.stale{opacity:0.5}
684
+ .fuel-gauge{display:inline-flex;flex-direction:row;gap:2px;align-items:flex-end;height:16px}
685
+ .fuel-gauge .cell{width:6px;border-radius:1px;background:rgba(88,166,255,0.9);box-shadow:0 0 4px rgba(88,166,255,0.5)}
686
+ .fuel-gauge .cell.off{background:rgba(48,54,61,0.6);box-shadow:none}
687
+ .fuel-gauge.mid .cell{background:rgba(210,168,255,0.9);box-shadow:0 0 4px rgba(210,168,255,0.5)}
688
+ .fuel-gauge.crit .cell{background:rgba(248,81,73,0.9);box-shadow:0 0 4px rgba(248,81,73,0.5)}
689
+ .mbar{display:inline-block;width:40px;height:8px;border-radius:4px;background:rgba(48,54,61,0.8);overflow:hidden;vertical-align:middle}
690
+ .mbar .fill{display:block;height:100%;border-radius:4px;background:var(--fill,#58a6ff);transition:width .6s}
691
+ .office{background:rgba(13,17,23,0.85);border:1px solid var(--edge,rgba(48,54,61,0.5));border-radius:12px;padding:12px 16px;margin-bottom:10px;position:relative;overflow:hidden;transition:box-shadow .6s,border-color .6s}
692
+ .office .hc{position:absolute;width:20px;height:20px;border-radius:50%;pointer-events:none;opacity:0}
693
+ .office:hover{box-shadow:0 0 24px var(--glow-strong,rgba(0,0,0,0)),0 8px 24px rgba(0,0,0,0.4)}
694
+ .empty-room{text-align:center;padding:60px 20px;color:#484f58;font-size:14px}
695
+ .state-box{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:5px;font-size:9px;font-weight:900;flex-shrink:0;font-family:"SF Mono",Consolas,monospace}
696
+ .office.thinking{--edge:rgba(88,166,255,0.7);--glow:rgba(88,166,255,0.25);--glow-strong:rgba(88,166,255,0.12)}
697
+ .office.thinking .office-name{text-shadow:0 0 10px rgba(88,166,255,0.45)}
698
+ .office.bg{--edge:rgba(240,136,62,0.7);--glow:rgba(240,136,62,0.3);--glow-strong:rgba(240,136,62,0.18)}
699
+ .office.waiting{--edge:rgba(240,185,11,0.7);--glow:rgba(240,185,11,0.3);--glow-strong:rgba(240,185,11,0.18)}
700
+ .office.running{--edge:rgba(63,185,80,0.6);--glow:rgba(63,185,80,0.22);--glow-strong:rgba(63,185,80,0.14)}
701
+ .office.idle{--edge:rgba(110,118,129,0.3);--glow:rgba(0,0,0,0);--glow-strong:rgba(0,0,0,0)}
702
+ .office-hd{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-bottom:5px;padding-bottom:5px;border-bottom:1px solid rgba(110,118,129,0.18)}
703
+ .office-name{font-size:13px;font-weight:700;color:#f0f6fc;display:flex;align-items:center;gap:5px;font-family:-apple-system,"Microsoft YaHei",sans-serif;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
704
+ .badge{padding:1px 7px;border-radius:9px;font-size:9px;font-weight:700;display:inline-block;vertical-align:middle;white-space:nowrap;letter-spacing:.3px;border:1px solid transparent;flex-shrink:0}
705
+ .b-ok{background:rgba(13,47,102,0.7);color:#79c0ff;border-color:rgba(88,166,255,0.4);box-shadow:0 0 10px rgba(88,166,255,0.2)}
706
+ .b-orange{background:rgba(92,44,10,0.7);color:#ffa657;border-color:rgba(240,136,62,0.4);box-shadow:0 0 10px rgba(240,136,62,0.2)}
707
+ .b-warn{background:rgba(61,46,0,0.7);color:#f0b90b;border-color:rgba(240,185,11,0.4);box-shadow:0 0 10px rgba(240,185,11,0.2)}
708
+ .b-mute{background:rgba(45,51,59,0.7);color:#8b949e;border-color:rgba(110,118,129,0.3)}
709
+ .b-run{background:rgba(12,64,36,0.7);color:#56d364;border-color:rgba(63,185,80,0.4);box-shadow:0 0 10px rgba(63,185,80,0.2)}
710
+ .staff{display:flex;align-items:flex-start;gap:6px;padding:2px 0;font-size:11px;line-height:1.4}
711
+ .staff .icon{font-size:13px;width:18px;height:18px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;animation:bob 4s ease-in-out infinite;line-height:1;margin-top:1px}
712
+ .staff .icon.ico{font-size:8px;border-radius:4px;font-weight:800;font-family:"SF Mono",Consolas,monospace}
713
+ .ico-m{background:linear-gradient(135deg,rgba(240,136,62,0.3),rgba(240,136,62,0.1));color:#ffa657;border:1px solid rgba(240,136,62,0.25)}
714
+ @keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}
715
+ .staff .body{flex:1;min-width:0;display:flex;gap:4px;flex-wrap:wrap;align-items:baseline}
716
+ .staff .title{color:#f0f6fc;font-weight:600;font-size:11px}
717
+ .staff .meta{color:#7d8590;font-size:10px;font-family:"SF Mono",Consolas,monospace}
718
+ .staff .tag{display:inline-block;padding:0 5px;border-radius:7px;background:rgba(13,17,23,0.8);border:1px solid rgba(110,118,129,0.25);color:#c9d1d9;font-size:9px;font-family:"SF Mono",Consolas,monospace;line-height:1.5}
719
+ .empty-dev{color:#484f58;font-size:10px;font-style:italic;padding:2px 6px}
720
+ .out-row{opacity:0.8;border-left:2px solid rgba(240,136,62,0.3);padding-left:6px;margin-left:2px}
721
+ .kv-row{display:flex;gap:6px;flex-wrap:wrap;margin:4px 0;padding:3px 0}
722
+ .kv{display:inline-flex;align-items:center;gap:4px}
723
+ .kk{color:#8b949e;font-size:9px;font-weight:600;text-transform:uppercase}
724
+ .kvv{color:#c9d1d9;font-size:10px}
725
+ .kv-age{color:#484f58;font-size:9px}
726
+ .kv-age.stale{color:rgba(248,81,73,0.5)}
727
+ .meta-badge{font-size:9px;color:#8b949e;margin-left:6px;font-family:"SF Mono",Consolas,monospace}
728
+ .ps-ok{--edge:rgba(63,185,80,0.5)}
729
+ .ps-warn{--edge:rgba(240,185,11,0.5)}
730
+ .ps-err{--edge:rgba(248,81,73,0.5)}
731
+ @keyframes rotate{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
732
+ @keyframes critPulse{0%,100%{box-shadow:0 0 12px rgba(248,81,73,0.2)}50%{box-shadow:0 0 24px rgba(248,81,73,0.4)}}
733
+ `