dsh-project-deck 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +111 -2
- package/backend/__pycache__/panel.cpython-314.pyc +0 -0
- package/backend/deck +1765 -0
- package/backend/deck-node +188 -0
- package/backend/deck-node.js +152 -0
- package/backend/deck-node.ps1 +106 -0
- package/backend/panel.html +855 -0
- package/backend/panel.py +953 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +366 -0
- package/lib/index.js +98 -0
- package/package.json +56 -4
package/backend/panel.py
ADDED
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""项目甲板 管理面板 —— 本地 Web 控制台。
|
|
3
|
+
|
|
4
|
+
python3 ~/.project-deck/bin/panel.py # 默认 127.0.0.1:9199
|
|
5
|
+
DECK_PANEL_HOST=0.0.0.0 DECK_PANEL_PORT=9199 python3 panel.py
|
|
6
|
+
|
|
7
|
+
数据全部来自 ~/.project-deck(registry / config / report-state / 播报历史),
|
|
8
|
+
与 deck CLI、launchd 守护进程共用同一份真相,不另存一份。
|
|
9
|
+
"""
|
|
10
|
+
import glob
|
|
11
|
+
import http.server
|
|
12
|
+
import importlib.util
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import urllib.parse
|
|
21
|
+
|
|
22
|
+
BASE = os.path.expanduser(os.environ.get('DECK_HOME', '~/.project-deck'))
|
|
23
|
+
DECK_BIN = os.environ.get('DECK_BIN') or os.path.join(BASE, 'bin', 'deck')
|
|
24
|
+
if not os.path.exists(DECK_BIN):
|
|
25
|
+
DECK_BIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'deck')
|
|
26
|
+
PAGENT = DECK_BIN
|
|
27
|
+
HTML = os.path.join(BASE, 'panel.html')
|
|
28
|
+
if not os.path.exists(HTML):
|
|
29
|
+
HTML = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'panel.html')
|
|
30
|
+
PORT = int(os.environ.get('DECK_PANEL_PORT', '9199'))
|
|
31
|
+
HOST = os.environ.get('DECK_PANEL_HOST', '127.0.0.1')
|
|
32
|
+
|
|
33
|
+
KINDS = {'code': '代码', 'content': '内容', 'ops': '运维', 'life': '生活', 'research': '研究'}
|
|
34
|
+
STATUSES = {'active': '活跃', 'paused': '暂停', 'blocked': '阻塞', 'archived': '归档'}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_deck():
|
|
38
|
+
from importlib.machinery import SourceFileLoader
|
|
39
|
+
loader = SourceFileLoader('deck', PAGENT)
|
|
40
|
+
spec = importlib.util.spec_from_loader('deck', loader)
|
|
41
|
+
mod = importlib.util.module_from_spec(spec)
|
|
42
|
+
loader.exec_module(mod)
|
|
43
|
+
return mod
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
P = load_deck()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── 数据汇总 ────────────────────────────────────────────────
|
|
50
|
+
def wechat_state():
|
|
51
|
+
try:
|
|
52
|
+
r = subprocess.run([P.WXSEND, '--status'], capture_output=True, text=True, timeout=10)
|
|
53
|
+
ok = r.returncode == 0
|
|
54
|
+
return {'ok': ok, 'raw': r.stdout.strip(), 'code': r.returncode}
|
|
55
|
+
except (OSError, subprocess.SubprocessError):
|
|
56
|
+
return {'ok': False, 'raw': 'wxsend 不可用', 'code': 3}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def project_view(p):
|
|
60
|
+
info = P.collect(p)
|
|
61
|
+
return {
|
|
62
|
+
'id': p['id'], 'name': p.get('name', p['id']),
|
|
63
|
+
'kind': p.get('kind', 'code'), 'kindLabel': KINDS.get(p.get('kind', 'code'), p.get('kind')),
|
|
64
|
+
'status': p.get('status', 'active'),
|
|
65
|
+
'statusLabel': STATUSES.get(p.get('status', 'active'), p.get('status')),
|
|
66
|
+
'priority': p.get('priority', 50), 'root': p.get('root', ''),
|
|
67
|
+
'note': p.get('note', ''), 'next': info['next'] or p.get('note') or None,
|
|
68
|
+
'exists': info['exists'], 'memory': info['memory'],
|
|
69
|
+
'activity': info['activity'], 'activityText': P.rel_time(info['activity']),
|
|
70
|
+
'git': info['git'], 'board': info['board'],
|
|
71
|
+
'autonomy': P.autonomy_of(p),
|
|
72
|
+
'sessions': project_sessions(p.get('root', ''), p.get('sessions') or []),
|
|
73
|
+
'boundSessions': p.get('sessions') or [],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def snapshot():
|
|
78
|
+
cfg = P.load_json(P.CONFIG, {})
|
|
79
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
80
|
+
st = P.load_json(P.STATE, {})
|
|
81
|
+
rep = cfg.get('report', {})
|
|
82
|
+
last = st.get('last_run') or 0
|
|
83
|
+
interval = float(rep.get('intervalHours', 4))
|
|
84
|
+
history = []
|
|
85
|
+
if os.path.exists(P.HISTORY):
|
|
86
|
+
try:
|
|
87
|
+
lines = open(P.HISTORY, encoding='utf-8', errors='replace').read().strip().splitlines()[-8:]
|
|
88
|
+
for line in lines:
|
|
89
|
+
try:
|
|
90
|
+
h = json.loads(line)
|
|
91
|
+
history.append({'at': h.get('at', 0), 'exit': h.get('exit', -1),
|
|
92
|
+
'text': h.get('text', '')})
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
continue
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
history.reverse()
|
|
98
|
+
projects = [project_view(p) for p in reg.get('projects', [])]
|
|
99
|
+
projects.sort(key=lambda p: (-p['priority'], p['id']))
|
|
100
|
+
return {
|
|
101
|
+
'now': time.time(),
|
|
102
|
+
'config': {'intervalHours': interval, 'enabled': rep.get('enabled', True),
|
|
103
|
+
'maxChars': rep.get('maxChars', 1200),
|
|
104
|
+
'format': rep.get('format', 'markdown'),
|
|
105
|
+
'image': bool(rep.get('image', False)),
|
|
106
|
+
'web': cfg.get('web', {}).get('baseUrl', 'http://127.0.0.1:43120'),
|
|
107
|
+
'scan': cfg.get('scan', {})},
|
|
108
|
+
'daemon': {'running': bool(P.daemon_status()), 'pid': P.daemon_status() or 0,
|
|
109
|
+
'lastRun': last, 'nextRun': (last + interval * 3600) if last else 0},
|
|
110
|
+
'wechat': dict(wechat_state(),
|
|
111
|
+
stale=P.channel_stale(),
|
|
112
|
+
fails=P.channel_state().get('fails', 0),
|
|
113
|
+
lastError=P.channel_state().get('lastError', '')),
|
|
114
|
+
'counts': {
|
|
115
|
+
'total': len(projects),
|
|
116
|
+
'active': sum(1 for p in projects if p['status'] == 'active'),
|
|
117
|
+
'blocked': sum(1 for p in projects if p['status'] == 'blocked'),
|
|
118
|
+
'recent': sum(1 for p in projects if p['activity'] and time.time() - p['activity'] < 6 * 3600),
|
|
119
|
+
'noMemory': sum(1 for p in projects if not p['memory']),
|
|
120
|
+
},
|
|
121
|
+
'projects': projects,
|
|
122
|
+
'history': history,
|
|
123
|
+
'nodes': nodes_view(),
|
|
124
|
+
'approvals': [x for x in P.load_approvals().get('items', []) if x.get('status') == 'pending'],
|
|
125
|
+
'decisions': decisions_view(10),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def nodes_view():
|
|
130
|
+
nodes = P.load_nodes().get('nodes', [])
|
|
131
|
+
out = []
|
|
132
|
+
for n in nodes:
|
|
133
|
+
snap = P.load_json(os.path.join(P.NODE_SNAPS, n['id'] + '.json'), None)
|
|
134
|
+
out.append({
|
|
135
|
+
'id': n['id'], 'name': n.get('name', n['id']), 'platform': n.get('platform', ''),
|
|
136
|
+
'address': n.get('address', ''), 'lastSeen': n.get('lastSeen'),
|
|
137
|
+
'runtimes': n.get('runtimes', ''), 'note': n.get('note', ''),
|
|
138
|
+
'scannedAt': min(snap.get('scanned_at'), time.time()) if snap else None,
|
|
139
|
+
'projects': len(snap.get('projects', [])) if snap else 0,
|
|
140
|
+
'online': bool(n.get('local')) or bool(snap and time.time() - snap.get('scanned_at', 0) < 86400),
|
|
141
|
+
})
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def latest_image():
|
|
146
|
+
files = sorted(glob.glob(os.path.join(BASE, 'graphics', 'board-*.png')))
|
|
147
|
+
return files[-1] if files else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def send_image_now(dry=False):
|
|
151
|
+
cfg = P.load_json(P.CONFIG, {})
|
|
152
|
+
png = P.render_image(cfg)
|
|
153
|
+
if dry:
|
|
154
|
+
return {'code': 0, 'message': '仅渲染(未发送)',
|
|
155
|
+
'url': '/api/image/latest?t=' + str(int(time.time()))}
|
|
156
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
157
|
+
n = len([p for p in reg.get('projects', []) if p.get('status') != 'archived'])
|
|
158
|
+
caption = f'项目播报 · {n} 个项目 · ' + time.strftime('%m-%d %H:%M')
|
|
159
|
+
code, msg = P.send_wechat_image(png, caption)
|
|
160
|
+
return {'code': code, 'message': msg,
|
|
161
|
+
'url': '/api/image/latest?t=' + str(int(time.time()))}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
SESSION_ROOT = os.path.join(os.path.expanduser('~'), '.dsh', 'sessions')
|
|
165
|
+
PROJCACHE_ROOT = os.path.join(os.path.expanduser('~'), '.dsh', 'storages',
|
|
166
|
+
'session_projcache', 'sessions')
|
|
167
|
+
SESSION_CACHE = os.path.join(BASE, 'sessions-cache.json')
|
|
168
|
+
INDEX_CACHE_VERSION = 2 # 缓存结构版本;旧版(按日志文件路径做键)遇到即重建
|
|
169
|
+
DISPLAY_CHARS = 120 # 列表 / 提示行展示用的首条消息长度
|
|
170
|
+
PROMPT_CHARS = 600 # 索引里保留的首条消息正文长度(供搜索与归类)
|
|
171
|
+
_INDEX_LOCK = threading.Lock() # 预热线程与 HTTP 请求会并发调 session_index,串行化扫描与落盘
|
|
172
|
+
INDEX_MEMO_TTL = 2.0 # 一次快照会按项目重复调 9 次索引,短 TTL 内存缓存合并成一次扫描
|
|
173
|
+
_INDEX_MEMO = {'at': 0.0, 'data': {}}
|
|
174
|
+
_WORKSPACE_STORE = os.path.join(os.path.expanduser('~'), '.dsh', 'storages', 'workspace.json')
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def project_key(cwd):
|
|
178
|
+
"""与 DSH 的 projectKey() 同算法:分隔符→'-',非安全字符→~XXXX,前后加 --。"""
|
|
179
|
+
out, sep = [], False
|
|
180
|
+
for ch in cwd:
|
|
181
|
+
code = ord(ch)
|
|
182
|
+
if ch in '/\\:':
|
|
183
|
+
if not sep:
|
|
184
|
+
out.append('-')
|
|
185
|
+
sep = True
|
|
186
|
+
elif ch != '~' and (ch.isalnum() or ch in '._-') and code < 128:
|
|
187
|
+
out.append(ch)
|
|
188
|
+
sep = False
|
|
189
|
+
else:
|
|
190
|
+
out.append('~%04X' % code)
|
|
191
|
+
sep = False
|
|
192
|
+
s = ''.join(out).lstrip('-') or 'root'
|
|
193
|
+
return '--' + s[:251] + '--'
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _squash(text):
|
|
197
|
+
"""把多行消息压成单行空白,便于列表展示与关键字搜索。"""
|
|
198
|
+
return re.sub(r'\s+', ' ', text or '').strip()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _session_log(session_dir):
|
|
202
|
+
"""返回 (权威日志路径, 最近活跃 mtime)。
|
|
203
|
+
|
|
204
|
+
DSH 迁移后 session.v3.jsonl.zstd 是权威副本,未迁移的老会话只有
|
|
205
|
+
session.jsonl.zstd;新代码不能只认前者,否则老会话永远进不了索引。
|
|
206
|
+
"""
|
|
207
|
+
v3 = os.path.join(session_dir, 'session.v3.jsonl.zstd')
|
|
208
|
+
legacy = os.path.join(session_dir, 'session.jsonl.zstd')
|
|
209
|
+
logs = [p for p in (v3, legacy) if os.path.exists(p)]
|
|
210
|
+
if not logs:
|
|
211
|
+
return None, 0
|
|
212
|
+
at = 0
|
|
213
|
+
for p in logs:
|
|
214
|
+
try:
|
|
215
|
+
at = max(at, os.path.getmtime(p))
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
return logs[0], at
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _pc_value(rows, key):
|
|
222
|
+
row = rows.get(key)
|
|
223
|
+
return row.get('val') if isinstance(row, dict) else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _read_projcache(sid):
|
|
227
|
+
"""读 DSH 的 projection cache(物化索引):标题、首条消息全文、cwd。
|
|
228
|
+
|
|
229
|
+
DSH 已把每个会话的 title / titleInput / cwd 折叠好落在
|
|
230
|
+
~/.dsh/storages/session_projcache/sessions/<id>.json,读它比逐个解压
|
|
231
|
+
日志便宜得多。文件不存在或暂时读不出(写入中 / 损坏)返回 {}。
|
|
232
|
+
"""
|
|
233
|
+
path = os.path.join(PROJCACHE_ROOT, sid + '.json')
|
|
234
|
+
try:
|
|
235
|
+
with open(path, encoding='utf-8') as f:
|
|
236
|
+
raw = json.load(f)
|
|
237
|
+
except (OSError, ValueError):
|
|
238
|
+
return {}
|
|
239
|
+
rec = raw.get('record') or {}
|
|
240
|
+
ident = rec.get('identity') or {}
|
|
241
|
+
rows = rec.get('rows') or {}
|
|
242
|
+
first = ''
|
|
243
|
+
ti = _pc_value(rows, 'titleInput')
|
|
244
|
+
if isinstance(ti, dict):
|
|
245
|
+
first = (ti.get('first') or {}).get('text') or ''
|
|
246
|
+
title = _pc_value(rows, 'title') or ''
|
|
247
|
+
if not title and not first and not ident.get('cwd'):
|
|
248
|
+
return {}
|
|
249
|
+
return {'cwd': ident.get('cwd') or '', 'createdAt': ident.get('createdAt') or 0,
|
|
250
|
+
'title': title, 'first': first}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _read_session_head(path):
|
|
254
|
+
"""兜底:只解开头几十行,拿会话头与首条用户消息(不解压整个日志)。"""
|
|
255
|
+
try:
|
|
256
|
+
proc = subprocess.Popen(['zstd', '-dc', path], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
257
|
+
except (OSError, subprocess.SubprocessError):
|
|
258
|
+
return None
|
|
259
|
+
raw = b''
|
|
260
|
+
try:
|
|
261
|
+
while raw.count(b'\n') < 25 and len(raw) < 200000:
|
|
262
|
+
chunk = proc.stdout.read(65536)
|
|
263
|
+
if not chunk:
|
|
264
|
+
break
|
|
265
|
+
raw += chunk
|
|
266
|
+
finally:
|
|
267
|
+
try:
|
|
268
|
+
proc.kill()
|
|
269
|
+
except OSError:
|
|
270
|
+
pass
|
|
271
|
+
proc.wait(timeout=5)
|
|
272
|
+
head, first = None, ''
|
|
273
|
+
for line in raw.splitlines()[:25]:
|
|
274
|
+
try:
|
|
275
|
+
j = json.loads(line)
|
|
276
|
+
except Exception:
|
|
277
|
+
continue
|
|
278
|
+
if j.get('type') == 'session' and head is None:
|
|
279
|
+
head = j
|
|
280
|
+
elif j.get('type') == 'user/message' and not first:
|
|
281
|
+
content = (j.get('data') or {}).get('content') or []
|
|
282
|
+
first = ' '.join(c.get('text', '') for c in content if isinstance(c, dict))
|
|
283
|
+
if head is not None and first:
|
|
284
|
+
break
|
|
285
|
+
if head is None:
|
|
286
|
+
return None
|
|
287
|
+
return {'id': head.get('id'), 'cwd': head.get('cwd'), 'createdAt': head.get('createdAt'),
|
|
288
|
+
'first': first}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _public_record(ent):
|
|
292
|
+
"""缓存条目去掉签名后的对外记录。"""
|
|
293
|
+
return {k: ent.get(k) for k in ('id', 'ws', 'at', 'cwd', 'createdAt',
|
|
294
|
+
'title', 'first', 'prompt')}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _lookup(idx, sid):
|
|
298
|
+
"""按会话 id 查索引;兼容历史绑定的 header id(如 im-connect 的 im:… 会话)。"""
|
|
299
|
+
rec = idx.get(sid)
|
|
300
|
+
if rec:
|
|
301
|
+
return rec
|
|
302
|
+
for rec in idx.values():
|
|
303
|
+
if rec.get('id') == sid:
|
|
304
|
+
return rec
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def session_index(refresh=False, fresh=False):
|
|
309
|
+
"""会话索引入口:进程内串行化(预热线程与并发请求共用一份缓存文件)。
|
|
310
|
+
|
|
311
|
+
面板一次刷新会按项目重复调用本函数,用短 TTL 的内存快照合并成一次磁盘扫描;
|
|
312
|
+
`fresh=True` 绕过内存快照(会话选择器用,保证刚建的会话立刻可见)。
|
|
313
|
+
调用方只读,不要修改返回的 dict。
|
|
314
|
+
"""
|
|
315
|
+
with _INDEX_LOCK:
|
|
316
|
+
now = time.time()
|
|
317
|
+
if (not refresh and not fresh and _INDEX_MEMO['at']
|
|
318
|
+
and now - _INDEX_MEMO['at'] < INDEX_MEMO_TTL):
|
|
319
|
+
return _INDEX_MEMO['data']
|
|
320
|
+
data = _index_sessions(refresh)
|
|
321
|
+
_INDEX_MEMO['at'] = now
|
|
322
|
+
_INDEX_MEMO['data'] = data
|
|
323
|
+
return data
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _index_sessions(refresh=False):
|
|
327
|
+
"""扫描全部会话目录,返回 {id: {id, cwd, at, createdAt, title, first, prompt, ws}}。
|
|
328
|
+
|
|
329
|
+
标题与首条消息优先取自 DSH 的 session_projcache(物化索引,不必解压日志);
|
|
330
|
+
只有缓存缺失时才回退到「只解头部」的日志读取。按日志与缓存文件的 mtime/size
|
|
331
|
+
做签名缓存,未变化的会话不重复解析,已消失的会话从索引里清掉。
|
|
332
|
+
"""
|
|
333
|
+
entries = {}
|
|
334
|
+
if not refresh:
|
|
335
|
+
raw = P.load_json(SESSION_CACHE, {})
|
|
336
|
+
if isinstance(raw, dict) and raw.get('v') == INDEX_CACHE_VERSION:
|
|
337
|
+
entries = raw.get('entries') or {}
|
|
338
|
+
out, seen, changed = {}, set(), False
|
|
339
|
+
if not os.path.isdir(SESSION_ROOT):
|
|
340
|
+
return {}
|
|
341
|
+
for ws in os.listdir(SESSION_ROOT):
|
|
342
|
+
wsd = os.path.join(SESSION_ROOT, ws)
|
|
343
|
+
if not os.path.isdir(wsd):
|
|
344
|
+
continue
|
|
345
|
+
for sid in os.listdir(wsd):
|
|
346
|
+
sd = os.path.join(wsd, sid)
|
|
347
|
+
if not os.path.isdir(sd):
|
|
348
|
+
continue
|
|
349
|
+
log, at = _session_log(sd)
|
|
350
|
+
if not log:
|
|
351
|
+
continue
|
|
352
|
+
try:
|
|
353
|
+
lst = os.stat(log)
|
|
354
|
+
except OSError:
|
|
355
|
+
continue
|
|
356
|
+
seen.add(sid)
|
|
357
|
+
try:
|
|
358
|
+
pst = os.stat(os.path.join(PROJCACHE_ROOT, sid + '.json'))
|
|
359
|
+
pc_sig = (pst.st_mtime, pst.st_size)
|
|
360
|
+
except OSError:
|
|
361
|
+
pc_sig = (0, 0)
|
|
362
|
+
sig = [lst.st_mtime, lst.st_size, pc_sig[0], pc_sig[1]]
|
|
363
|
+
ent = entries.get(sid)
|
|
364
|
+
if isinstance(ent, dict) and ent.get('sig') == sig:
|
|
365
|
+
out[sid] = _public_record(ent)
|
|
366
|
+
continue
|
|
367
|
+
pc = _read_projcache(sid)
|
|
368
|
+
head = None
|
|
369
|
+
# 只有 title 不够:mesh 任务会话的标题是插件写死的「Mesh 任务 t-xxx」,
|
|
370
|
+
# titleInput 里没有符合条件的首条消息,正文得回日志头部补。
|
|
371
|
+
if not pc.get('cwd') or not pc.get('first'):
|
|
372
|
+
head = _read_session_head(log)
|
|
373
|
+
if head and head.get('id') and head['id'] != sid:
|
|
374
|
+
# 目录名是编码后的 projectKey(im-connect 会话就是这样),
|
|
375
|
+
# projcache 以真实 session id 落盘,再按 header id 试一次。
|
|
376
|
+
alt = _read_projcache(head['id'])
|
|
377
|
+
if alt.get('title') or alt.get('first'):
|
|
378
|
+
pc = alt
|
|
379
|
+
if not pc and not head:
|
|
380
|
+
continue
|
|
381
|
+
rec = {'id': sid, 'ws': ws, 'at': at,
|
|
382
|
+
'cwd': pc.get('cwd') or (head or {}).get('cwd') or '',
|
|
383
|
+
'createdAt': pc.get('createdAt') or (head or {}).get('createdAt') or 0,
|
|
384
|
+
'title': _squash(pc.get('title')),
|
|
385
|
+
'prompt': _squash(pc.get('first') or (head or {}).get('first'))[:PROMPT_CHARS]}
|
|
386
|
+
rec['first'] = rec['prompt'][:DISPLAY_CHARS]
|
|
387
|
+
out[sid] = dict(rec)
|
|
388
|
+
if pc_sig != (0, 0) and not pc:
|
|
389
|
+
changed = True # 缓存文件在却读不出(写入中 / 损坏):这次不落缓存,下次重试
|
|
390
|
+
continue
|
|
391
|
+
entries[sid] = dict(rec, sig=sig)
|
|
392
|
+
changed = True
|
|
393
|
+
for sid in list(entries):
|
|
394
|
+
if sid not in seen:
|
|
395
|
+
del entries[sid]
|
|
396
|
+
changed = True
|
|
397
|
+
if changed:
|
|
398
|
+
P.save_json(SESSION_CACHE, {'v': INDEX_CACHE_VERSION, 'entries': entries})
|
|
399
|
+
return out
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def project_sessions(root, bound=None, limit=12):
|
|
403
|
+
"""cwd 匹配的会话 + 手工绑定的会话,合并去重后按最近活跃排序。"""
|
|
404
|
+
idx = session_index()
|
|
405
|
+
seen, out = set(), []
|
|
406
|
+
for s in _cwd_sessions(root, limit=200):
|
|
407
|
+
if s['id'] in seen:
|
|
408
|
+
continue
|
|
409
|
+
seen.add(s['id'])
|
|
410
|
+
meta = _lookup(idx, s['id']) or {}
|
|
411
|
+
out.append({'id': s['id'], 'at': s['at'], 'bound': False,
|
|
412
|
+
'title': meta.get('title', ''), 'first': meta.get('first', ''),
|
|
413
|
+
'cwd': meta.get('cwd', '')})
|
|
414
|
+
for sid in (bound or []):
|
|
415
|
+
if sid in seen:
|
|
416
|
+
continue
|
|
417
|
+
m = _lookup(idx, sid)
|
|
418
|
+
if not m:
|
|
419
|
+
continue
|
|
420
|
+
seen.add(sid)
|
|
421
|
+
out.append({'id': m.get('id') or sid, 'at': m['at'], 'bound': True,
|
|
422
|
+
'title': m.get('title', ''), 'first': m.get('first', ''),
|
|
423
|
+
'cwd': m.get('cwd', '')})
|
|
424
|
+
out.sort(key=lambda x: -x['at'])
|
|
425
|
+
return out[:limit]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _cwd_sessions(root, limit=12):
|
|
429
|
+
"""按 projectKey 找到该项目的会话目录,按最近修改时间倒序。"""
|
|
430
|
+
if not root:
|
|
431
|
+
return []
|
|
432
|
+
d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root)))
|
|
433
|
+
if not os.path.isdir(d):
|
|
434
|
+
return []
|
|
435
|
+
out = []
|
|
436
|
+
try:
|
|
437
|
+
names = os.listdir(d)
|
|
438
|
+
except OSError:
|
|
439
|
+
return []
|
|
440
|
+
for name in names:
|
|
441
|
+
sd = os.path.join(d, name)
|
|
442
|
+
if not os.path.isdir(sd):
|
|
443
|
+
continue
|
|
444
|
+
# 目录名就是会话 id(session-<uuid> / mesh-<uuid> / 裸 uuid 都见过),
|
|
445
|
+
# 必须原样传给 uiWorkspace.openSession,不能剥前缀。
|
|
446
|
+
log, latest = _session_log(sd)
|
|
447
|
+
if not log:
|
|
448
|
+
continue
|
|
449
|
+
if latest:
|
|
450
|
+
out.append({'id': name, 'at': latest})
|
|
451
|
+
out.sort(key=lambda x: -x['at'])
|
|
452
|
+
return out[:limit]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def goal_list(root):
|
|
456
|
+
gdir = os.path.join(root, '.dsh-graph', 'goals')
|
|
457
|
+
out = []
|
|
458
|
+
if not os.path.isdir(gdir):
|
|
459
|
+
return out
|
|
460
|
+
for name in sorted(os.listdir(gdir)):
|
|
461
|
+
f = os.path.join(gdir, name, 'goal.md')
|
|
462
|
+
if not os.path.isfile(f):
|
|
463
|
+
continue
|
|
464
|
+
try:
|
|
465
|
+
head = open(f, encoding='utf-8', errors='replace').read(3000)
|
|
466
|
+
except OSError:
|
|
467
|
+
continue
|
|
468
|
+
title = re.search(r'^\s+title:\s*["\']?(.+?)["\']?\s*$', head, re.M)
|
|
469
|
+
status = re.search(r'^\s+status:\s*["\']?([a-z_]+)', head, re.M)
|
|
470
|
+
out.append({'id': name, 'title': (title.group(1) if title else name)[:60],
|
|
471
|
+
'status': status.group(1) if status else 'unknown'})
|
|
472
|
+
return out[:12]
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def git_log(root, n=5):
|
|
476
|
+
try:
|
|
477
|
+
r = subprocess.run(['git', '-C', root, 'log', '-' + str(n), '--format=%h%x1f%ar%x1f%s'],
|
|
478
|
+
capture_output=True, text=True, timeout=6)
|
|
479
|
+
if r.returncode != 0:
|
|
480
|
+
return []
|
|
481
|
+
rows = []
|
|
482
|
+
for line in r.stdout.strip().splitlines():
|
|
483
|
+
parts = line.split('\x1f', 2)
|
|
484
|
+
if len(parts) == 3:
|
|
485
|
+
rows.append({'sha': parts[0], 'when': parts[1], 'subject': parts[2][:90]})
|
|
486
|
+
return rows
|
|
487
|
+
except (OSError, subprocess.SubprocessError):
|
|
488
|
+
return []
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def project_detail(pid):
|
|
492
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
493
|
+
proj = next((p for p in reg['projects'] if p['id'] == pid), None)
|
|
494
|
+
if not proj:
|
|
495
|
+
raise ValueError('项目不存在')
|
|
496
|
+
view = project_view(proj)
|
|
497
|
+
root = proj.get('root', '')
|
|
498
|
+
detail = {'project': view, 'gitLog': git_log(root) if os.path.isdir(root) else [],
|
|
499
|
+
'goals': goal_list(root) if os.path.isdir(root) else [], 'state': None}
|
|
500
|
+
sf = P.state_file(proj)
|
|
501
|
+
if sf and os.path.exists(sf):
|
|
502
|
+
try:
|
|
503
|
+
text = open(sf, encoding='utf-8', errors='replace').read(4000)
|
|
504
|
+
except OSError:
|
|
505
|
+
text = ''
|
|
506
|
+
nxt, st = P.parse_state(sf)
|
|
507
|
+
detail['state'] = {'path': sf, 'next': nxt, 'status': st,
|
|
508
|
+
'size': os.path.getsize(sf), 'excerpt': text[:2000]}
|
|
509
|
+
return detail
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def image_history(limit=8):
|
|
513
|
+
files = sorted(glob.glob(os.path.join(BASE, 'graphics', 'board-*.png')), reverse=True)[:limit]
|
|
514
|
+
return [{'name': os.path.basename(f), 'at': os.path.getmtime(f),
|
|
515
|
+
'url': '/api/image/file?name=' + urllib.parse.quote(os.path.basename(f))} for f in files]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def scan_view():
|
|
519
|
+
st = P.load_json(P.SCAN_STATE, {})
|
|
520
|
+
return {'lastRun': st.get('lastRun'), 'added': st.get('added', []),
|
|
521
|
+
'candidateCount': len(st.get('candidates', []))}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def scan_candidates():
|
|
525
|
+
return P.discover(P.load_json(P.CONFIG, {}))
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def run_scan():
|
|
529
|
+
cfg = P.load_json(P.CONFIG, {})
|
|
530
|
+
res = P.auto_scan(cfg)
|
|
531
|
+
return {'added': res.get('added', []), 'candidates': P.discover(cfg), 'state': snapshot()}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def add_candidates(paths):
|
|
535
|
+
cfg = P.load_json(P.CONFIG, {})
|
|
536
|
+
cands = {c['path']: c for c in P.discover(cfg)}
|
|
537
|
+
added, errors = [], []
|
|
538
|
+
for path in paths:
|
|
539
|
+
c = cands.get(path)
|
|
540
|
+
if not c:
|
|
541
|
+
errors.append(f'{path} 已失效')
|
|
542
|
+
continue
|
|
543
|
+
try:
|
|
544
|
+
add_project({'id': c['id'], 'name': c['name'], 'root': c['path'],
|
|
545
|
+
'kind': c['kind'], 'priority': 40,
|
|
546
|
+
'note': '自动发现:' + ';'.join(c['reasons'][:3])})
|
|
547
|
+
added.append(c['id'])
|
|
548
|
+
except ValueError as e:
|
|
549
|
+
errors.append(str(e))
|
|
550
|
+
return {'added': added, 'errors': errors, 'candidates': P.discover(cfg), 'state': snapshot()}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# ── 写操作 ──────────────────────────────────────────────────
|
|
554
|
+
def set_config(payload):
|
|
555
|
+
cfg = P.load_json(P.CONFIG, {})
|
|
556
|
+
rep = cfg.setdefault('report', {})
|
|
557
|
+
if 'intervalHours' in payload:
|
|
558
|
+
try:
|
|
559
|
+
h = float(payload['intervalHours'])
|
|
560
|
+
except (TypeError, ValueError):
|
|
561
|
+
raise ValueError('间隔必须是数字')
|
|
562
|
+
if not (0 < h <= 720):
|
|
563
|
+
raise ValueError('间隔需在 0 到 720 小时之间')
|
|
564
|
+
rep['intervalHours'] = h
|
|
565
|
+
if 'enabled' in payload:
|
|
566
|
+
rep['enabled'] = bool(payload['enabled'])
|
|
567
|
+
if 'maxChars' in payload:
|
|
568
|
+
rep['maxChars'] = int(payload['maxChars'])
|
|
569
|
+
if 'image' in payload:
|
|
570
|
+
rep['image'] = bool(payload['image'])
|
|
571
|
+
if 'scan' in payload and isinstance(payload['scan'], dict):
|
|
572
|
+
sc = cfg.setdefault('scan', {})
|
|
573
|
+
for k in ('enabled', 'autoAdd'):
|
|
574
|
+
if k in payload['scan']:
|
|
575
|
+
sc[k] = bool(payload['scan'][k])
|
|
576
|
+
for k in ('intervalHours', 'threshold', 'depth'):
|
|
577
|
+
if k in payload['scan']:
|
|
578
|
+
try:
|
|
579
|
+
sc[k] = float(payload['scan'][k]) if k == 'intervalHours' else int(payload['scan'][k])
|
|
580
|
+
except (TypeError, ValueError):
|
|
581
|
+
raise ValueError(k + ' 必须是数字')
|
|
582
|
+
if 'roots' in payload['scan']:
|
|
583
|
+
roots = payload['scan']['roots']
|
|
584
|
+
if isinstance(roots, str):
|
|
585
|
+
roots = [x.strip() for x in roots.splitlines() if x.strip()]
|
|
586
|
+
sc['roots'] = [str(x) for x in roots][:12]
|
|
587
|
+
if 'format' in payload:
|
|
588
|
+
if payload['format'] not in ('markdown', 'plain'):
|
|
589
|
+
raise ValueError('版式只支持 markdown / plain')
|
|
590
|
+
rep['format'] = payload['format']
|
|
591
|
+
P.save_json(P.CONFIG, cfg)
|
|
592
|
+
P.log(f'[panel] config interval={rep.get("intervalHours")} enabled={rep.get("enabled")}')
|
|
593
|
+
return snapshot()
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def bind_session(payload):
|
|
597
|
+
pid = payload.get('project')
|
|
598
|
+
sid = str(payload.get('sessionId') or '').strip()
|
|
599
|
+
if not pid or not sid:
|
|
600
|
+
raise ValueError('project 与 sessionId 必填')
|
|
601
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
602
|
+
proj = next((x for x in reg['projects'] if x['id'] == pid), None)
|
|
603
|
+
if not proj:
|
|
604
|
+
raise ValueError('项目不存在')
|
|
605
|
+
idx = session_index()
|
|
606
|
+
rec = _lookup(idx, sid)
|
|
607
|
+
if not rec:
|
|
608
|
+
raise ValueError('会话不存在(索引里没有这个 id)')
|
|
609
|
+
sid = rec.get('id') or sid # 统一落成索引里的规范 id(目录名)
|
|
610
|
+
cur = list(proj.get('sessions') or [])
|
|
611
|
+
if payload.get('bind', True):
|
|
612
|
+
if sid not in cur:
|
|
613
|
+
cur.append(sid)
|
|
614
|
+
else:
|
|
615
|
+
cur = [x for x in cur if x != sid]
|
|
616
|
+
proj['sessions'] = cur
|
|
617
|
+
P.save_json(P.REGISTRY, reg)
|
|
618
|
+
P.log(f'[panel] bind session {sid} -> {pid} ({len(cur)})')
|
|
619
|
+
return snapshot()
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def set_autonomy(project, mode, max_per_day=None):
|
|
623
|
+
if mode not in ('off', 'assist', 'auto'):
|
|
624
|
+
raise ValueError('mode 只能是 off / assist / auto')
|
|
625
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
626
|
+
proj = next((x for x in reg['projects'] if x['id'] == project), None)
|
|
627
|
+
if not proj:
|
|
628
|
+
raise ValueError('项目不存在')
|
|
629
|
+
a = P.autonomy_of(proj)
|
|
630
|
+
a['mode'] = mode
|
|
631
|
+
if max_per_day is not None:
|
|
632
|
+
a['maxPerDay'] = int(max_per_day)
|
|
633
|
+
proj['autonomy'] = a
|
|
634
|
+
P.save_json(P.REGISTRY, reg)
|
|
635
|
+
P.log(f'[panel] autonomy {project} -> {mode}')
|
|
636
|
+
return snapshot()
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def resolve_approval(approval_id, action, choice=''):
|
|
640
|
+
it = P.resolve_approval(approval_id, action, choice)
|
|
641
|
+
if not it:
|
|
642
|
+
raise ValueError('待批项不存在或已处理')
|
|
643
|
+
return {'item': it, 'state': snapshot()}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def decisions_view(limit=20):
|
|
647
|
+
rows = []
|
|
648
|
+
if os.path.exists(P.DECISIONS):
|
|
649
|
+
for line in open(P.DECISIONS):
|
|
650
|
+
try:
|
|
651
|
+
rows.append(json.loads(line))
|
|
652
|
+
except json.JSONDecodeError:
|
|
653
|
+
continue
|
|
654
|
+
return rows[-limit:][::-1]
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def ignore_candidate(path, days):
|
|
658
|
+
if not path:
|
|
659
|
+
raise ValueError('缺少 path')
|
|
660
|
+
ig = P.load_ignore()
|
|
661
|
+
days = int(days)
|
|
662
|
+
if days <= 0:
|
|
663
|
+
if path not in ig['blocked']:
|
|
664
|
+
ig['blocked'].append(path)
|
|
665
|
+
ig.get('ignore', {}).pop(path, None)
|
|
666
|
+
else:
|
|
667
|
+
ig.setdefault('ignore', {})[path] = time.time() + days * 86400
|
|
668
|
+
P.save_json(P.SCAN_IGNORE, ig)
|
|
669
|
+
P.log(f'[scan] 忽略候选 {path} ({days} 天)')
|
|
670
|
+
return {'candidates': P.discover(P.load_json(P.CONFIG, {})), 'state': snapshot()}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def all_sessions(q='', limit=300):
|
|
674
|
+
idx = session_index(fresh=True) # 选择器要立刻看到刚建的会话,不吃内存快照
|
|
675
|
+
rows = list(idx.values())
|
|
676
|
+
if q:
|
|
677
|
+
ql = q.lower()
|
|
678
|
+
rows = [r for r in rows
|
|
679
|
+
if ql in (r.get('title') or '').lower()
|
|
680
|
+
or ql in (r.get('prompt') or r.get('first') or '').lower()
|
|
681
|
+
or ql in (r.get('cwd') or '').lower()]
|
|
682
|
+
rows.sort(key=lambda r: -r['at'])
|
|
683
|
+
return rows[:limit]
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def add_project(payload):
|
|
687
|
+
pid = str(payload.get('id', '')).strip()
|
|
688
|
+
root = os.path.realpath(os.path.expanduser(str(payload.get('root', '')).strip()))
|
|
689
|
+
if not pid or not root:
|
|
690
|
+
raise ValueError('id 与 root 必填')
|
|
691
|
+
if not os.path.isdir(root):
|
|
692
|
+
raise ValueError(f'目录不存在: {root}')
|
|
693
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
694
|
+
for p in reg['projects']:
|
|
695
|
+
if p['id'] == pid:
|
|
696
|
+
raise ValueError(f'项目 {pid} 已存在')
|
|
697
|
+
if os.path.realpath(p.get('root', '')) == root:
|
|
698
|
+
raise ValueError(f'该目录已绑定项目 {p["id"]}')
|
|
699
|
+
reg['projects'].append({
|
|
700
|
+
'id': pid, 'name': payload.get('name') or pid, 'aliases': [pid],
|
|
701
|
+
'kind': payload.get('kind', 'code'), 'status': payload.get('status', 'active'),
|
|
702
|
+
'priority': int(payload.get('priority', 50)), 'root': root,
|
|
703
|
+
'memory': None, 'note': payload.get('note', ''),
|
|
704
|
+
'created_at': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
|
705
|
+
})
|
|
706
|
+
P.save_json(P.REGISTRY, reg)
|
|
707
|
+
P.log(f'[panel] add project {pid} -> {root}')
|
|
708
|
+
return snapshot()
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def update_project(payload):
|
|
712
|
+
pid = payload.get('id')
|
|
713
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
714
|
+
for p in reg['projects']:
|
|
715
|
+
if p['id'] != pid:
|
|
716
|
+
continue
|
|
717
|
+
for key in ('name', 'note', 'kind', 'status'):
|
|
718
|
+
if key in payload:
|
|
719
|
+
p[key] = payload[key]
|
|
720
|
+
if 'aliases' in payload and isinstance(payload['aliases'], list):
|
|
721
|
+
p['aliases'] = [str(a).strip() for a in payload['aliases'] if str(a).strip()][:20]
|
|
722
|
+
if 'priority' in payload:
|
|
723
|
+
p['priority'] = int(payload['priority'])
|
|
724
|
+
P.save_json(P.REGISTRY, reg)
|
|
725
|
+
P.log(f'[panel] update project {pid}: ' + ', '.join(k for k in payload if k != 'id'))
|
|
726
|
+
return snapshot()
|
|
727
|
+
raise ValueError(f'没有项目 {pid}')
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def remove_project(payload):
|
|
731
|
+
pid = payload.get('id')
|
|
732
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
733
|
+
before = len(reg['projects'])
|
|
734
|
+
reg['projects'] = [p for p in reg['projects'] if p['id'] != pid]
|
|
735
|
+
if len(reg['projects']) == before:
|
|
736
|
+
raise ValueError(f'没有项目 {pid}')
|
|
737
|
+
P.save_json(P.REGISTRY, reg)
|
|
738
|
+
P.log(f'[panel] remove project {pid}')
|
|
739
|
+
return snapshot()
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def send_report():
|
|
743
|
+
code, text, msg = P.do_report_with_image(P.load_json(P.CONFIG, {}), quiet=True)
|
|
744
|
+
st = P.load_json(P.STATE, {})
|
|
745
|
+
st['last_run'] = time.time()
|
|
746
|
+
P.save_json(P.STATE, st)
|
|
747
|
+
return {'code': code, 'message': msg, 'text': text, 'state': snapshot()}
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
# ── HTTP ────────────────────────────────────────────────────
|
|
751
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
752
|
+
server_version = 'deck-panel/1.0'
|
|
753
|
+
|
|
754
|
+
def log_message(self, fmt, *args):
|
|
755
|
+
pass
|
|
756
|
+
|
|
757
|
+
def _json(self, obj, status=200):
|
|
758
|
+
body = json.dumps(obj, ensure_ascii=False).encode()
|
|
759
|
+
self.send_response(status)
|
|
760
|
+
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
|
761
|
+
self.send_header('Content-Length', str(len(body)))
|
|
762
|
+
self.send_header('Access-Control-Allow-Origin', '*')
|
|
763
|
+
self.end_headers()
|
|
764
|
+
self.wfile.write(body)
|
|
765
|
+
|
|
766
|
+
def _html(self, path):
|
|
767
|
+
try:
|
|
768
|
+
body = open(path, 'rb').read()
|
|
769
|
+
except OSError:
|
|
770
|
+
self._json({'ok': False, 'error': 'panel.html 缺失'}, 500)
|
|
771
|
+
return
|
|
772
|
+
self.send_response(200)
|
|
773
|
+
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
|
774
|
+
self.send_header('Content-Length', str(len(body)))
|
|
775
|
+
self.end_headers()
|
|
776
|
+
self.wfile.write(body)
|
|
777
|
+
|
|
778
|
+
def _local(self):
|
|
779
|
+
if self.client_address[0] in ('127.0.0.1', '::1', '::ffff:127.0.0.1'):
|
|
780
|
+
return True
|
|
781
|
+
cfg = P.load_json(P.CONFIG, {})
|
|
782
|
+
return bool((cfg.get('panel') or {}).get('allowLanRead'))
|
|
783
|
+
|
|
784
|
+
def do_GET(self):
|
|
785
|
+
path = urllib.parse.urlparse(self.path).path
|
|
786
|
+
if not self._local() and path not in ('/api/node/scanner.py', '/api/node/scanner.js', '/api/node/scanner.ps1'):
|
|
787
|
+
return self._json({'ok': False, 'error': 'forbidden'}, 403)
|
|
788
|
+
if path in ('/', '/index.html'):
|
|
789
|
+
return self._html(HTML)
|
|
790
|
+
if path == '/api/state':
|
|
791
|
+
try:
|
|
792
|
+
return self._json(snapshot())
|
|
793
|
+
except Exception as e:
|
|
794
|
+
return self._json({'ok': False, 'error': repr(e)}, 500)
|
|
795
|
+
if path == '/api/all-sessions':
|
|
796
|
+
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
797
|
+
return self._json({'ok': True, 'sessions': all_sessions(qs.get('q', [''])[0], int(qs.get('limit', ['300'])[0]))})
|
|
798
|
+
if path == '/api/sessions':
|
|
799
|
+
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
800
|
+
pid = qs.get('id', [''])[0]
|
|
801
|
+
reg = P.load_json(P.REGISTRY, {'projects': []})
|
|
802
|
+
proj = next((x for x in reg['projects'] if x['id'] == pid), None)
|
|
803
|
+
if not proj:
|
|
804
|
+
return self._json({'ok': False, 'error': '项目不存在'}, 404)
|
|
805
|
+
return self._json({'ok': True, 'id': pid, 'sessions': project_sessions(proj.get('root', ''), 20)})
|
|
806
|
+
if path == '/api/decisions':
|
|
807
|
+
return self._json({'ok': True, 'decisions': decisions_view(30)})
|
|
808
|
+
if path == '/api/approvals':
|
|
809
|
+
return self._json({'ok': True, 'items': [x for x in P.load_approvals().get('items', []) if x.get('status') == 'pending']})
|
|
810
|
+
if path == '/api/nodes':
|
|
811
|
+
return self._json({'ok': True, 'nodes': nodes_view()})
|
|
812
|
+
if path == '/api/node/scanner.ps1':
|
|
813
|
+
fp = os.path.join(BASE, 'bin', 'deck-node.ps1')
|
|
814
|
+
if not os.path.isfile(fp):
|
|
815
|
+
return self._json({'ok': False, 'error': 'scanner 不存在'}, 404)
|
|
816
|
+
body = open(fp, 'rb').read()
|
|
817
|
+
self.send_response(200)
|
|
818
|
+
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
|
819
|
+
self.send_header('Content-Length', str(len(body)))
|
|
820
|
+
self.end_headers()
|
|
821
|
+
self.wfile.write(body)
|
|
822
|
+
return
|
|
823
|
+
if path == '/api/node/scanner.js':
|
|
824
|
+
fp = os.path.join(BASE, 'bin', 'deck-node.js')
|
|
825
|
+
if not os.path.isfile(fp):
|
|
826
|
+
return self._json({'ok': False, 'error': 'scanner 不存在'}, 404)
|
|
827
|
+
body = open(fp, 'rb').read()
|
|
828
|
+
self.send_response(200)
|
|
829
|
+
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
|
830
|
+
self.send_header('Content-Length', str(len(body)))
|
|
831
|
+
self.end_headers()
|
|
832
|
+
self.wfile.write(body)
|
|
833
|
+
return
|
|
834
|
+
if path == '/api/node/scanner.py':
|
|
835
|
+
fp = os.path.join(BASE, 'bin', 'deck-node')
|
|
836
|
+
if not os.path.isfile(fp):
|
|
837
|
+
return self._json({'ok': False, 'error': 'scanner 不存在'}, 404)
|
|
838
|
+
body = open(fp, 'rb').read()
|
|
839
|
+
self.send_response(200)
|
|
840
|
+
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
|
841
|
+
self.send_header('Content-Length', str(len(body)))
|
|
842
|
+
self.end_headers()
|
|
843
|
+
self.wfile.write(body)
|
|
844
|
+
return
|
|
845
|
+
if path == '/api/project':
|
|
846
|
+
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
847
|
+
try:
|
|
848
|
+
return self._json({'ok': True, 'detail': project_detail(qs.get('id', [''])[0])})
|
|
849
|
+
except ValueError as e:
|
|
850
|
+
return self._json({'ok': False, 'error': str(e)}, 404)
|
|
851
|
+
if path == '/api/image/history':
|
|
852
|
+
return self._json({'ok': True, 'images': image_history()})
|
|
853
|
+
if path == '/api/image/file':
|
|
854
|
+
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
855
|
+
name = os.path.basename(qs.get('name', [''])[0])
|
|
856
|
+
fp = os.path.join(BASE, 'graphics', name)
|
|
857
|
+
if not name.endswith('.png') or not os.path.isfile(fp):
|
|
858
|
+
return self._json({'ok': False, 'error': '图片不存在'}, 404)
|
|
859
|
+
body = open(fp, 'rb').read()
|
|
860
|
+
self.send_response(200)
|
|
861
|
+
self.send_header('Content-Type', 'image/png')
|
|
862
|
+
self.send_header('Content-Length', str(len(body)))
|
|
863
|
+
self.end_headers()
|
|
864
|
+
self.wfile.write(body)
|
|
865
|
+
return
|
|
866
|
+
if path == '/api/image/latest':
|
|
867
|
+
f = latest_image()
|
|
868
|
+
if not f:
|
|
869
|
+
return self._json({'ok': False, 'error': '还没有图片'}, 404)
|
|
870
|
+
body = open(f, 'rb').read()
|
|
871
|
+
self.send_response(200)
|
|
872
|
+
self.send_header('Content-Type', 'image/png')
|
|
873
|
+
self.send_header('Cache-Control', 'no-store')
|
|
874
|
+
self.send_header('Content-Length', str(len(body)))
|
|
875
|
+
self.end_headers()
|
|
876
|
+
self.wfile.write(body)
|
|
877
|
+
return
|
|
878
|
+
if path == '/api/scan':
|
|
879
|
+
try:
|
|
880
|
+
return self._json({'ok': True, 'candidates': scan_candidates(), **scan_view()})
|
|
881
|
+
except Exception as e:
|
|
882
|
+
return self._json({'ok': False, 'error': repr(e)}, 500)
|
|
883
|
+
return self._json({'ok': False, 'error': 'not found'}, 404)
|
|
884
|
+
|
|
885
|
+
def do_POST(self):
|
|
886
|
+
path = urllib.parse.urlparse(self.path).path
|
|
887
|
+
if not self._local() and path != '/api/node-scan':
|
|
888
|
+
return self._json({'ok': False, 'error': 'forbidden'}, 403)
|
|
889
|
+
try:
|
|
890
|
+
length = int(self.headers.get('Content-Length') or 0)
|
|
891
|
+
payload = json.loads(self.rfile.read(length) or b'{}')
|
|
892
|
+
except (ValueError, json.JSONDecodeError):
|
|
893
|
+
return self._json({'ok': False, 'error': '请求体不是合法 JSON'}, 400)
|
|
894
|
+
|
|
895
|
+
try:
|
|
896
|
+
if path == '/api/autonomy':
|
|
897
|
+
return self._json({'ok': True, 'state': set_autonomy(payload.get('project'), payload.get('mode'), payload.get('maxPerDay'))})
|
|
898
|
+
if path == '/api/approval':
|
|
899
|
+
return self._json({'ok': True, **resolve_approval(payload.get('id'), payload.get('action'), payload.get('choice') or '')})
|
|
900
|
+
if path == '/api/scan/ignore':
|
|
901
|
+
return self._json({'ok': True, **ignore_candidate(payload.get('path'), payload.get('days', 7))})
|
|
902
|
+
if path == '/api/bind':
|
|
903
|
+
return self._json({'ok': True, 'state': bind_session(payload)})
|
|
904
|
+
if path == '/api/node-scan':
|
|
905
|
+
try:
|
|
906
|
+
snap = P.save_node_scan(payload)
|
|
907
|
+
return self._json({'ok': True, 'node': snap['node'],
|
|
908
|
+
'received': len(snap.get('projects', []))})
|
|
909
|
+
except ValueError as e:
|
|
910
|
+
return self._json({'ok': False, 'error': str(e)}, 400)
|
|
911
|
+
if path == '/api/image':
|
|
912
|
+
return self._json({'ok': True, **send_image_now(dry=bool(payload.get('dry')))})
|
|
913
|
+
if path == '/api/report':
|
|
914
|
+
return self._json({'ok': True, **send_report()})
|
|
915
|
+
if path == '/api/config':
|
|
916
|
+
return self._json({'ok': True, 'state': set_config(payload)})
|
|
917
|
+
if path == '/api/scan':
|
|
918
|
+
action = payload.get('action')
|
|
919
|
+
if action == 'run':
|
|
920
|
+
return self._json({'ok': True, **run_scan()})
|
|
921
|
+
if action == 'add':
|
|
922
|
+
return self._json({'ok': True, **add_candidates(payload.get('paths') or [])})
|
|
923
|
+
return self._json({'ok': False, 'error': '未知 action'}, 400)
|
|
924
|
+
if path == '/api/project':
|
|
925
|
+
action = payload.get('action')
|
|
926
|
+
if action == 'add':
|
|
927
|
+
return self._json({'ok': True, 'state': add_project(payload)})
|
|
928
|
+
if action == 'update':
|
|
929
|
+
return self._json({'ok': True, 'state': update_project(payload)})
|
|
930
|
+
if action == 'remove':
|
|
931
|
+
return self._json({'ok': True, 'state': remove_project(payload)})
|
|
932
|
+
return self._json({'ok': False, 'error': '未知 action'}, 400)
|
|
933
|
+
except ValueError as e:
|
|
934
|
+
return self._json({'ok': False, 'error': str(e)}, 400)
|
|
935
|
+
except Exception as e:
|
|
936
|
+
P.log(f'[panel] error {path}: {e!r}')
|
|
937
|
+
return self._json({'ok': False, 'error': repr(e)}, 500)
|
|
938
|
+
return self._json({'ok': False, 'error': 'not found'}, 404)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def main():
|
|
942
|
+
import threading
|
|
943
|
+
threading.Thread(target=lambda: session_index(), daemon=True).start() # 预热会话索引
|
|
944
|
+
httpd = http.server.ThreadingHTTPServer((HOST, PORT), Handler)
|
|
945
|
+
P.log(f'[panel] listening http://{HOST}:{PORT}')
|
|
946
|
+
try:
|
|
947
|
+
httpd.serve_forever()
|
|
948
|
+
except KeyboardInterrupt:
|
|
949
|
+
pass
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
if __name__ == '__main__':
|
|
953
|
+
sys.exit(main())
|