@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.
- package/README.md +76 -67
- package/cli.ts +176 -0
- package/config.ts +135 -0
- package/daemon.ts +1080 -144
- package/email-worker.ts +534 -0
- package/env-bootstrap.ts +7 -0
- package/feishu-mcp.ts +482 -0
- package/package.json +36 -37
- package/runtime-api.ts +569 -0
- package/scripts/runtime-thread.sh +91 -0
- package/status-dashboard.ts +733 -0
- package/src/cardkit.ts +0 -215
- package/src/cards.ts +0 -304
- package/src/claude-process.ts +0 -301
- package/src/config.ts +0 -83
- package/src/feishu.ts +0 -365
- package/src/instructions.ts +0 -22
- package/src/log.ts +0 -11
- package/src/paths.ts +0 -41
- package/src/session.ts +0 -447
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
`
|