patchwarden 0.6.1 → 0.6.4
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/PatchWarden-Control-Tray.cmd +11 -0
- package/PatchWarden-Control.cmd +6 -0
- package/PatchWarden-Desktop.cmd +5 -0
- package/PatchWarden.cmd +1 -1
- package/README.en.md +106 -18
- package/README.md +30 -19
- package/Restart-PatchWarden-Control.cmd +6 -0
- package/Stop-PatchWarden.cmd +11 -0
- package/dist/controlCenter.d.ts +14 -0
- package/dist/controlCenter.js +2002 -0
- package/dist/doctor.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/control-center/README.md +33 -0
- package/docs/control-center/control-center-daily-driver.md +211 -0
- package/docs/control-center/control-center-mvp.md +205 -0
- package/docs/control-center/control-center-phase2.md +159 -0
- package/docs/demo.md +3 -0
- package/docs/release-v0.6.4.md +45 -0
- package/examples/openai-tunnel/README.md +5 -5
- package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
- package/package.json +23 -15
- package/scripts/README.md +47 -0
- package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
- package/scripts/checks/control-center-smoke.js +1098 -0
- package/scripts/{control-smoke.js → checks/control-smoke.js} +17 -3
- package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +1 -1
- package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +2 -2
- package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +12 -12
- package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +2 -2
- package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +4 -2
- package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
- package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
- package/scripts/{unit-tests.js → checks/unit-tests.js} +1 -1
- package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +11 -5
- package/scripts/control/control-center-tray.ps1 +281 -0
- package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
- package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
- package/scripts/control/restart-control-center.ps1 +173 -0
- package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
- package/scripts/control/start-control-center.ps1 +263 -0
- package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
- package/scripts/control/stop-patchwarden.ps1 +114 -0
- package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
- package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
- package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
- package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
- package/scripts/{pack-clean.js → release/pack-clean.js} +9 -1
- package/src/controlCenter.ts +2152 -0
- package/src/doctor.ts +4 -4
- package/src/version.ts +1 -1
- package/ui/colors_and_type.css +141 -0
- package/ui/pages/audit.html +743 -0
- package/ui/pages/dashboard.html +1154 -0
- package/ui/pages/direct-sessions.html +652 -0
- package/ui/pages/logs.html +502 -0
- package/ui/pages/task-detail.html +1229 -0
- package/ui/pages/tasks.html +702 -0
- package/ui/pages/workspace.html +947 -0
- package/ui/partials/project-shell.html +362 -0
- package/ui/vendor/lucide.js +12 -0
- package/ui/vendor/tailwindcss-browser.js +947 -0
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>控制台</title>
|
|
7
|
+
<style id="theme-vars">
|
|
8
|
+
/* PatchWarden Brand CSS — Dark Security Theme */
|
|
9
|
+
/* Prefix: pw */
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
/* ── Primary Palette ── */
|
|
13
|
+
--pw-bg: #0a0e14;
|
|
14
|
+
--pw-bg-elevated: #111820;
|
|
15
|
+
--pw-bg-surface: #161d27;
|
|
16
|
+
--pw-bg-hover: #1c2533;
|
|
17
|
+
--pw-bg-active: #222d3d;
|
|
18
|
+
--pw-border: #1e2a3a;
|
|
19
|
+
--pw-border-subtle: #172030;
|
|
20
|
+
--pw-border-focus: #2dd4a8;
|
|
21
|
+
|
|
22
|
+
/* ── Text ── */
|
|
23
|
+
--pw-text-primary: #e8edf4;
|
|
24
|
+
--pw-text-secondary: #8b99ad;
|
|
25
|
+
--pw-text-tertiary: #5a6a80;
|
|
26
|
+
--pw-text-inverse: #0a0e14;
|
|
27
|
+
--pw-text-accent: #2dd4a8;
|
|
28
|
+
|
|
29
|
+
/* ── Brand Accent — Security Green ── */
|
|
30
|
+
--pw-accent: #2dd4a8;
|
|
31
|
+
--pw-accent-hover: #34eabc;
|
|
32
|
+
--pw-accent-muted: rgba(45, 212, 168, 0.12);
|
|
33
|
+
--pw-accent-subtle: rgba(45, 212, 168, 0.06);
|
|
34
|
+
|
|
35
|
+
/* ── State Colors ── */
|
|
36
|
+
--pw-state-success: #22c55e;
|
|
37
|
+
--pw-state-success-bg: rgba(34, 197, 94, 0.1);
|
|
38
|
+
--pw-state-warning: #f59e0b;
|
|
39
|
+
--pw-state-warning-bg: rgba(245, 158, 11, 0.1);
|
|
40
|
+
--pw-state-error: #ef4444;
|
|
41
|
+
--pw-state-error-bg: rgba(239, 68, 68, 0.1);
|
|
42
|
+
--pw-state-info: #3b82f6;
|
|
43
|
+
--pw-state-info-bg: rgba(59, 130, 246, 0.1);
|
|
44
|
+
|
|
45
|
+
/* ── Radius ── */
|
|
46
|
+
--pw-radius-sm: 4px;
|
|
47
|
+
--pw-radius-md: 8px;
|
|
48
|
+
--pw-radius-lg: 12px;
|
|
49
|
+
--pw-radius-full: 9999px;
|
|
50
|
+
|
|
51
|
+
/* ── Shadows — static surfaces use border only; floating layers use subtle shadow ── */
|
|
52
|
+
--pw-shadow-float: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
53
|
+
--pw-shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.5);
|
|
54
|
+
|
|
55
|
+
/* ── Typography ── */
|
|
56
|
+
--pw-font-sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
57
|
+
--pw-font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
|
|
58
|
+
|
|
59
|
+
--pw-text-xs: 0.75rem;
|
|
60
|
+
--pw-text-sm: 0.8125rem;
|
|
61
|
+
--pw-text-base: 0.875rem;
|
|
62
|
+
--pw-text-lg: 1rem;
|
|
63
|
+
--pw-text-xl: 1.25rem;
|
|
64
|
+
--pw-text-2xl: 1.5rem;
|
|
65
|
+
--pw-text-3xl: 2rem;
|
|
66
|
+
|
|
67
|
+
--pw-leading-tight: 1.25;
|
|
68
|
+
--pw-leading-normal: 1.5;
|
|
69
|
+
--pw-leading-relaxed: 1.625;
|
|
70
|
+
|
|
71
|
+
--pw-weight-normal: 400;
|
|
72
|
+
--pw-weight-medium: 500;
|
|
73
|
+
--pw-weight-semibold: 600;
|
|
74
|
+
--pw-weight-bold: 700;
|
|
75
|
+
|
|
76
|
+
/* ── Spacing ── */
|
|
77
|
+
--pw-space-1: 4px;
|
|
78
|
+
--pw-space-2: 8px;
|
|
79
|
+
--pw-space-3: 12px;
|
|
80
|
+
--pw-space-4: 16px;
|
|
81
|
+
--pw-space-5: 20px;
|
|
82
|
+
--pw-space-6: 24px;
|
|
83
|
+
--pw-space-8: 32px;
|
|
84
|
+
--pw-space-10: 40px;
|
|
85
|
+
--pw-space-12: 48px;
|
|
86
|
+
|
|
87
|
+
/* ── Transitions ── */
|
|
88
|
+
--pw-transition-fast: 150ms ease;
|
|
89
|
+
--pw-transition-base: 200ms ease;
|
|
90
|
+
--pw-transition-slow: 300ms ease;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
html.dark {
|
|
94
|
+
--pw-bg: #0a0e14;
|
|
95
|
+
--pw-bg-elevated: #111820;
|
|
96
|
+
--pw-bg-surface: #161d27;
|
|
97
|
+
--pw-bg-hover: #1c2533;
|
|
98
|
+
--pw-bg-active: #222d3d;
|
|
99
|
+
--pw-border: #1e2a3a;
|
|
100
|
+
--pw-border-subtle: #172030;
|
|
101
|
+
--pw-text-primary: #e8edf4;
|
|
102
|
+
--pw-text-secondary: #8b99ad;
|
|
103
|
+
--pw-text-tertiary: #5a6a80;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
</style>
|
|
107
|
+
<script src="/vendor/tailwindcss-browser.js"></script>
|
|
108
|
+
<script src="/vendor/lucide.js"></script>
|
|
109
|
+
<style type="text/tailwindcss">
|
|
110
|
+
@theme inline {
|
|
111
|
+
--color-accent: var(--pw-accent);
|
|
112
|
+
--color-border: var(--pw-border);
|
|
113
|
+
}
|
|
114
|
+
@layer base {
|
|
115
|
+
body { background: var(--pw-background); color: var(--pw-text-primary); }
|
|
116
|
+
td, th { @apply break-words; word-break: break-all; word-break: auto-phrase; }
|
|
117
|
+
th { @apply whitespace-nowrap; }
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
120
|
+
<style>
|
|
121
|
+
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
122
|
+
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
|
123
|
+
[data-icon] {
|
|
124
|
+
display: inline-flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
justify-content: center;
|
|
127
|
+
-webkit-mask-size: contain;
|
|
128
|
+
mask-size: contain;
|
|
129
|
+
-webkit-mask-repeat: no-repeat;
|
|
130
|
+
mask-repeat: no-repeat;
|
|
131
|
+
-webkit-mask-position: center;
|
|
132
|
+
mask-position: center;
|
|
133
|
+
background-color: currentColor;
|
|
134
|
+
}
|
|
135
|
+
</style>
|
|
136
|
+
</head>
|
|
137
|
+
<body class="flex h-screen overflow-hidden">
|
|
138
|
+
|
|
139
|
+
<!-- ========== SIDEBAR ========== -->
|
|
140
|
+
<aside class="flex flex-col w-60 shrink-0 h-screen" style="background-color: var(--pw-bg); border-right: 1px solid var(--pw-border);">
|
|
141
|
+
|
|
142
|
+
<!-- Logo Area -->
|
|
143
|
+
<div class="flex items-center gap-3 px-5 py-5" style="border-bottom: 1px solid var(--pw-border-subtle);">
|
|
144
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--pw-accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
145
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
|
146
|
+
<path d="M9 12l2 2 4-4"/>
|
|
147
|
+
</svg>
|
|
148
|
+
<span class="truncate text-lg font-semibold tracking-tight" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">PatchWarden</span>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<!-- Navigation -->
|
|
152
|
+
<nav class="flex-1 flex flex-col gap-1 px-3 pt-4 overflow-y-auto">
|
|
153
|
+
|
|
154
|
+
<a href="/pages/dashboard.html" data-nav-key="dashboard" data-active="true" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors truncate" style="color: var(--pw-text-secondary); font-family: var(--pw-font-sans);">
|
|
155
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
|
156
|
+
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
|
157
|
+
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
|
158
|
+
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
|
159
|
+
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
|
160
|
+
</svg>
|
|
161
|
+
<span class="truncate" data-zh="控制台" data-en="Dashboard">控制台</span>
|
|
162
|
+
</a>
|
|
163
|
+
|
|
164
|
+
<a href="/pages/tasks.html" data-nav-key="tasks" data-dom-id="nav-tasks" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors truncate" style="color: var(--pw-text-secondary); font-family: var(--pw-font-sans);">
|
|
165
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
|
166
|
+
<path d="M9 11l3 3L22 4"/>
|
|
167
|
+
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
|
168
|
+
</svg>
|
|
169
|
+
<span class="truncate" data-zh="任务面板" data-en="Tasks">任务面板</span>
|
|
170
|
+
</a>
|
|
171
|
+
|
|
172
|
+
<a href="/pages/workspace.html" data-nav-key="workspace" data-dom-id="nav-workspace" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors truncate" style="color: var(--pw-text-secondary); font-family: var(--pw-font-sans);">
|
|
173
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
|
174
|
+
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2z"/>
|
|
175
|
+
</svg>
|
|
176
|
+
<span class="truncate" data-zh="工作区" data-en="Workspace">工作区</span>
|
|
177
|
+
</a>
|
|
178
|
+
|
|
179
|
+
<a href="/pages/audit.html" data-nav-key="audit" data-dom-id="nav-audit" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors truncate" style="color: var(--pw-text-secondary); font-family: var(--pw-font-sans);">
|
|
180
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
|
181
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
182
|
+
<polyline points="14 2 14 8 20 8"/>
|
|
183
|
+
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
184
|
+
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
185
|
+
<polyline points="10 9 9 9 8 9"/>
|
|
186
|
+
</svg>
|
|
187
|
+
<span class="truncate" data-zh="审计日志" data-en="Audit Log">审计日志</span>
|
|
188
|
+
</a>
|
|
189
|
+
|
|
190
|
+
<a href="/pages/direct-sessions.html" data-nav-key="direct-sessions" data-dom-id="nav-direct-sessions" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors truncate" style="color: var(--pw-text-secondary); font-family: var(--pw-font-sans);">
|
|
191
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
|
192
|
+
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/>
|
|
193
|
+
<line x1="4" y1="22" x2="4" y2="15"/>
|
|
194
|
+
</svg>
|
|
195
|
+
<span class="truncate" data-zh="Direct 会话" data-en="Direct Sessions">Direct 会话</span>
|
|
196
|
+
</a>
|
|
197
|
+
|
|
198
|
+
<a href="/pages/logs.html" data-nav-key="logs" data-dom-id="nav-logs" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors truncate" style="color: var(--pw-text-secondary); font-family: var(--pw-font-sans);">
|
|
199
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
|
200
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
201
|
+
<polyline points="14 2 14 8 20 8"/>
|
|
202
|
+
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
203
|
+
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
204
|
+
</svg>
|
|
205
|
+
<span class="truncate" data-zh="日志" data-en="Logs">日志</span>
|
|
206
|
+
</a>
|
|
207
|
+
|
|
208
|
+
</nav>
|
|
209
|
+
|
|
210
|
+
<!-- Bottom Section -->
|
|
211
|
+
<div class="px-5 py-4" style="border-top: 1px solid var(--pw-border-subtle);">
|
|
212
|
+
<div class="flex items-center gap-2 mb-3">
|
|
213
|
+
<span class="text-xs font-medium px-2 py-0.5 rounded-md whitespace-nowrap" style="color: var(--pw-text-tertiary); background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border-subtle); font-family: var(--pw-font-mono);">Control Center</span>
|
|
214
|
+
</div>
|
|
215
|
+
<div class="flex items-center gap-2">
|
|
216
|
+
<span id="sidebar-status-dot" class="shrink-0 w-2 h-2 rounded-full" style="background-color: var(--pw-state-warning);"></span>
|
|
217
|
+
<span id="sidebar-status-text" class="text-xs truncate" style="color: var(--pw-text-tertiary);">连接中…</span>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
</aside>
|
|
222
|
+
|
|
223
|
+
<!-- ========== MAIN AREA ========== -->
|
|
224
|
+
<div class="flex flex-col flex-1 min-w-0 h-screen overflow-hidden">
|
|
225
|
+
|
|
226
|
+
<!-- Top Bar -->
|
|
227
|
+
<header class="flex items-center justify-between shrink-0 px-6 py-3" style="border-bottom: 1px solid var(--pw-border); background-color: var(--pw-bg);">
|
|
228
|
+
<h1 class="text-lg font-semibold truncate min-w-0" data-zh="控制台" data-en="Dashboard" style="color: var(--pw-text-primary); font-family: var(--pw-font-sans); text-wrap: balance; word-break: keep-all; overflow-wrap: break-word;">
|
|
229
|
+
控制台
|
|
230
|
+
</h1>
|
|
231
|
+
<div class="flex items-center gap-3 shrink-0">
|
|
232
|
+
<span id="header-workspace" class="text-xs truncate max-w-[260px]" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-mono);" title=""></span>
|
|
233
|
+
<div class="shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold" style="background-color: var(--pw-accent-muted); color: var(--pw-accent); font-family: var(--pw-font-mono);">PW</div>
|
|
234
|
+
</div>
|
|
235
|
+
</header>
|
|
236
|
+
|
|
237
|
+
<!-- Page Content Area -->
|
|
238
|
+
<main class="flex-1 overflow-y-auto p-6" style="background-color: var(--pw-bg-elevated);">
|
|
239
|
+
|
|
240
|
+
<!-- ===== TOP TOOLBAR ===== -->
|
|
241
|
+
<div class="flex flex-wrap items-center gap-2 mb-4">
|
|
242
|
+
<button data-action="start-all" class="flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap" style="background-color: var(--pw-state-success); color: var(--pw-text-inverse); font-family: var(--pw-font-sans);">
|
|
243
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
|
244
|
+
<span>Start All</span>
|
|
245
|
+
</button>
|
|
246
|
+
<button data-action="stop-all" class="flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap" style="background-color: var(--pw-state-error); color: #fff; font-family: var(--pw-font-sans);">
|
|
247
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>
|
|
248
|
+
<span>Stop All</span>
|
|
249
|
+
</button>
|
|
250
|
+
<button data-action="restart-all" class="flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap" style="background-color: var(--pw-state-warning); color: var(--pw-text-inverse); font-family: var(--pw-font-sans);">
|
|
251
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
|
|
252
|
+
<span>Restart All</span>
|
|
253
|
+
</button>
|
|
254
|
+
<button data-action="refresh" class="flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap" style="background-color: var(--pw-bg-surface); color: var(--pw-text-primary); border: 1px solid var(--pw-border); font-family: var(--pw-font-sans);">
|
|
255
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
|
|
256
|
+
<span>Refresh</span>
|
|
257
|
+
</button>
|
|
258
|
+
<div class="flex-1 min-w-0"></div>
|
|
259
|
+
<button data-action="open-core-tunnel" class="flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap" style="background-color: transparent; color: var(--pw-accent); border: 1px solid var(--pw-accent); font-family: var(--pw-font-sans);">
|
|
260
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
261
|
+
<span>Open Core Tunnel UI</span>
|
|
262
|
+
</button>
|
|
263
|
+
<button data-action="open-direct-tunnel" class="flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap" style="background-color: transparent; color: var(--pw-accent); border: 1px solid var(--pw-accent); font-family: var(--pw-font-sans);">
|
|
264
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
265
|
+
<span>Open Direct Tunnel UI</span>
|
|
266
|
+
</button>
|
|
267
|
+
<button data-action="open-logs-folder" class="flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap" style="background-color: var(--pw-bg-surface); color: var(--pw-text-primary); border: 1px solid var(--pw-border); font-family: var(--pw-font-sans);">
|
|
268
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2z"/></svg>
|
|
269
|
+
<span>Open Logs Folder</span>
|
|
270
|
+
</button>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<!-- ===== ERROR BANNER ===== -->
|
|
274
|
+
<div id="error-banner" class="hidden mb-4 rounded-lg px-4 py-3 text-sm" style="background-color: var(--pw-state-error-bg); border: 1px solid var(--pw-state-error); color: var(--pw-state-error); font-family: var(--pw-font-sans);"></div>
|
|
275
|
+
|
|
276
|
+
<!-- ===== SETUP CHECKLIST ===== -->
|
|
277
|
+
<div id="setup-checklist-card" class="rounded-lg mb-4" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
|
|
278
|
+
<div class="flex items-center justify-between px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
|
|
279
|
+
<h2 class="text-sm font-semibold truncate" style="color: var(--pw-text-primary);">Setup checklist</h2>
|
|
280
|
+
<span id="setup-checklist-summary" class="text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary);">checking</span>
|
|
281
|
+
</div>
|
|
282
|
+
<div id="setup-checklist-body" class="grid grid-cols-1 md:grid-cols-3 gap-0"></div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<!-- ===== TOAST CONTAINER (fixed, top-right) ===== -->
|
|
286
|
+
<div id="toast-container" class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm pointer-events-none"></div>
|
|
287
|
+
|
|
288
|
+
<!-- ===== SERVICE CARDS ROW ===== -->
|
|
289
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
|
290
|
+
<div id="card-core" class="rounded-lg" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
|
|
291
|
+
<div class="px-4 py-6 text-center text-sm" style="color: var(--pw-text-tertiary);">加载中…</div>
|
|
292
|
+
</div>
|
|
293
|
+
<div id="card-direct" class="rounded-lg" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
|
|
294
|
+
<div class="px-4 py-6 text-center text-sm" style="color: var(--pw-text-tertiary);">加载中…</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<!-- ===== HEALTH SUGGESTIONS CARD ===== -->
|
|
299
|
+
<div id="suggestions-card" class="hidden rounded-lg mb-6" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
|
|
300
|
+
<div class="flex items-center justify-between px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
|
|
301
|
+
<h2 class="text-sm font-semibold truncate" data-zh="健康建议" data-en="Health Suggestions" style="color: var(--pw-text-primary);">健康建议</h2>
|
|
302
|
+
<span id="suggestions-count" class="text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary);">—</span>
|
|
303
|
+
</div>
|
|
304
|
+
<div id="suggestions-body" class="px-4 py-2"></div>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<!-- ===== TWO-COLUMN LAYOUT ===== -->
|
|
308
|
+
<div class="grid grid-cols-1 lg:grid-cols-5 gap-4 mb-6">
|
|
309
|
+
|
|
310
|
+
<!-- LEFT COLUMN: 最近任务 (3/5 width) -->
|
|
311
|
+
<div class="lg:col-span-3 rounded-lg" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
|
|
312
|
+
<div class="flex items-center justify-between px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
|
|
313
|
+
<h2 class="text-sm font-semibold truncate" data-zh="最近任务" data-en="Recent Tasks" style="color: var(--pw-text-primary);">最近任务</h2>
|
|
314
|
+
<span id="recent-tasks-count" class="text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary);">—</span>
|
|
315
|
+
</div>
|
|
316
|
+
<div id="recent-tasks-body">
|
|
317
|
+
<div class="px-4 py-6 text-center text-sm" style="color: var(--pw-text-tertiary);">加载中…</div>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<!-- RIGHT COLUMN: 系统状态 (2/5 width) -->
|
|
322
|
+
<div class="lg:col-span-2 rounded-lg" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
|
|
323
|
+
<div class="px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
|
|
324
|
+
<h2 class="text-sm font-semibold truncate" data-zh="系统状态" data-en="System Status" style="color: var(--pw-text-primary);">系统状态</h2>
|
|
325
|
+
</div>
|
|
326
|
+
<div id="system-status-body" class="px-4 py-2">
|
|
327
|
+
<div class="py-3 text-center text-sm" style="color: var(--pw-text-tertiary);">加载中…</div>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
<!-- ===== 活动日志 ===== -->
|
|
334
|
+
<div class="rounded-lg mb-6" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
|
|
335
|
+
<div class="flex items-center justify-between px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
|
|
336
|
+
<h2 class="text-sm font-semibold truncate" data-zh="活动日志" data-en="Activity Log" style="color: var(--pw-text-primary);">活动日志</h2>
|
|
337
|
+
<span class="text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary);">Core + Direct stdout 尾部</span>
|
|
338
|
+
</div>
|
|
339
|
+
<div id="activity-log-body">
|
|
340
|
+
<div class="px-4 py-3 text-xs" style="color: var(--pw-text-tertiary);">加载日志中…</div>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<!-- ===== 活动时间线 (Events) ===== -->
|
|
345
|
+
<div class="rounded-lg" style="background-color: var(--pw-bg-surface); border: 1px solid var(--pw-border);">
|
|
346
|
+
<div class="flex items-center justify-between px-4 py-3" style="border-bottom: 1px solid var(--pw-border);">
|
|
347
|
+
<h2 class="text-sm font-semibold truncate" data-zh="活动时间线" data-en="Activity Timeline" style="color: var(--pw-text-primary);">活动时间线</h2>
|
|
348
|
+
<span id="events-count" class="text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary);">—</span>
|
|
349
|
+
</div>
|
|
350
|
+
<div id="events-body" class="px-4 py-2">
|
|
351
|
+
<div class="py-3 text-center text-sm" style="color: var(--pw-text-tertiary);">加载中…</div>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
</main>
|
|
356
|
+
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<!-- Nav + input styling -->
|
|
360
|
+
<style>
|
|
361
|
+
[data-nav-key] {
|
|
362
|
+
transition: background-color var(--pw-transition-fast), color var(--pw-transition-fast);
|
|
363
|
+
}
|
|
364
|
+
[data-nav-key]:hover {
|
|
365
|
+
background-color: var(--pw-bg-hover);
|
|
366
|
+
color: var(--pw-text-primary);
|
|
367
|
+
}
|
|
368
|
+
[data-nav-key][data-active="true"] {
|
|
369
|
+
background-color: var(--pw-accent-subtle);
|
|
370
|
+
color: var(--pw-accent);
|
|
371
|
+
border-left: 3px solid var(--pw-accent);
|
|
372
|
+
padding-left: 9px;
|
|
373
|
+
}
|
|
374
|
+
input[type="text"]:focus {
|
|
375
|
+
border-color: var(--pw-border-focus);
|
|
376
|
+
background-color: var(--pw-bg-hover);
|
|
377
|
+
}
|
|
378
|
+
input[type="text"]::placeholder {
|
|
379
|
+
color: var(--pw-text-tertiary);
|
|
380
|
+
}
|
|
381
|
+
/* Task row hover */
|
|
382
|
+
.divide-y > div:hover {
|
|
383
|
+
background-color: var(--pw-bg-hover);
|
|
384
|
+
}
|
|
385
|
+
/* Button hover states */
|
|
386
|
+
button:hover {
|
|
387
|
+
opacity: 0.9;
|
|
388
|
+
}
|
|
389
|
+
button:active {
|
|
390
|
+
opacity: 0.8;
|
|
391
|
+
}
|
|
392
|
+
button:disabled {
|
|
393
|
+
opacity: 0.6;
|
|
394
|
+
cursor: not-allowed;
|
|
395
|
+
}
|
|
396
|
+
[data-action] {
|
|
397
|
+
cursor: pointer;
|
|
398
|
+
}
|
|
399
|
+
/* Spinner */
|
|
400
|
+
@keyframes pw-spin {
|
|
401
|
+
from { transform: rotate(0deg); }
|
|
402
|
+
to { transform: rotate(360deg); }
|
|
403
|
+
}
|
|
404
|
+
.pw-spin {
|
|
405
|
+
animation: pw-spin 0.6s linear infinite;
|
|
406
|
+
}
|
|
407
|
+
/* Task row hover */
|
|
408
|
+
[data-task-row]:hover {
|
|
409
|
+
background-color: var(--pw-bg-hover);
|
|
410
|
+
}
|
|
411
|
+
/* Stat card hover */
|
|
412
|
+
[style*="border-image"] {
|
|
413
|
+
transition: background-color var(--pw-transition-fast);
|
|
414
|
+
}
|
|
415
|
+
/* Toast notifications */
|
|
416
|
+
.pw-toast {
|
|
417
|
+
pointer-events: auto;
|
|
418
|
+
display: flex;
|
|
419
|
+
align-items: flex-start;
|
|
420
|
+
gap: 8px;
|
|
421
|
+
padding: 10px 12px;
|
|
422
|
+
border-radius: 8px;
|
|
423
|
+
border: 1px solid var(--pw-border);
|
|
424
|
+
background-color: var(--pw-bg-surface);
|
|
425
|
+
box-shadow: var(--pw-shadow-float);
|
|
426
|
+
font-family: var(--pw-font-sans);
|
|
427
|
+
font-size: 12px;
|
|
428
|
+
color: var(--pw-text-primary);
|
|
429
|
+
animation: pw-toast-in 200ms ease;
|
|
430
|
+
max-width: 360px;
|
|
431
|
+
}
|
|
432
|
+
.pw-toast.pw-toast-success { border-left: 3px solid var(--pw-state-success); }
|
|
433
|
+
.pw-toast.pw-toast-warning { border-left: 3px solid var(--pw-state-warning); }
|
|
434
|
+
.pw-toast.pw-toast-error { border-left: 3px solid var(--pw-state-error); }
|
|
435
|
+
.pw-toast.pw-toast-info { border-left: 3px solid var(--pw-state-info); }
|
|
436
|
+
.pw-toast .pw-toast-title { font-weight: 600; margin-bottom: 2px; }
|
|
437
|
+
.pw-toast .pw-toast-msg { color: var(--pw-text-secondary); word-break: break-word; }
|
|
438
|
+
.pw-toast .pw-toast-close { margin-left: auto; cursor: pointer; color: var(--pw-text-tertiary); font-size: 14px; line-height: 1; padding: 0 2px; }
|
|
439
|
+
.pw-toast .pw-toast-close:hover { color: var(--pw-text-primary); }
|
|
440
|
+
@keyframes pw-toast-in {
|
|
441
|
+
from { opacity: 0; transform: translateX(20px); }
|
|
442
|
+
to { opacity: 1; transform: translateX(0); }
|
|
443
|
+
}
|
|
444
|
+
.pw-toast.pw-toast-leaving { animation: pw-toast-out 200ms ease forwards; }
|
|
445
|
+
@keyframes pw-toast-out {
|
|
446
|
+
to { opacity: 0; transform: translateX(20px); }
|
|
447
|
+
}
|
|
448
|
+
</style>
|
|
449
|
+
|
|
450
|
+
<script>
|
|
451
|
+
(function () {
|
|
452
|
+
'use strict';
|
|
453
|
+
|
|
454
|
+
let controlToken = null;
|
|
455
|
+
let statusTimer = null;
|
|
456
|
+
|
|
457
|
+
// ---------- Utilities ----------
|
|
458
|
+
function $(id) { return document.getElementById(id); }
|
|
459
|
+
|
|
460
|
+
function escapeHtml(s) {
|
|
461
|
+
if (s === null || s === undefined) return '';
|
|
462
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
463
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function shortId(id) {
|
|
468
|
+
if (!id) return '—';
|
|
469
|
+
const s = String(id);
|
|
470
|
+
return s.length > 22 ? s.slice(0, 10) + '…' + s.slice(-8) : s;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function shortSha(sha) {
|
|
474
|
+
if (!sha) return '—';
|
|
475
|
+
const s = String(sha);
|
|
476
|
+
return s.length > 16 ? s.slice(0, 16) : s;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function fmtTime(s) {
|
|
480
|
+
if (!s) return '—';
|
|
481
|
+
const d = new Date(s);
|
|
482
|
+
if (isNaN(d.getTime())) return escapeHtml(s);
|
|
483
|
+
return d.toLocaleString();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function statusKind(s) {
|
|
487
|
+
const v = String(s || '').toLowerCase();
|
|
488
|
+
if (['running', 'active', 'available', 'healthy', 'ready', 'ok', 'online', 'started', 'completed', 'done', 'success', 'live'].indexOf(v) >= 0) return 'success';
|
|
489
|
+
if (['pending', 'stale', 'degraded', 'paused', 'waiting', 'queued'].indexOf(v) >= 0) return 'warning';
|
|
490
|
+
if (['failed', 'error', 'missing', 'stopped', 'unavailable', 'dead', 'crashed'].indexOf(v) >= 0) return 'error';
|
|
491
|
+
// substring fallback for reason text like "connection refused"
|
|
492
|
+
if (v.indexOf('refus') >= 0 || v.indexOf('error') >= 0 || v.indexOf('fail') >= 0) return 'error';
|
|
493
|
+
return 'neutral';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function statusColors(kind) {
|
|
497
|
+
if (kind === 'success') return { color: 'var(--pw-state-success)', bg: 'var(--pw-state-success-bg)' };
|
|
498
|
+
if (kind === 'warning') return { color: 'var(--pw-state-warning)', bg: 'var(--pw-state-warning-bg)' };
|
|
499
|
+
if (kind === 'error') return { color: 'var(--pw-state-error)', bg: 'var(--pw-state-error-bg)' };
|
|
500
|
+
return { color: 'var(--pw-text-secondary)', bg: 'var(--pw-bg-hover)' };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function dotHtml(kind) {
|
|
504
|
+
const c = statusColors(kind);
|
|
505
|
+
return '<span class="shrink-0 w-2 h-2 rounded-full" style="background-color: ' + c.color + ';"></span>';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function badgeHtml(text, kind) {
|
|
509
|
+
const c = statusColors(kind);
|
|
510
|
+
return '<span class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style="color: ' + c.color + '; background-color: ' + c.bg + ';">' + escapeHtml(text) + '</span>';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function showError(msg) {
|
|
514
|
+
const el = $('error-banner');
|
|
515
|
+
if (!el) return;
|
|
516
|
+
el.textContent = msg;
|
|
517
|
+
el.classList.remove('hidden');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function clearError() {
|
|
521
|
+
const el = $('error-banner');
|
|
522
|
+
if (el) el.classList.add('hidden');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function setSidebarStatus(kind, text) {
|
|
526
|
+
const dot = $('sidebar-status-dot');
|
|
527
|
+
const txt = $('sidebar-status-text');
|
|
528
|
+
const c = statusColors(kind);
|
|
529
|
+
if (dot) dot.style.backgroundColor = c.color;
|
|
530
|
+
if (txt) txt.textContent = text;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------- Toast notifications (dedup by key) ----------
|
|
534
|
+
const shownToastKeys = new Set();
|
|
535
|
+
|
|
536
|
+
function showToast(kind, title, msg, key) {
|
|
537
|
+
// kind: success | warning | error | info
|
|
538
|
+
// key: dedup id; if already shown and still active, skip.
|
|
539
|
+
if (key && shownToastKeys.has(key)) return;
|
|
540
|
+
if (key) shownToastKeys.add(key);
|
|
541
|
+
const container = $('toast-container');
|
|
542
|
+
if (!container) return;
|
|
543
|
+
const el = document.createElement('div');
|
|
544
|
+
el.className = 'pw-toast pw-toast-' + (kind || 'info');
|
|
545
|
+
el.innerHTML =
|
|
546
|
+
'<div class="min-w-0 flex-1">'
|
|
547
|
+
+ '<div class="pw-toast-title">' + escapeHtml(title || '') + '</div>'
|
|
548
|
+
+ (msg ? '<div class="pw-toast-msg">' + escapeHtml(msg) + '</div>' : '')
|
|
549
|
+
+ '</div>'
|
|
550
|
+
+ '<span class="pw-toast-close" title="关闭">×</span>';
|
|
551
|
+
const closeBtn = el.querySelector('.pw-toast-close');
|
|
552
|
+
if (closeBtn) closeBtn.addEventListener('click', function () { dismissToast(el, key); });
|
|
553
|
+
container.appendChild(el);
|
|
554
|
+
// Auto-dismiss after 8s; keep the key deduped until dismissed so repeated
|
|
555
|
+
// state doesn't re-fire the same event.
|
|
556
|
+
setTimeout(function () { dismissToast(el, key); }, 8000);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function dismissToast(el, key) {
|
|
560
|
+
if (!el || el.classList.contains('pw-toast-leaving')) return;
|
|
561
|
+
el.classList.add('pw-toast-leaving');
|
|
562
|
+
setTimeout(function () {
|
|
563
|
+
if (el.parentNode) el.parentNode.removeChild(el);
|
|
564
|
+
if (key) shownToastKeys.delete(key);
|
|
565
|
+
}, 220);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------- Health suggestions ----------
|
|
569
|
+
function renderSuggestions(status) {
|
|
570
|
+
const card = $('suggestions-card');
|
|
571
|
+
const body = $('suggestions-body');
|
|
572
|
+
const countEl = $('suggestions-count');
|
|
573
|
+
if (!card || !body) return;
|
|
574
|
+
const suggestions = (status && Array.isArray(status.suggestions)) ? status.suggestions : [];
|
|
575
|
+
if (suggestions.length === 0) {
|
|
576
|
+
card.classList.add('hidden');
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
card.classList.remove('hidden');
|
|
580
|
+
if (countEl) countEl.textContent = String(suggestions.length);
|
|
581
|
+
let html = '';
|
|
582
|
+
suggestions.forEach(function (s) {
|
|
583
|
+
const kind = s.severity === 'error' ? 'error' : (s.severity === 'warning' ? 'warning' : 'info');
|
|
584
|
+
const c = statusColors(kind);
|
|
585
|
+
const actionHtml = s.action
|
|
586
|
+
? '<button class="suggestion-action ml-2 text-xs px-2 py-0.5 rounded whitespace-nowrap" data-suggestion-action="' + escapeHtml(s.action) + '" style="background-color: ' + c.bg + '; color: ' + c.color + '; border: 1px solid ' + c.color + ';">执行</button>'
|
|
587
|
+
: '';
|
|
588
|
+
const linkHtml = s.link
|
|
589
|
+
? '<a href="' + escapeHtml(s.link) + '" class="ml-2 text-xs px-2 py-0.5 rounded whitespace-nowrap" style="background-color: var(--pw-accent-muted); color: var(--pw-accent);">查看</a>'
|
|
590
|
+
: '';
|
|
591
|
+
html +=
|
|
592
|
+
'<div class="flex items-center gap-2 py-1.5" style="border-bottom: 1px solid var(--pw-border-subtle);">'
|
|
593
|
+
+ dotHtml(kind)
|
|
594
|
+
+ '<span class="flex-1 min-w-0 text-xs" style="color: var(--pw-text-primary);">' + escapeHtml(s.message || '') + '</span>'
|
|
595
|
+
+ '<span class="text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-mono);">' + escapeHtml(s.code || '') + '</span>'
|
|
596
|
+
+ actionHtml + linkHtml
|
|
597
|
+
+ '</div>';
|
|
598
|
+
});
|
|
599
|
+
body.innerHTML = html;
|
|
600
|
+
// Wire suggestion action buttons
|
|
601
|
+
body.querySelectorAll('[data-suggestion-action]').forEach(function (btn) {
|
|
602
|
+
btn.addEventListener('click', function () {
|
|
603
|
+
const action = btn.getAttribute('data-suggestion-action');
|
|
604
|
+
if (action) postAction(action, btn).then(function () {
|
|
605
|
+
return Promise.all([refreshStatus(), refreshTasks()]);
|
|
606
|
+
}).catch(function (err) {
|
|
607
|
+
showError('建议操作失败: ' + err.message);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ---------- Setup checklist ----------
|
|
614
|
+
function renderSetupChecklist(status) {
|
|
615
|
+
const body = $('setup-checklist-body');
|
|
616
|
+
const summary = $('setup-checklist-summary');
|
|
617
|
+
if (!body) return;
|
|
618
|
+
const setup = (status && status.setup) || {};
|
|
619
|
+
const tunnel = setup.tunnel_client || {};
|
|
620
|
+
const watcher = setup.watcher || ((status && status.watcher) || {});
|
|
621
|
+
const workspaceRoot = setup.workspace_root || (status && status.workspace_root) || '';
|
|
622
|
+
|
|
623
|
+
const items = [
|
|
624
|
+
{
|
|
625
|
+
label: 'tunnel-client.exe',
|
|
626
|
+
ok: !!tunnel.available,
|
|
627
|
+
value: tunnel.available ? (tunnel.path || 'available') : 'missing',
|
|
628
|
+
hint: tunnel.available ? 'Ready for Web UI and tray start/restart.' : 'Expected at TUNNEL_CLIENT_EXE, PATCHWARDEN_TUNNEL_CLIENT_EXE, PATH, or the saved D:\\ai_agent tunnel-client folder.'
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
label: 'workspaceRoot',
|
|
632
|
+
ok: !!workspaceRoot,
|
|
633
|
+
value: workspaceRoot || 'not loaded',
|
|
634
|
+
hint: 'This is the workspace currently managed by PatchWarden tasks.'
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
label: 'Watcher',
|
|
638
|
+
ok: watcher.status === 'healthy',
|
|
639
|
+
value: watcher.status || 'missing',
|
|
640
|
+
hint: watcher.status === 'healthy' ? 'Task execution watcher is healthy.' : (watcher.reason || 'Start Core/Watcher when you want queued tasks to execute.')
|
|
641
|
+
}
|
|
642
|
+
];
|
|
643
|
+
|
|
644
|
+
const missingCount = items.filter(function (item) { return !item.ok; }).length;
|
|
645
|
+
if (summary) {
|
|
646
|
+
summary.textContent = missingCount === 0 ? 'ready' : (missingCount + ' needs attention');
|
|
647
|
+
summary.style.color = missingCount === 0 ? 'var(--pw-state-success)' : 'var(--pw-state-warning)';
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let html = '';
|
|
651
|
+
items.forEach(function (item) {
|
|
652
|
+
const kind = item.ok ? 'success' : 'warning';
|
|
653
|
+
const c = statusColors(kind);
|
|
654
|
+
html +=
|
|
655
|
+
'<div class="min-w-0 px-4 py-3" style="border-right: 1px solid var(--pw-border-subtle);">'
|
|
656
|
+
+ '<div class="flex items-center gap-2 mb-1">'
|
|
657
|
+
+ dotHtml(kind)
|
|
658
|
+
+ '<span class="text-xs font-semibold whitespace-nowrap" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + escapeHtml(item.label) + '</span>'
|
|
659
|
+
+ '<span class="ml-auto text-xs px-2 py-0.5 rounded-full" style="color: ' + c.color + '; background-color: ' + c.bg + ';">' + (item.ok ? 'ok' : 'check') + '</span>'
|
|
660
|
+
+ '</div>'
|
|
661
|
+
+ '<div class="text-xs truncate" style="color: var(--pw-text-secondary); font-family: var(--pw-font-mono);" title="' + escapeHtml(item.value) + '">' + escapeHtml(item.value) + '</div>'
|
|
662
|
+
+ '<div class="text-xs mt-1" style="color: var(--pw-text-tertiary);">' + escapeHtml(item.hint) + '</div>'
|
|
663
|
+
+ '</div>';
|
|
664
|
+
});
|
|
665
|
+
body.innerHTML = html;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---------- Activity timeline (events) ----------
|
|
669
|
+
function eventKind(type) {
|
|
670
|
+
if (!type) return 'neutral';
|
|
671
|
+
if (type.indexOf('stopped') >= 0 || type.indexOf('failed') >= 0 || type.indexOf('.failed') >= 0) return 'error';
|
|
672
|
+
if (type.indexOf('started') >= 0 || type.indexOf('done') >= 0 || type.indexOf('pass') >= 0) return 'success';
|
|
673
|
+
if (type.indexOf('stale') >= 0 || type.indexOf('warn') >= 0 || type.indexOf('reconcil') >= 0) return 'warning';
|
|
674
|
+
if (type.indexOf('status_changed') >= 0) return 'info';
|
|
675
|
+
return 'neutral';
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function renderEvents(payload) {
|
|
679
|
+
const body = $('events-body');
|
|
680
|
+
const countEl = $('events-count');
|
|
681
|
+
if (!body) return;
|
|
682
|
+
const events = (payload && Array.isArray(payload.events)) ? payload.events : [];
|
|
683
|
+
if (countEl) countEl.textContent = '共 ' + events.length + ' 条';
|
|
684
|
+
if (events.length === 0) {
|
|
685
|
+
body.innerHTML = '<div class="py-3 text-center text-sm" style="color: var(--pw-text-tertiary);">暂无事件</div>';
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Newest first (server returns ascending; reverse for display)
|
|
689
|
+
const ordered = events.slice().reverse();
|
|
690
|
+
let html = '';
|
|
691
|
+
ordered.forEach(function (ev) {
|
|
692
|
+
const kind = eventKind(ev.type);
|
|
693
|
+
const c = statusColors(kind);
|
|
694
|
+
const payloadStr = ev.payload ? JSON.stringify(ev.payload) : '';
|
|
695
|
+
html +=
|
|
696
|
+
'<div class="flex items-start gap-2 py-1.5" style="border-bottom: 1px solid var(--pw-border-subtle);">'
|
|
697
|
+
+ '<span class="shrink-0 mt-0.5 w-2 h-2 rounded-full" style="background-color: ' + c.color + ';"></span>'
|
|
698
|
+
+ '<span class="shrink-0 text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-mono);">' + escapeHtml(fmtTime(ev.timestamp)) + '</span>'
|
|
699
|
+
+ '<span class="shrink-0 text-xs font-medium whitespace-nowrap" style="color: ' + c.color + '; font-family: var(--pw-font-mono);">' + escapeHtml(ev.type || '') + '</span>'
|
|
700
|
+
+ '<span class="flex-1 min-w-0 text-xs truncate" style="color: var(--pw-text-secondary); font-family: var(--pw-font-mono);" title="' + escapeHtml(payloadStr) + '">' + escapeHtml(payloadStr) + '</span>'
|
|
701
|
+
+ '</div>';
|
|
702
|
+
});
|
|
703
|
+
body.innerHTML = html;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function refreshEvents() {
|
|
707
|
+
try {
|
|
708
|
+
const data = await fetchJson('/api/events?limit=30');
|
|
709
|
+
renderEvents(data);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
const body = $('events-body');
|
|
712
|
+
if (body) body.innerHTML = '<div class="py-3 text-center text-sm" style="color: var(--pw-state-error);">事件加载失败: ' + escapeHtml(err.message) + '</div>';
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ---------- Notification detection (toasts on status change) ----------
|
|
717
|
+
// Tracks the previous /api/status snapshot to detect transitions.
|
|
718
|
+
let prevNotifySnapshot = null;
|
|
719
|
+
|
|
720
|
+
function detectAndNotify(status) {
|
|
721
|
+
if (!status) return;
|
|
722
|
+
const curr = {
|
|
723
|
+
core_available: !!(status.core && status.core.available),
|
|
724
|
+
direct_available: !!(status.direct && status.direct.available),
|
|
725
|
+
watcher_status: (status.watcher && status.watcher.status) || '',
|
|
726
|
+
task_statuses: {},
|
|
727
|
+
};
|
|
728
|
+
const tasks = (status.tasks && status.tasks.tasks) || [];
|
|
729
|
+
tasks.forEach(function (t) {
|
|
730
|
+
if (t && t.task_id) curr.task_statuses[t.task_id] = t.status;
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
if (prevNotifySnapshot) {
|
|
734
|
+
// core available -> error
|
|
735
|
+
if (prevNotifySnapshot.core_available && !curr.core_available) {
|
|
736
|
+
showToast('error', 'Core 不可用', 'PatchWarden Core 已停止响应', 'core_unavailable');
|
|
737
|
+
} else if (!prevNotifySnapshot.core_available && curr.core_available) {
|
|
738
|
+
showToast('success', 'Core 已就绪', 'PatchWarden Core 已恢复可用', 'core_available');
|
|
739
|
+
}
|
|
740
|
+
// direct available -> error
|
|
741
|
+
if (prevNotifySnapshot.direct_available && !curr.direct_available) {
|
|
742
|
+
showToast('error', 'Direct 不可用', 'PatchWarden Direct 已停止响应', 'direct_unavailable');
|
|
743
|
+
} else if (!prevNotifySnapshot.direct_available && curr.direct_available) {
|
|
744
|
+
showToast('success', 'Direct 已就绪', 'PatchWarden Direct 已恢复可用', 'direct_available');
|
|
745
|
+
}
|
|
746
|
+
// watcher healthy -> stale
|
|
747
|
+
if (prevNotifySnapshot.watcher_status !== curr.watcher_status) {
|
|
748
|
+
if (curr.watcher_status === 'stale' || curr.watcher_status === 'unreadable') {
|
|
749
|
+
showToast('error', 'Watcher 异常', 'Watcher 状态变为 ' + curr.watcher_status, 'watcher_' + curr.watcher_status);
|
|
750
|
+
} else if (curr.watcher_status === 'healthy' && (prevNotifySnapshot.watcher_status === 'stale' || prevNotifySnapshot.watcher_status === 'unreadable')) {
|
|
751
|
+
showToast('success', 'Watcher 已恢复', 'Watcher 状态变为 healthy', 'watcher_healthy');
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// task running -> done/failed
|
|
755
|
+
for (const id in curr.task_statuses) {
|
|
756
|
+
const prevStatus = prevNotifySnapshot.task_statuses[id];
|
|
757
|
+
const newStatus = curr.task_statuses[id];
|
|
758
|
+
if (prevStatus && prevStatus !== newStatus) {
|
|
759
|
+
if (newStatus === 'done') {
|
|
760
|
+
showToast('success', '任务完成', '任务 ' + id.slice(0, 12) + '… 已完成', 'task_' + id + '_done');
|
|
761
|
+
} else if (newStatus === 'failed' || newStatus === 'failed_verification' || newStatus === 'failed_scope_violation' || newStatus === 'failed_policy_violation' || newStatus === 'timeout') {
|
|
762
|
+
showToast('error', '任务失败', '任务 ' + id.slice(0, 12) + '… 状态: ' + newStatus, 'task_' + id + '_' + newStatus);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
prevNotifySnapshot = curr;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------- Audit toast polling (slow) ----------
|
|
771
|
+
let seenAuditKeys = new Set();
|
|
772
|
+
async function refreshAuditForToasts() {
|
|
773
|
+
try {
|
|
774
|
+
const data = await fetchJson('/api/audit');
|
|
775
|
+
const audits = (data && Array.isArray(data.audits)) ? data.audits : [];
|
|
776
|
+
audits.forEach(function (a) {
|
|
777
|
+
const key = (a.task_id || a.session_id || '') + '|' + (a.checked_at || '');
|
|
778
|
+
if (seenAuditKeys.has(key)) return;
|
|
779
|
+
// Only notify on new audits seen after initial load; seed the set on
|
|
780
|
+
// first poll so existing audits don't all fire toasts.
|
|
781
|
+
if (seenAuditKeys.size < 100 && !auditSeeded) {
|
|
782
|
+
seenAuditKeys.add(key);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
seenAuditKeys.add(key);
|
|
786
|
+
const verdict = String(a.verdict || a.decision || '').toLowerCase();
|
|
787
|
+
const label = a.task_id ? ('任务 ' + String(a.task_id).slice(0, 12) + '…') : ('会话 ' + String(a.session_id || '').slice(0, 12) + '…');
|
|
788
|
+
if (verdict === 'fail' || verdict === 'failed') {
|
|
789
|
+
showToast('error', '审计未通过', label + ' 审计结果: fail', 'audit_' + key);
|
|
790
|
+
} else if (verdict === 'warn' || verdict === 'warning') {
|
|
791
|
+
showToast('warning', '审计警告', label + ' 审计结果: warn', 'audit_' + key);
|
|
792
|
+
} else if (verdict === 'pass' || verdict === 'passed') {
|
|
793
|
+
showToast('success', '审计通过', label + ' 审计结果: pass', 'audit_' + key);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
auditSeeded = true;
|
|
797
|
+
} catch {
|
|
798
|
+
/* audit polling is best-effort */
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
let auditSeeded = false;
|
|
802
|
+
|
|
803
|
+
// ---------- Network ----------
|
|
804
|
+
async function fetchJson(url) {
|
|
805
|
+
const res = await fetch(url);
|
|
806
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ' · ' + url);
|
|
807
|
+
return res.json();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function loadToken() {
|
|
811
|
+
try {
|
|
812
|
+
const data = await fetchJson('/control-token.json');
|
|
813
|
+
controlToken = data.token || null;
|
|
814
|
+
} catch (err) {
|
|
815
|
+
controlToken = null;
|
|
816
|
+
console.error('loadToken failed:', err);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function postAction(url, btnEl) {
|
|
821
|
+
if (!controlToken) await loadToken();
|
|
822
|
+
const orig = btnEl ? btnEl.innerHTML : null;
|
|
823
|
+
if (btnEl) {
|
|
824
|
+
btnEl.disabled = true;
|
|
825
|
+
btnEl.innerHTML =
|
|
826
|
+
'<span class="inline-block w-3 h-3 align-middle border-2 border-current border-t-transparent rounded-full pw-spin"></span>' +
|
|
827
|
+
'<span class="ml-1.5">处理中…</span>';
|
|
828
|
+
}
|
|
829
|
+
try {
|
|
830
|
+
const res = await fetch(url, {
|
|
831
|
+
method: 'POST',
|
|
832
|
+
headers: { 'X-PatchWarden-Control-Token': controlToken || '' }
|
|
833
|
+
});
|
|
834
|
+
let data = null;
|
|
835
|
+
try { data = await res.json(); } catch (_) { /* ignore */ }
|
|
836
|
+
if (!res.ok) {
|
|
837
|
+
const msg = (data && (data.error || data.reason || data.message)) || ('HTTP ' + res.status);
|
|
838
|
+
throw new Error(msg);
|
|
839
|
+
}
|
|
840
|
+
return data;
|
|
841
|
+
} finally {
|
|
842
|
+
if (btnEl) {
|
|
843
|
+
btnEl.disabled = false;
|
|
844
|
+
btnEl.innerHTML = orig;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ---------- Render: Service Card ----------
|
|
850
|
+
function renderServiceCard(prefix, label, status) {
|
|
851
|
+
const cardEl = $('card-' + prefix);
|
|
852
|
+
if (!cardEl) return;
|
|
853
|
+
const svc = (status && status[prefix]) || {};
|
|
854
|
+
const tunnel = (status && status.tunnel && status.tunnel[prefix]) || {};
|
|
855
|
+
const tools = (status && status.tools && status.tools[prefix]) || {};
|
|
856
|
+
|
|
857
|
+
const available = !!svc.available;
|
|
858
|
+
const kind = available ? 'success' : 'error';
|
|
859
|
+
const dotColor = available ? 'var(--pw-state-success)' : 'var(--pw-state-error)';
|
|
860
|
+
|
|
861
|
+
const reasonText = svc.reason ? escapeHtml(svc.reason) : '—';
|
|
862
|
+
|
|
863
|
+
const rows = [
|
|
864
|
+
{ label: 'PID', html: tunnel.pid != null ? escapeHtml(tunnel.pid) : '—' },
|
|
865
|
+
{ label: 'tool_profile', html: tools.tool_profile ? escapeHtml(tools.tool_profile) : '—' },
|
|
866
|
+
{ label: 'tool_count', html: tools.tool_count != null ? escapeHtml(tools.tool_count) : '—' },
|
|
867
|
+
{
|
|
868
|
+
label: 'health',
|
|
869
|
+
html: available
|
|
870
|
+
? '<span style="color: var(--pw-state-success);">available</span>'
|
|
871
|
+
: '<span style="color: var(--pw-state-error);">unavailable</span>',
|
|
872
|
+
text: available ? 'available' : 'unavailable'
|
|
873
|
+
},
|
|
874
|
+
{ label: 'reason', html: reasonText },
|
|
875
|
+
{ label: 'last_error', html: tunnel.last_error ? escapeHtml(tunnel.last_error) : '—' }
|
|
876
|
+
];
|
|
877
|
+
|
|
878
|
+
let rowsHtml = '';
|
|
879
|
+
rows.forEach(function (r, i) {
|
|
880
|
+
const last = i === rows.length - 1;
|
|
881
|
+
const styleAttr = last ? '' : ' style="border-bottom: 1px solid var(--pw-border-subtle);"';
|
|
882
|
+
const text = r.text != null ? escapeHtml(r.text) : r.html;
|
|
883
|
+
rowsHtml +=
|
|
884
|
+
'<div class="flex items-center justify-between gap-3 py-1.5"' + styleAttr + '>'
|
|
885
|
+
+ '<span class="text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-mono);">' + r.label + '</span>'
|
|
886
|
+
+ '<span class="text-xs text-right min-w-0 truncate" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);" title="' + text + '">' + r.html + '</span>'
|
|
887
|
+
+ '</div>';
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
cardEl.innerHTML =
|
|
891
|
+
'<div class="px-4 py-3 flex items-center justify-between gap-2" style="border-bottom: 1px solid var(--pw-border);">'
|
|
892
|
+
+ '<div class="flex items-center gap-2 min-w-0">'
|
|
893
|
+
+ '<span class="shrink-0 w-2.5 h-2.5 rounded-full" style="background-color: ' + dotColor + ';"></span>'
|
|
894
|
+
+ '<h2 class="text-sm font-semibold truncate" style="color: var(--pw-text-primary); font-family: var(--pw-font-sans);">' + escapeHtml(label) + '</h2>'
|
|
895
|
+
+ '</div>'
|
|
896
|
+
+ badgeHtml(available ? 'available' : 'unavailable', kind)
|
|
897
|
+
+ '</div>'
|
|
898
|
+
+ '<div class="px-4 py-2">' + rowsHtml + '</div>';
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ---------- Render: System Status ----------
|
|
902
|
+
function renderSystemStatus(status) {
|
|
903
|
+
const el = $('system-status-body');
|
|
904
|
+
if (!el) return;
|
|
905
|
+
const watcher = (status && status.watcher) || {};
|
|
906
|
+
const tunnel = (status && status.tunnel) || {};
|
|
907
|
+
const tools = (status && status.tools && status.tools.core) || {};
|
|
908
|
+
const agents = (status && status.agents) || [];
|
|
909
|
+
|
|
910
|
+
const watcherKind = statusKind(watcher.status);
|
|
911
|
+
const coreReady = !!(tunnel.core && tunnel.core.ready);
|
|
912
|
+
const directReady = !!(tunnel.direct && tunnel.direct.ready);
|
|
913
|
+
const tunnelReady = coreReady && directReady;
|
|
914
|
+
const tunnelKind = tunnelReady ? 'success' : (coreReady || directReady ? 'warning' : 'error');
|
|
915
|
+
|
|
916
|
+
let agentsHtml = '';
|
|
917
|
+
if (!agents.length) {
|
|
918
|
+
agentsHtml = '<span class="text-xs" style="color: var(--pw-text-tertiary);">无</span>';
|
|
919
|
+
} else {
|
|
920
|
+
agents.forEach(function (a) {
|
|
921
|
+
const k = a.available ? 'success' : 'error';
|
|
922
|
+
agentsHtml +=
|
|
923
|
+
'<span class="inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded" style="background-color: var(--pw-bg-hover); color: var(--pw-text-secondary); font-family: var(--pw-font-mono);">'
|
|
924
|
+
+ dotHtml(k) + escapeHtml(a.name || '?')
|
|
925
|
+
+ '</span>';
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function row(label, valueHtml, kind) {
|
|
930
|
+
return '<div class="flex items-center justify-between gap-3 py-1.5" style="border-bottom: 1px solid var(--pw-border-subtle);">'
|
|
931
|
+
+ '<span class="text-sm whitespace-nowrap" style="color: var(--pw-text-secondary); font-family: var(--pw-font-sans);">' + label + '</span>'
|
|
932
|
+
+ '<span class="flex items-center gap-2 min-w-0">' + (kind !== undefined ? dotHtml(kind) : '') + valueHtml + '</span>'
|
|
933
|
+
+ '</div>';
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const watcherText = escapeHtml(watcher.status || '—') +
|
|
937
|
+
(watcher.last_heartbeat_at ? ' · ' + fmtTime(watcher.last_heartbeat_at) : '');
|
|
938
|
+
|
|
939
|
+
el.innerHTML =
|
|
940
|
+
row('Watcher',
|
|
941
|
+
'<span class="text-xs whitespace-nowrap" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + watcherText + '</span>',
|
|
942
|
+
watcherKind)
|
|
943
|
+
+ row('Tunnel ready',
|
|
944
|
+
'<span class="text-xs whitespace-nowrap" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">core ' + (coreReady ? '✓' : '✗') + ' / direct ' + (directReady ? '✓' : '✗') + '</span>',
|
|
945
|
+
tunnelKind)
|
|
946
|
+
+ row('schema_epoch',
|
|
947
|
+
'<span class="text-xs whitespace-nowrap" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);">' + (tools.schema_epoch != null ? escapeHtml(tools.schema_epoch) : '—') + '</span>',
|
|
948
|
+
undefined)
|
|
949
|
+
+ row('tool_manifest_sha256',
|
|
950
|
+
'<span class="text-xs whitespace-nowrap" style="color: var(--pw-text-primary); font-family: var(--pw-font-mono);" title="' + escapeHtml(tools.tool_manifest_sha256 || '') + '">' + shortSha(tools.tool_manifest_sha256) + '</span>',
|
|
951
|
+
undefined)
|
|
952
|
+
+ '<div class="pt-2">'
|
|
953
|
+
+ '<div class="text-xs mb-1.5" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-sans);">Agents</div>'
|
|
954
|
+
+ '<div class="flex flex-wrap gap-1.5">' + agentsHtml + '</div>'
|
|
955
|
+
+ '</div>';
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ---------- Render: Recent Tasks ----------
|
|
959
|
+
function renderTasks(payload) {
|
|
960
|
+
const bodyEl = $('recent-tasks-body');
|
|
961
|
+
const countEl = $('recent-tasks-count');
|
|
962
|
+
if (!bodyEl) return;
|
|
963
|
+
const tasks = (payload && payload.tasks) || [];
|
|
964
|
+
const total = (payload && typeof payload.total === 'number') ? payload.total : tasks.length;
|
|
965
|
+
const top = tasks.slice(0, 5);
|
|
966
|
+
if (countEl) countEl.textContent = '共 ' + total + ' 条 · 显示 ' + top.length;
|
|
967
|
+
if (!top.length) {
|
|
968
|
+
bodyEl.innerHTML = '<div class="px-4 py-6 text-center text-sm" style="color: var(--pw-text-tertiary);">暂无任务</div>';
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
let html = '';
|
|
972
|
+
top.forEach(function (t) {
|
|
973
|
+
const kind = statusKind(t.status);
|
|
974
|
+
const c = statusColors(kind);
|
|
975
|
+
const href = '/pages/task-detail.html?id=' + encodeURIComponent(t.task_id || '');
|
|
976
|
+
html +=
|
|
977
|
+
'<a href="' + href + '" data-task-row class="flex items-center gap-3 px-4 py-3 transition-colors cursor-pointer" style="border-bottom: 1px solid var(--pw-border-subtle);">'
|
|
978
|
+
+ '<span class="shrink-0 text-xs font-medium px-2 py-0.5 rounded whitespace-nowrap" style="color: var(--pw-text-secondary); background-color: var(--pw-bg-hover); font-family: var(--pw-font-mono);" title="' + escapeHtml(t.task_id || '') + '">' + escapeHtml(shortId(t.task_id)) + '</span>'
|
|
979
|
+
+ '<span class="flex-1 min-w-0 text-sm truncate" style="color: var(--pw-text-primary);" title="' + escapeHtml(t.title || '') + '">' + escapeHtml(t.title || '—') + '</span>'
|
|
980
|
+
+ '<span class="shrink-0 text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary); font-family: var(--pw-font-mono);" title="phase">' + escapeHtml(t.phase || '—') + '</span>'
|
|
981
|
+
+ '<span class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style="color: ' + c.color + '; background-color: ' + c.bg + ';">' + escapeHtml(t.status || '—') + '</span>'
|
|
982
|
+
+ '<span class="shrink-0 text-xs whitespace-nowrap" style="color: var(--pw-text-tertiary);">' + escapeHtml(fmtTime(t.created_at)) + '</span>'
|
|
983
|
+
+ '</a>';
|
|
984
|
+
});
|
|
985
|
+
bodyEl.innerHTML = html;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// ---------- Render: Activity Log ----------
|
|
989
|
+
async function refreshLogs() {
|
|
990
|
+
const el = $('activity-log-body');
|
|
991
|
+
if (!el) return;
|
|
992
|
+
el.innerHTML = '<div class="px-4 py-3 text-xs" style="color: var(--pw-text-tertiary);">加载日志中…</div>';
|
|
993
|
+
|
|
994
|
+
let coreText = '';
|
|
995
|
+
let directText = '';
|
|
996
|
+
|
|
997
|
+
try {
|
|
998
|
+
const core = await fetchJson('/api/logs/core');
|
|
999
|
+
if (core && core.stdout) coreText = String(core.stdout);
|
|
1000
|
+
if (!coreText && core && core.reason) coreText = '[error] ' + core.reason;
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
coreText = '[fetch-error] ' + e.message;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
const direct = await fetchJson('/api/logs/direct');
|
|
1006
|
+
if (direct && direct.stdout) directText = String(direct.stdout);
|
|
1007
|
+
if (!directText && direct && direct.reason) directText = '[error] ' + direct.reason;
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
directText = '[fetch-error] ' + e.message;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function tail(text, n) {
|
|
1013
|
+
if (!text) return '';
|
|
1014
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
1015
|
+
if (lines.length <= n) return text;
|
|
1016
|
+
return '…(' + (lines.length - n) + ' 行已省略)\n' + lines.slice(lines.length - n).join('\n');
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const merged =
|
|
1020
|
+
'=== Core stdout (tail) ===\n' + (tail(coreText, 200) || '(空)') +
|
|
1021
|
+
'\n\n=== Direct stdout (tail) ===\n' + (tail(directText, 200) || '(空)') + '\n';
|
|
1022
|
+
|
|
1023
|
+
el.innerHTML =
|
|
1024
|
+
'<details class="px-4 py-3">'
|
|
1025
|
+
+ '<summary class="cursor-pointer text-xs font-medium" style="color: var(--pw-text-primary); font-family: var(--pw-font-sans);">Show Core / Direct log tails</summary>'
|
|
1026
|
+
+ '<pre class="mt-3 text-xs overflow-auto m-0" style="color: var(--pw-text-secondary); font-family: var(--pw-font-mono); white-space: pre-wrap; word-break: break-all; max-height: 320px;">' + escapeHtml(merged) + '</pre>'
|
|
1027
|
+
+ '</details>';
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// ---------- Refresh orchestrators ----------
|
|
1031
|
+
async function refreshStatus() {
|
|
1032
|
+
try {
|
|
1033
|
+
const data = await fetchJson('/api/status');
|
|
1034
|
+
clearError();
|
|
1035
|
+
renderServiceCard('core', 'PatchWarden Core (chatgpt_core)', data);
|
|
1036
|
+
renderServiceCard('direct', 'PatchWarden Direct (chatgpt_direct)', data);
|
|
1037
|
+
renderSetupChecklist(data);
|
|
1038
|
+
renderSystemStatus(data);
|
|
1039
|
+
renderSuggestions(data);
|
|
1040
|
+
detectAndNotify(data);
|
|
1041
|
+
|
|
1042
|
+
// Sidebar + header cosmetics wired to real state
|
|
1043
|
+
const watcher = (data && data.watcher) || {};
|
|
1044
|
+
const wKind = statusKind(watcher.status);
|
|
1045
|
+
setSidebarStatus(wKind, watcher.status ? ('Watcher: ' + watcher.status) : '已连接');
|
|
1046
|
+
const wsEl = $('header-workspace');
|
|
1047
|
+
if (wsEl && data && data.workspace_root) {
|
|
1048
|
+
wsEl.textContent = data.workspace_root;
|
|
1049
|
+
wsEl.setAttribute('title', data.workspace_root);
|
|
1050
|
+
}
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
showError('加载状态失败: ' + err.message);
|
|
1053
|
+
setSidebarStatus('error', '状态获取失败');
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async function refreshTasks() {
|
|
1058
|
+
try {
|
|
1059
|
+
const data = await fetchJson('/api/tasks');
|
|
1060
|
+
renderTasks(data);
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
const bodyEl = $('recent-tasks-body');
|
|
1063
|
+
if (bodyEl) {
|
|
1064
|
+
bodyEl.innerHTML = '<div class="px-4 py-3 text-sm" style="color: var(--pw-state-error);">任务加载失败: ' + escapeHtml(err.message) + '</div>';
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
async function refreshAll() {
|
|
1070
|
+
await Promise.all([refreshStatus(), refreshTasks(), refreshLogs(), refreshEvents()]);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// ---------- Actions ----------
|
|
1074
|
+
async function handleAction(action, btnEl) {
|
|
1075
|
+
try {
|
|
1076
|
+
if (action === 'refresh') {
|
|
1077
|
+
await Promise.all([refreshStatus(), refreshTasks()]);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (action === 'open-core-tunnel' || action === 'open-direct-tunnel') {
|
|
1082
|
+
const which = action === 'open-core-tunnel' ? 'core' : 'direct';
|
|
1083
|
+
const orig = btnEl ? btnEl.innerHTML : null;
|
|
1084
|
+
if (btnEl) {
|
|
1085
|
+
btnEl.disabled = true;
|
|
1086
|
+
btnEl.innerHTML = '<span class="inline-block w-3 h-3 align-middle border-2 border-current border-t-transparent rounded-full pw-spin"></span><span class="ml-1.5">加载中…</span>';
|
|
1087
|
+
}
|
|
1088
|
+
try {
|
|
1089
|
+
const data = await fetchJson('/api/tunnel-ui-url');
|
|
1090
|
+
const entry = data && data[which];
|
|
1091
|
+
if (!entry || !entry.url) {
|
|
1092
|
+
showError((which === 'core' ? 'Core' : 'Direct') + ' 隧道 UI 不可用: ' + (entry && entry.reason ? entry.reason : '未提供 URL'));
|
|
1093
|
+
} else {
|
|
1094
|
+
window.open(entry.url, '_blank', 'noopener');
|
|
1095
|
+
}
|
|
1096
|
+
} finally {
|
|
1097
|
+
if (btnEl) {
|
|
1098
|
+
btnEl.disabled = false;
|
|
1099
|
+
btnEl.innerHTML = orig;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const map = {
|
|
1106
|
+
'start-all': '/api/start-all',
|
|
1107
|
+
'stop-all': '/api/stop-all',
|
|
1108
|
+
'restart-all': '/api/restart-all',
|
|
1109
|
+
'open-logs-folder': '/api/open-logs-folder'
|
|
1110
|
+
};
|
|
1111
|
+
const url = map[action];
|
|
1112
|
+
if (!url) return;
|
|
1113
|
+
await postAction(url, btnEl);
|
|
1114
|
+
// Refresh state after state-changing POSTs (open-logs-folder has no state change)
|
|
1115
|
+
if (action !== 'open-logs-folder') {
|
|
1116
|
+
await Promise.all([refreshStatus(), refreshTasks()]);
|
|
1117
|
+
}
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
showError('操作失败 (' + action + '): ' + err.message);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ---------- Init ----------
|
|
1124
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
1125
|
+
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
|
1126
|
+
btn.addEventListener('click', function () {
|
|
1127
|
+
handleAction(btn.getAttribute('data-action'), btn);
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
loadToken().then(function () {
|
|
1132
|
+
auditSeeded = false;
|
|
1133
|
+
seenAuditKeys = new Set();
|
|
1134
|
+
return refreshAll().then(function () {
|
|
1135
|
+
// Seed audit keys after first load so existing audits don't all fire toasts.
|
|
1136
|
+
return refreshAuditForToasts();
|
|
1137
|
+
});
|
|
1138
|
+
}).catch(function (err) {
|
|
1139
|
+
showError('初始化失败: ' + err.message);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// Auto-refresh /api/status every 30s (no logs to avoid performance issues)
|
|
1143
|
+
statusTimer = setInterval(function () { refreshStatus(); }, 30000);
|
|
1144
|
+
// Refresh activity timeline every 60s
|
|
1145
|
+
setInterval(function () { refreshEvents(); }, 60000);
|
|
1146
|
+
// Audit toast polling: every 90s, best-effort
|
|
1147
|
+
setInterval(function () { refreshAuditForToasts(); }, 90000);
|
|
1148
|
+
});
|
|
1149
|
+
})();
|
|
1150
|
+
</script>
|
|
1151
|
+
|
|
1152
|
+
<script>lucide.createIcons();</script>
|
|
1153
|
+
</body>
|
|
1154
|
+
</html>
|