@agentmessier/restwalker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/bin/restwalker.js +81 -0
- package/index.html +1161 -0
- package/install.sh +176 -0
- package/node/app.ts +767 -0
- package/node/db.ts +392 -0
- package/node/mcp.ts +217 -0
- package/node/package-lock.json +4552 -0
- package/node/package.json +32 -0
- package/node/runner.ts +174 -0
- package/node/scheduler.ts +221 -0
- package/node/schema.ts +46 -0
- package/node/session.ts +119 -0
- package/node/tsconfig.json +14 -0
- package/package.json +39 -0
- package/uninstall.sh +36 -0
package/index.html
ADDED
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>restwalker</title>
|
|
7
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js" integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF" crossorigin="anonymous"></script>
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@13/marked.min.js" integrity="sha384-YTBHtsL8yVTHcLakYNyrOfK3K+QQcXiECuaALJ+3j7Mo681Rtzadt8NR6WrZH+eQ" crossorigin="anonymous"></script>
|
|
9
|
+
<style>
|
|
10
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
11
|
+
:root {
|
|
12
|
+
--green: #22c55e; --amber: #f59e0b; --red: #ef4444; --blue: #3b82f6;
|
|
13
|
+
--bg: #0f172a; --card: #1e293b; --border: #334155;
|
|
14
|
+
--text: #e2e8f0; --muted: #64748b; --mono: 'JetBrains Mono', 'Fira Code', monospace;
|
|
15
|
+
}
|
|
16
|
+
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; min-height: 100vh; }
|
|
17
|
+
|
|
18
|
+
/* ── Header ── */
|
|
19
|
+
header {
|
|
20
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
21
|
+
padding: 16px 24px; border-bottom: 1px solid var(--border);
|
|
22
|
+
}
|
|
23
|
+
header h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
|
|
24
|
+
header span.sub { font-size: 13px; color: var(--muted); }
|
|
25
|
+
.header-right { display: flex; align-items: center; gap: 12px; }
|
|
26
|
+
.status-pill {
|
|
27
|
+
display: flex; align-items: center; gap: 8px;
|
|
28
|
+
padding: 6px 14px; border-radius: 999px; font-size: 13px; font-weight: 600;
|
|
29
|
+
background: var(--card); border: 1px solid var(--border);
|
|
30
|
+
}
|
|
31
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
|
32
|
+
.dot.green { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
33
|
+
.dot.amber { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
|
|
34
|
+
.dot.red { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
|
35
|
+
.ts { font-size: 12px; color: var(--muted); }
|
|
36
|
+
|
|
37
|
+
/* ── Gear button ── */
|
|
38
|
+
.gear-btn {
|
|
39
|
+
background: var(--card); border: 1px solid var(--border);
|
|
40
|
+
color: var(--muted); border-radius: 8px;
|
|
41
|
+
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
|
|
42
|
+
cursor: pointer; font-size: 16px; transition: color .15s, border-color .15s;
|
|
43
|
+
}
|
|
44
|
+
.gear-btn:hover { color: var(--text); border-color: var(--text); }
|
|
45
|
+
|
|
46
|
+
/* ── Layout ── */
|
|
47
|
+
main { padding: 20px 24px; max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
|
48
|
+
|
|
49
|
+
/* ── Cards ── */
|
|
50
|
+
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
|
51
|
+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
|
|
52
|
+
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--muted); margin-bottom: 8px; }
|
|
53
|
+
.card .value { font-size: 28px; font-weight: 700; font-family: var(--mono); line-height: 1; }
|
|
54
|
+
.card .sub { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
|
55
|
+
.stale-badge {
|
|
56
|
+
display: inline-block; font-size: 10px; font-weight: 600; padding: 1px 6px;
|
|
57
|
+
border-radius: 4px; background: rgba(245,158,11,0.15); color: var(--amber);
|
|
58
|
+
margin-left: 6px; vertical-align: middle;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ── Bar ── */
|
|
62
|
+
.bar-wrap { margin-top: 10px; position: relative; height: 6px; background: var(--border); border-radius: 3px; }
|
|
63
|
+
.bar-fill { height: 100%; border-radius: 3px; transition: width .4s; }
|
|
64
|
+
.bar-fill.green { background: var(--green); }
|
|
65
|
+
.bar-fill.amber { background: var(--amber); }
|
|
66
|
+
.bar-fill.red { background: var(--red); }
|
|
67
|
+
.bar-marker { position: absolute; top: -4px; width: 2px; height: 14px; background: var(--muted); border-radius: 1px; opacity: 0.6; }
|
|
68
|
+
|
|
69
|
+
/* ── Section ── */
|
|
70
|
+
.section { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 20px; }
|
|
71
|
+
.section h2 { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 16px; }
|
|
72
|
+
.chart-wrap { position: relative; height: 220px; }
|
|
73
|
+
.legend { display: flex; gap: 20px; margin-top: 12px; font-size: 12px; color: var(--muted); }
|
|
74
|
+
|
|
75
|
+
/* ── Predictions ── */
|
|
76
|
+
.predictions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
|
77
|
+
.pred-item { display: flex; align-items: flex-start; gap: 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
|
|
78
|
+
.pred-icon { font-size: 18px; flex-shrink: 0; }
|
|
79
|
+
.pred-title { font-size: 12px; color: var(--muted); margin-bottom: 2px; }
|
|
80
|
+
.pred-value { font-size: 14px; font-weight: 600; }
|
|
81
|
+
.pred-value.green { color: var(--green); }
|
|
82
|
+
.pred-value.amber { color: var(--amber); }
|
|
83
|
+
.pred-value.red { color: var(--red); }
|
|
84
|
+
|
|
85
|
+
/* ── Modal overlay ── */
|
|
86
|
+
.modal-overlay {
|
|
87
|
+
display: none; position: fixed; inset: 0;
|
|
88
|
+
background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
|
|
89
|
+
z-index: 100; align-items: center; justify-content: center;
|
|
90
|
+
}
|
|
91
|
+
.modal-overlay.open { display: flex; }
|
|
92
|
+
.modal {
|
|
93
|
+
background: var(--card); border: 1px solid var(--border); border-radius: 14px;
|
|
94
|
+
width: 480px; max-width: calc(100vw - 32px); max-height: calc(100vh - 64px);
|
|
95
|
+
overflow-y: auto; padding: 24px;
|
|
96
|
+
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
|
97
|
+
}
|
|
98
|
+
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
|
|
99
|
+
.modal-header h2 { font-size: 16px; font-weight: 700; }
|
|
100
|
+
.modal-close { background: none; border: none; color: var(--muted); font-size: 20px; cursor: pointer; line-height: 1; padding: 2px 6px; border-radius: 4px; }
|
|
101
|
+
.modal-close:hover { color: var(--text); background: var(--border); }
|
|
102
|
+
|
|
103
|
+
/* ── Settings form ── */
|
|
104
|
+
.settings-group { margin-bottom: 20px; }
|
|
105
|
+
.settings-group-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--muted); font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
|
106
|
+
.setting-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; gap: 12px; }
|
|
107
|
+
.setting-label { font-size: 13px; color: var(--text); flex: 1; }
|
|
108
|
+
.setting-hint { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
|
109
|
+
.setting-input-wrap { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
|
110
|
+
.setting-input {
|
|
111
|
+
width: 72px; text-align: right; padding: 6px 10px;
|
|
112
|
+
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
|
113
|
+
color: var(--text); font-size: 13px; font-family: var(--mono);
|
|
114
|
+
}
|
|
115
|
+
.setting-input:focus { outline: none; border-color: var(--blue); }
|
|
116
|
+
select.setting-input { text-align: left; cursor: pointer; }
|
|
117
|
+
.setting-unit { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
118
|
+
|
|
119
|
+
/* ── Range sliders ── */
|
|
120
|
+
.slider-wrap { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
|
121
|
+
.slider-val { font-size: 13px; font-family: var(--mono); color: var(--text); min-width: 36px; text-align: right; }
|
|
122
|
+
input[type=range] {
|
|
123
|
+
-webkit-appearance: none; appearance: none;
|
|
124
|
+
width: 160px; height: 4px; border-radius: 2px;
|
|
125
|
+
background: var(--border); outline: none; cursor: pointer;
|
|
126
|
+
}
|
|
127
|
+
input[type=range]::-webkit-slider-thumb {
|
|
128
|
+
-webkit-appearance: none; appearance: none;
|
|
129
|
+
width: 14px; height: 14px; border-radius: 50%;
|
|
130
|
+
background: var(--blue); border: none; cursor: pointer;
|
|
131
|
+
transition: transform .1s;
|
|
132
|
+
}
|
|
133
|
+
input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|
134
|
+
input[type=range]::-moz-range-thumb {
|
|
135
|
+
width: 14px; height: 14px; border-radius: 50%;
|
|
136
|
+
background: var(--blue); border: none; cursor: pointer;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border); }
|
|
140
|
+
.btn { padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; }
|
|
141
|
+
.btn-secondary { background: var(--bg); border: 1px solid var(--border); color: var(--text); }
|
|
142
|
+
.btn-primary { background: var(--blue); color: #fff; }
|
|
143
|
+
.btn:hover { opacity: 0.85; }
|
|
144
|
+
.save-status { font-size: 12px; color: var(--green); align-self: center; margin-right: auto; display: none; }
|
|
145
|
+
|
|
146
|
+
footer { text-align: center; font-size: 12px; color: var(--muted); padding: 16px; }
|
|
147
|
+
|
|
148
|
+
/* ── Markdown body ── */
|
|
149
|
+
.md-body { font-size: 12px; line-height: 1.65; color: var(--text); }
|
|
150
|
+
.md-body h1,.md-body h2,.md-body h3 { font-weight: 600; color: var(--text); margin: 8px 0 3px; }
|
|
151
|
+
.md-body h1 { font-size: 14px; } .md-body h2 { font-size: 13px; } .md-body h3 { font-size: 12px; }
|
|
152
|
+
.md-body p { margin: 4px 0; }
|
|
153
|
+
.md-body ul,.md-body ol { margin: 4px 0; padding-left: 20px; }
|
|
154
|
+
.md-body li { margin: 2px 0; }
|
|
155
|
+
.md-body code { background: rgba(255,255,255,.08); border-radius: 3px; padding: 1px 4px; font-family: var(--mono); font-size: 11px; }
|
|
156
|
+
.md-body pre { background: rgba(0,0,0,.3); border: 1px solid var(--border); border-radius: 5px; padding: 8px 10px; overflow-x: auto; margin: 6px 0; }
|
|
157
|
+
.md-body pre code { background: none; padding: 0; }
|
|
158
|
+
.md-body strong { font-weight: 600; }
|
|
159
|
+
.md-body a { color: var(--blue); }
|
|
160
|
+
.md-body hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
|
|
161
|
+
.md-body blockquote { border-left: 3px solid var(--border); margin: 4px 0; padding-left: 10px; color: var(--muted); }
|
|
162
|
+
|
|
163
|
+
@media (max-width: 700px) {
|
|
164
|
+
.cards { grid-template-columns: repeat(2, 1fr); }
|
|
165
|
+
.predictions { grid-template-columns: 1fr; }
|
|
166
|
+
}
|
|
167
|
+
</style>
|
|
168
|
+
</head>
|
|
169
|
+
<body>
|
|
170
|
+
|
|
171
|
+
<header>
|
|
172
|
+
<div>
|
|
173
|
+
<h1>restwalker</h1>
|
|
174
|
+
<span class="sub">Idle-time Claude task runner</span>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="header-right">
|
|
177
|
+
<span class="ts" id="refresh-ts"></span>
|
|
178
|
+
<div class="status-pill">
|
|
179
|
+
<div class="dot" id="status-dot"></div>
|
|
180
|
+
<span id="status-label">—</span>
|
|
181
|
+
</div>
|
|
182
|
+
<div style="display:flex;gap:6px;margin-left:8px;align-items:center">
|
|
183
|
+
<button id="tab-dash" onclick="switchTab('dash')" style="background:var(--accent);color:white;border:none;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:inherit;font-size:12px">Dashboard</button>
|
|
184
|
+
<button id="tab-queue" onclick="switchTab('queue')" style="background:var(--blue);color:white;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;letter-spacing:0.01em">⚡ Queue</button>
|
|
185
|
+
</div>
|
|
186
|
+
<button class="gear-btn" title="Settings" onclick="openSettings()">⚙</button>
|
|
187
|
+
</div>
|
|
188
|
+
</header>
|
|
189
|
+
|
|
190
|
+
<main>
|
|
191
|
+
<div class="cards">
|
|
192
|
+
<div class="card">
|
|
193
|
+
<div class="label">Window</div>
|
|
194
|
+
<div class="value" id="card-window">—</div>
|
|
195
|
+
<div class="sub" id="card-window-sub"></div>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="card">
|
|
198
|
+
<div class="label">5h Usage <span class="stale-badge" id="stale-badge" style="display:none">STALE</span></div>
|
|
199
|
+
<div class="value" id="card-5h">—</div>
|
|
200
|
+
<div class="bar-wrap">
|
|
201
|
+
<div class="bar-fill" id="bar-5h" style="width:0%"></div>
|
|
202
|
+
<div class="bar-marker" id="marker-5h"></div>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="sub" id="card-5h-sub"></div>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="card">
|
|
207
|
+
<div class="label">Weekly Usage</div>
|
|
208
|
+
<div class="value" id="card-weekly">—</div>
|
|
209
|
+
<div class="bar-wrap">
|
|
210
|
+
<div class="bar-fill" id="bar-weekly" style="width:0%"></div>
|
|
211
|
+
<div class="bar-marker" id="marker-weekly-pause"></div>
|
|
212
|
+
<div class="bar-marker" id="marker-weekly-stop"></div>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="sub" id="card-weekly-sub"></div>
|
|
215
|
+
</div>
|
|
216
|
+
<div class="card">
|
|
217
|
+
<div class="label">Next Milestone</div>
|
|
218
|
+
<div class="value" id="card-next" style="font-size:18px;padding-top:4px">—</div>
|
|
219
|
+
<div class="sub" id="card-next-sub"></div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div class="section">
|
|
224
|
+
<h2>Usage Trend (48h)</h2>
|
|
225
|
+
<div class="chart-wrap"><canvas id="chart"></canvas></div>
|
|
226
|
+
<div class="legend">
|
|
227
|
+
<span><span style="color:#3b82f6">●</span> 5h usage</span>
|
|
228
|
+
<span><span style="color:#22c55e">●</span> weekly usage</span>
|
|
229
|
+
<span><span style="color:rgba(100,116,139,0.4)">█</span> coding window</span>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div class="section">
|
|
234
|
+
<h2>Predictions</h2>
|
|
235
|
+
<div class="predictions" id="predictions"></div>
|
|
236
|
+
</div>
|
|
237
|
+
</main>
|
|
238
|
+
|
|
239
|
+
<!-- Queue panel -->
|
|
240
|
+
<main id="queue-panel" style="display:none">
|
|
241
|
+
<div class="section" style="margin-bottom:16px">
|
|
242
|
+
<h2>Add task to queue</h2>
|
|
243
|
+
<textarea id="q-desc" style="width:100%;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:8px 10px;font-family:inherit;font-size:13px;resize:vertical;min-height:64px;box-sizing:border-box" placeholder="What should Claude do overnight? e.g. Add integration tests for the ledger service in ~/dev/agentnet"></textarea>
|
|
244
|
+
<div style="display:flex;gap:8px;margin-top:8px;align-items:center">
|
|
245
|
+
<span style="color:var(--muted);white-space:nowrap;font-size:12px">cwd:</span>
|
|
246
|
+
<select id="q-cwd-select" onchange="qCwdSelectChange()" style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 10px;font-family:inherit;font-size:13px;cursor:pointer">
|
|
247
|
+
<option value="">— loading projects… —</option>
|
|
248
|
+
</select>
|
|
249
|
+
<input type="text" id="q-cwd-custom" style="flex:1;display:none;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 10px;font-family:inherit;font-size:13px" placeholder="enter path…">
|
|
250
|
+
</div>
|
|
251
|
+
<div style="display:flex;gap:8px;margin-top:8px;align-items:center">
|
|
252
|
+
<select id="q-schedule" style="background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 10px;font-family:inherit;font-size:12px;cursor:pointer">
|
|
253
|
+
<option value="once">Once</option>
|
|
254
|
+
<option value="hourly">Hourly</option>
|
|
255
|
+
<option value="daily">Daily</option>
|
|
256
|
+
<option value="weekly">Weekly</option>
|
|
257
|
+
<option value="monthly">Monthly</option>
|
|
258
|
+
</select>
|
|
259
|
+
<select id="q-provider" style="background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 10px;font-family:inherit;font-size:12px;cursor:pointer">
|
|
260
|
+
<option value="">default provider</option>
|
|
261
|
+
</select>
|
|
262
|
+
<select id="q-model" style="background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 10px;font-family:inherit;font-size:12px;cursor:pointer">
|
|
263
|
+
<option value="claude-sonnet-4-6">Sonnet 4.6</option>
|
|
264
|
+
</select>
|
|
265
|
+
<button onclick="queueAddTask()" style="background:var(--blue);color:#fff;border:none;border-radius:6px;padding:7px 14px;cursor:pointer;font-family:inherit;font-size:13px;white-space:nowrap">Add to queue</button>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div class="section" style="margin-bottom:16px">
|
|
270
|
+
<h2 style="display:flex;justify-content:space-between;align-items:center">
|
|
271
|
+
Task queue
|
|
272
|
+
<span style="font-size:10px;font-weight:400;letter-spacing:0">
|
|
273
|
+
<span id="q-stat-scheduled" style="color:#a78bfa">0 scheduled</span> ·
|
|
274
|
+
<span id="q-stat-pending" style="color:#f59e0b">0 pending</span> ·
|
|
275
|
+
<span id="q-stat-running" style="color:#3b82f6">0 running</span> ·
|
|
276
|
+
<span id="q-stat-done" style="color:#22c55e">0 done</span> ·
|
|
277
|
+
<span id="q-stat-failed" style="color:#ef4444">0 failed</span>
|
|
278
|
+
</span>
|
|
279
|
+
</h2>
|
|
280
|
+
<div id="q-task-list"><div style="color:var(--muted);text-align:center;padding:20px;font-style:italic">No tasks yet</div></div>
|
|
281
|
+
<div id="q-pagination"></div>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
</main>
|
|
285
|
+
|
|
286
|
+
<footer>auto-refreshes every 60s · syncs on open</footer>
|
|
287
|
+
|
|
288
|
+
<!-- Settings modal -->
|
|
289
|
+
<div class="modal-overlay" id="modal-overlay" onclick="maybeCloseModal(event)">
|
|
290
|
+
<div class="modal">
|
|
291
|
+
<div class="modal-header">
|
|
292
|
+
<h2>⚙ Settings</h2>
|
|
293
|
+
<button class="modal-close" onclick="closeSettings()">✕</button>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div class="settings-group">
|
|
297
|
+
<div class="settings-group-title" id="sched-group-title">Schedule</div>
|
|
298
|
+
<div class="setting-row">
|
|
299
|
+
<div>
|
|
300
|
+
<div class="setting-label">Timezone</div>
|
|
301
|
+
<div class="setting-hint">Coding window hours are interpreted in this timezone</div>
|
|
302
|
+
</div>
|
|
303
|
+
<div class="setting-input-wrap">
|
|
304
|
+
<select class="setting-input" id="s-TIMEZONE" style="width:14rem"></select>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
<div class="setting-row">
|
|
308
|
+
<div>
|
|
309
|
+
<div class="setting-label">Coding window start</div>
|
|
310
|
+
<div class="setting-hint">Jobs pause after this time</div>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="setting-input-wrap">
|
|
313
|
+
<select class="setting-input" id="s-CODING_START_H" style="width:9rem"></select>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
<div class="setting-row">
|
|
317
|
+
<div>
|
|
318
|
+
<div class="setting-label">Coding window end</div>
|
|
319
|
+
<div class="setting-hint">Jobs resume after this time</div>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="setting-input-wrap">
|
|
322
|
+
<select class="setting-input" id="s-CODING_END_H" style="width:9rem"></select>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<div class="settings-group">
|
|
328
|
+
<div class="settings-group-title">Budget Thresholds</div>
|
|
329
|
+
<div class="setting-row">
|
|
330
|
+
<div>
|
|
331
|
+
<div class="setting-label">5h pause at</div>
|
|
332
|
+
<div class="setting-hint">Pause when 5h rolling window hits this %</div>
|
|
333
|
+
</div>
|
|
334
|
+
<div class="slider-wrap">
|
|
335
|
+
<input type="range" min="1" max="100" id="s-FIVE_HOUR_PAUSE_PCT" oninput="document.getElementById('v-FIVE_HOUR_PAUSE_PCT').textContent=this.value+'%'">
|
|
336
|
+
<span class="slider-val" id="v-FIVE_HOUR_PAUSE_PCT">75%</span>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
<div class="setting-row">
|
|
340
|
+
<div>
|
|
341
|
+
<div class="setting-label">Weekly reserve</div>
|
|
342
|
+
<div class="setting-hint">Keep this % of weekly budget for coding</div>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="slider-wrap">
|
|
345
|
+
<input type="range" min="0" max="99" id="s-WEEKLY_RESERVE_PCT" oninput="document.getElementById('v-WEEKLY_RESERVE_PCT').textContent=this.value+'%'">
|
|
346
|
+
<span class="slider-val" id="v-WEEKLY_RESERVE_PCT">35%</span>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
<div class="setting-row">
|
|
350
|
+
<div>
|
|
351
|
+
<div class="setting-label">Weekly hard stop</div>
|
|
352
|
+
<div class="setting-hint">Full stop at this weekly %</div>
|
|
353
|
+
</div>
|
|
354
|
+
<div class="slider-wrap">
|
|
355
|
+
<input type="range" min="1" max="100" id="s-WEEKLY_HARD_STOP_PCT" oninput="document.getElementById('v-WEEKLY_HARD_STOP_PCT').textContent=this.value+'%'">
|
|
356
|
+
<span class="slider-val" id="v-WEEKLY_HARD_STOP_PCT">90%</span>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div class="settings-group">
|
|
362
|
+
<div class="settings-group-title">Polling</div>
|
|
363
|
+
<div class="setting-row">
|
|
364
|
+
<div>
|
|
365
|
+
<div class="setting-label">Poll interval</div>
|
|
366
|
+
<div class="setting-hint">How often to read the Claude Code cache</div>
|
|
367
|
+
</div>
|
|
368
|
+
<div class="slider-wrap">
|
|
369
|
+
<input type="range" min="1" max="60" id="s-POLL_INTERVAL_MIN" oninput="document.getElementById('v-POLL_INTERVAL_MIN').textContent=this.value+'m'">
|
|
370
|
+
<span class="slider-val" id="v-POLL_INTERVAL_MIN">5m</span>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
<div class="setting-row">
|
|
374
|
+
<div>
|
|
375
|
+
<div class="setting-label">Cache stale after</div>
|
|
376
|
+
<div class="setting-hint">Treat cache as stale if older than this</div>
|
|
377
|
+
</div>
|
|
378
|
+
<div class="slider-wrap">
|
|
379
|
+
<input type="range" min="5" max="120" id="s-CACHE_STALE_MIN" oninput="document.getElementById('v-CACHE_STALE_MIN').textContent=this.value+'m'">
|
|
380
|
+
<span class="slider-val" id="v-CACHE_STALE_MIN">30m</span>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<div class="settings-group">
|
|
386
|
+
<div class="settings-group-title" style="display:flex;justify-content:space-between;align-items:center">
|
|
387
|
+
Agent Providers
|
|
388
|
+
<button onclick="providerShowAdd()" style="background:var(--blue);color:#fff;border:none;border-radius:4px;padding:2px 8px;font-size:11px;cursor:pointer;font-family:inherit">+ Add</button>
|
|
389
|
+
</div>
|
|
390
|
+
<div id="provider-list" style="margin-bottom:8px"></div>
|
|
391
|
+
<div id="provider-add-form" style="display:none;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;margin-top:8px">
|
|
392
|
+
<div style="font-size:11px;color:var(--muted);margin-bottom:8px;font-weight:600">NEW PROVIDER</div>
|
|
393
|
+
<div style="display:grid;gap:6px">
|
|
394
|
+
<input id="p-name" placeholder="Name (e.g. opencode)" style="background:var(--card);border:1px solid var(--border);border-radius:4px;color:var(--text);padding:5px 8px;font-size:12px;font-family:inherit;width:100%">
|
|
395
|
+
<input id="p-command" placeholder="Command (e.g. /usr/local/bin/opencode)" style="background:var(--card);border:1px solid var(--border);border-radius:4px;color:var(--text);padding:5px 8px;font-size:12px;font-family:var(--mono);width:100%">
|
|
396
|
+
<textarea id="p-args" rows="3" placeholder='Args template as JSON array e.g. ["run","--model","{{model}}","{{task}}"]' style="background:var(--card);border:1px solid var(--border);border-radius:4px;color:var(--text);padding:5px 8px;font-size:11px;font-family:var(--mono);width:100%;resize:vertical"></textarea>
|
|
397
|
+
<div style="font-size:10px;color:var(--muted)">Placeholders: <code>{{task}}</code> <code>{{model}}</code> <code>{{cwd}}</code></div>
|
|
398
|
+
<div style="display:flex;gap:6px">
|
|
399
|
+
<button onclick="providerSaveAdd()" style="background:var(--blue);color:#fff;border:none;border-radius:4px;padding:4px 12px;font-size:12px;cursor:pointer;font-family:inherit">Save</button>
|
|
400
|
+
<button onclick="providerHideAdd()" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;padding:4px 10px;font-size:12px;cursor:pointer;font-family:inherit">Cancel</button>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
<div class="modal-footer">
|
|
407
|
+
<span class="save-status" id="save-status">✓ Saved</span>
|
|
408
|
+
<button class="btn btn-secondary" onclick="closeSettings()">Cancel</button>
|
|
409
|
+
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
<script>
|
|
415
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
let tz = 'America/Los_Angeles'; // updated from /status on every refresh
|
|
418
|
+
|
|
419
|
+
function fmtPct(v) { return v == null ? '—' : Math.round(v) + '%'; }
|
|
420
|
+
function fmtTs(iso) {
|
|
421
|
+
if (!iso) return '—';
|
|
422
|
+
return new Date(iso).toLocaleString('en-US', {
|
|
423
|
+
month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', timeZone: tz
|
|
424
|
+
}) + ' ' + tzAbbr(tz);
|
|
425
|
+
}
|
|
426
|
+
function tzAbbr(tzName) {
|
|
427
|
+
try {
|
|
428
|
+
const parts = new Intl.DateTimeFormat('en-US', {timeZone: tzName, timeZoneName:'short'})
|
|
429
|
+
.formatToParts(new Date());
|
|
430
|
+
return parts.find(p => p.type === 'timeZoneName')?.value ?? tzName;
|
|
431
|
+
} catch { return tzName; }
|
|
432
|
+
}
|
|
433
|
+
function fmtHours(h) {
|
|
434
|
+
if (h == null || !isFinite(h) || h < 0) return null;
|
|
435
|
+
if (h < 1) return Math.round(h * 60) + 'm';
|
|
436
|
+
if (h < 24) return h.toFixed(1) + 'h';
|
|
437
|
+
return (h / 24).toFixed(1) + 'd';
|
|
438
|
+
}
|
|
439
|
+
function barColor(pct, warnAt, stopAt) {
|
|
440
|
+
if (pct >= stopAt) return 'red';
|
|
441
|
+
if (pct >= warnAt) return 'amber';
|
|
442
|
+
return 'green';
|
|
443
|
+
}
|
|
444
|
+
function isCodingHour(date, startH, endH) {
|
|
445
|
+
try {
|
|
446
|
+
const h = parseInt(new Intl.DateTimeFormat('en-US',{timeZone:tz,hour:'numeric',hour12:false}).format(date));
|
|
447
|
+
return h >= startH || h < endH;
|
|
448
|
+
} catch {
|
|
449
|
+
const pstH = ((date.getUTCHours() - 8 + 24) % 24);
|
|
450
|
+
return pstH >= startH || pstH < endH;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function slope(points) {
|
|
454
|
+
const n = points.length;
|
|
455
|
+
if (n < 2) return null;
|
|
456
|
+
let sx = 0, sy = 0, sxy = 0, sxx = 0;
|
|
457
|
+
for (const p of points) { sx += p.x; sy += p.y; sxy += p.x*p.y; sxx += p.x*p.x; }
|
|
458
|
+
const d = n*sxx - sx*sx;
|
|
459
|
+
return d === 0 ? null : (n*sxy - sx*sy) / d;
|
|
460
|
+
}
|
|
461
|
+
function hoursUntil(cur, target, slopePctPerMs) {
|
|
462
|
+
if (!slopePctPerMs || slopePctPerMs <= 0) return null;
|
|
463
|
+
const rem = target - cur;
|
|
464
|
+
if (rem <= 0) return 0;
|
|
465
|
+
return rem / slopePctPerMs / 3600000;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Chart ─────────────────────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
let chart = null;
|
|
471
|
+
let currentThresholds = {};
|
|
472
|
+
|
|
473
|
+
function buildCodingBands(startMs, endMs, startH, endH) {
|
|
474
|
+
const pts = [], step = 15 * 60 * 1000;
|
|
475
|
+
for (let t = startMs; t <= endMs; t += step)
|
|
476
|
+
pts.push({ x: t, y: isCodingHour(new Date(t), startH, endH) ? 100 : null });
|
|
477
|
+
return pts;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function renderChart(fiveH, weekly, startMs, endMs, thr) {
|
|
481
|
+
const ctx = document.getElementById('chart').getContext('2d');
|
|
482
|
+
if (chart) chart.destroy();
|
|
483
|
+
const startH = thr.coding_start_h ?? 16;
|
|
484
|
+
const endH = thr.coding_end_h ?? 2;
|
|
485
|
+
const coding = buildCodingBands(startMs, endMs, startH, endH);
|
|
486
|
+
|
|
487
|
+
chart = new Chart(ctx, {
|
|
488
|
+
type: 'line',
|
|
489
|
+
data: { datasets: [
|
|
490
|
+
{ label:'Coding window', data: coding, borderColor:'transparent', backgroundColor:'rgba(100,116,139,0.1)', fill:true, pointRadius:0, tension:0, spanGaps:false, order:10 },
|
|
491
|
+
{ label:'5h pause', data:[{x:startMs,y:thr.five_hour_pause_pct??75},{x:endMs,y:thr.five_hour_pause_pct??75}], borderColor:'rgba(59,130,246,0.3)', borderDash:[4,4], borderWidth:1, pointRadius:0, fill:false, order:5 },
|
|
492
|
+
{ label:'Weekly pause', data:[{x:startMs,y:100-(thr.weekly_reserve_pct??35)},{x:endMs,y:100-(thr.weekly_reserve_pct??35)}], borderColor:'rgba(245,158,11,0.3)', borderDash:[4,4], borderWidth:1, pointRadius:0, fill:false, order:5 },
|
|
493
|
+
{ label:'Hard stop', data:[{x:startMs,y:thr.weekly_hard_stop_pct??90},{x:endMs,y:thr.weekly_hard_stop_pct??90}], borderColor:'rgba(239,68,68,0.3)', borderDash:[4,4], borderWidth:1, pointRadius:0, fill:false, order:5 },
|
|
494
|
+
{ label:'5h usage', data:fiveH, borderColor:'#3b82f6', backgroundColor:'rgba(59,130,246,0.07)', fill:true, pointRadius:0, pointHoverRadius:4, tension:0.3, borderWidth:2, order:1 },
|
|
495
|
+
{ label:'Weekly usage',data:weekly, borderColor:'#22c55e', backgroundColor:'rgba(34,197,94,0.05)', fill:true, pointRadius:0, pointHoverRadius:4, tension:0.3, borderWidth:2, order:1 },
|
|
496
|
+
]},
|
|
497
|
+
options: {
|
|
498
|
+
responsive:true, maintainAspectRatio:false, animation:false,
|
|
499
|
+
interaction:{mode:'index',intersect:false},
|
|
500
|
+
plugins:{
|
|
501
|
+
legend:{display:false},
|
|
502
|
+
tooltip:{
|
|
503
|
+
backgroundColor:'#1e293b', borderColor:'#334155', borderWidth:1,
|
|
504
|
+
titleColor:'#94a3b8', bodyColor:'#e2e8f0',
|
|
505
|
+
callbacks:{
|
|
506
|
+
title: items => new Date(items[0].parsed.x).toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',timeZone:tz})+' '+tzAbbr(tz),
|
|
507
|
+
label: item => ['Coding window','5h pause','Weekly pause','Hard stop'].includes(item.dataset.label) ? null : ` ${item.dataset.label}: ${item.parsed.y?.toFixed(1)}%`,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
scales:{
|
|
512
|
+
x:{ type:'linear', min:startMs, max:endMs, grid:{color:'rgba(255,255,255,0.04)'}, ticks:{color:'#64748b', maxTicksLimit:8, callback:v=>new Date(v).toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',timeZone:tz})} },
|
|
513
|
+
y:{ min:0, max:100, grid:{color:'rgba(255,255,255,0.04)'}, ticks:{color:'#64748b', callback:v=>v+'%'} },
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── Status cards ──────────────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
function renderStatus(s) {
|
|
522
|
+
const dot = document.getElementById('status-dot');
|
|
523
|
+
const label = document.getElementById('status-label');
|
|
524
|
+
|
|
525
|
+
if (s.window === 'coding') { dot.className='dot amber'; label.textContent='Coding window'; }
|
|
526
|
+
else if (!s.ok) { dot.className='dot red'; label.textContent='Paused'; }
|
|
527
|
+
else { dot.className='dot green'; label.textContent='Idle · max'; }
|
|
528
|
+
|
|
529
|
+
// Window card
|
|
530
|
+
const wEl = document.getElementById('card-window');
|
|
531
|
+
const wSub = document.getElementById('card-window-sub');
|
|
532
|
+
if (s.window === 'coding') {
|
|
533
|
+
wEl.textContent = 'CODING'; wEl.style.color = 'var(--amber)';
|
|
534
|
+
const m = Math.round(s.next_idle_in_s / 60);
|
|
535
|
+
wSub.textContent = `idle in ${m>=60?Math.floor(m/60)+'h '+m%60+'m':m+'m'}`;
|
|
536
|
+
} else {
|
|
537
|
+
wEl.textContent = 'IDLE'; wEl.style.color = 'var(--green)';
|
|
538
|
+
wSub.textContent = s.reason || '';
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Stale badge
|
|
542
|
+
const stale = s.usage?.stale;
|
|
543
|
+
document.getElementById('stale-badge').style.display = stale ? 'inline-block' : 'none';
|
|
544
|
+
|
|
545
|
+
// 5h
|
|
546
|
+
const t5 = s.thresholds?.five_hour_pause_pct ?? 75;
|
|
547
|
+
const ts = s.thresholds?.weekly_hard_stop_pct ?? 90;
|
|
548
|
+
const tw = s.thresholds?.weekly_reserve_pct ?? 35;
|
|
549
|
+
const ceiling = 100 - tw;
|
|
550
|
+
const age = s.usage?.cache_age_s;
|
|
551
|
+
const fiveH = s.usage?.five_hour_pct;
|
|
552
|
+
document.getElementById('card-5h').textContent = fmtPct(fiveH);
|
|
553
|
+
const b5 = document.getElementById('bar-5h');
|
|
554
|
+
b5.style.width = Math.min(fiveH??0,100)+'%';
|
|
555
|
+
b5.className = 'bar-fill ' + barColor(fiveH??0, t5, 100);
|
|
556
|
+
document.getElementById('marker-5h').style.left = t5+'%';
|
|
557
|
+
document.getElementById('card-5h-sub').textContent =
|
|
558
|
+
`pause at ${t5}%` + (age != null ? ` · cache ${Math.round(age/60)}m old` : '');
|
|
559
|
+
|
|
560
|
+
// Weekly
|
|
561
|
+
const weekly = s.usage?.weekly_pct;
|
|
562
|
+
document.getElementById('card-weekly').textContent = fmtPct(weekly);
|
|
563
|
+
const bw = document.getElementById('bar-weekly');
|
|
564
|
+
bw.style.width = Math.min(weekly??0,100)+'%';
|
|
565
|
+
bw.className = 'bar-fill ' + barColor(weekly??0, ceiling, ts);
|
|
566
|
+
document.getElementById('marker-weekly-pause').style.left = ceiling+'%';
|
|
567
|
+
document.getElementById('marker-weekly-stop').style.left = ts+'%';
|
|
568
|
+
document.getElementById('card-weekly-sub').textContent = `pause at ${ceiling}% · stop at ${ts}%`;
|
|
569
|
+
|
|
570
|
+
// Next milestone
|
|
571
|
+
const nextEl = document.getElementById('card-next');
|
|
572
|
+
const nextSub = document.getElementById('card-next-sub');
|
|
573
|
+
if (s.window === 'coding') {
|
|
574
|
+
const h = Math.floor(s.next_idle_in_s/3600), m = Math.floor((s.next_idle_in_s%3600)/60);
|
|
575
|
+
nextEl.textContent = h>0?`${h}h ${m}m`:`${m}m`;
|
|
576
|
+
nextSub.textContent = 'until idle window';
|
|
577
|
+
} else if (s.usage?.weekly_resets_at) {
|
|
578
|
+
const d = new Date(s.usage.weekly_resets_at);
|
|
579
|
+
nextEl.textContent = d.toLocaleDateString('en-US',{month:'short',day:'numeric'});
|
|
580
|
+
nextSub.textContent = d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',timeZone:tz})+' '+tzAbbr(tz)+' · weekly reset';
|
|
581
|
+
} else { nextEl.textContent='—'; nextSub.textContent=''; }
|
|
582
|
+
|
|
583
|
+
currentThresholds = s.thresholds || {};
|
|
584
|
+
if (s.thresholds?.timezone) {
|
|
585
|
+
tz = s.thresholds.timezone;
|
|
586
|
+
const abbr = tzAbbr(tz);
|
|
587
|
+
document.getElementById('sched-group-title').textContent = `Schedule (${abbr})`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Predictions ───────────────────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
function renderPredictions(status, history) {
|
|
594
|
+
const el = document.getElementById('predictions');
|
|
595
|
+
const thr = status.thresholds || {};
|
|
596
|
+
const fiveH = status.usage?.five_hour_pct;
|
|
597
|
+
const weekly = status.usage?.weekly_pct;
|
|
598
|
+
const resets = status.usage?.weekly_resets_at;
|
|
599
|
+
const t5 = thr.five_hour_pause_pct ?? 75;
|
|
600
|
+
const tw = thr.weekly_reserve_pct ?? 35;
|
|
601
|
+
const ts = thr.weekly_hard_stop_pct ?? 90;
|
|
602
|
+
|
|
603
|
+
const cutoff = Date.now() - 6*3600000;
|
|
604
|
+
const recent = history.filter(h => new Date(h.bucket).getTime() > cutoff);
|
|
605
|
+
const s5 = slope(recent.map(h=>({x:new Date(h.bucket).getTime(), y:h.five_hour_pct})));
|
|
606
|
+
const sWk = slope(recent.map(h=>({x:new Date(h.bucket).getTime(), y:h.weekly_pct})));
|
|
607
|
+
|
|
608
|
+
const h5 = hoursUntil(fiveH, t5, s5);
|
|
609
|
+
const hw = hoursUntil(weekly, 100-tw, sWk);
|
|
610
|
+
const hws = hoursUntil(weekly, ts, sWk);
|
|
611
|
+
|
|
612
|
+
const resetStr = resets ? new Date(resets).toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',timeZone:tz})+' '+tzAbbr(tz) : '—';
|
|
613
|
+
|
|
614
|
+
const items = [
|
|
615
|
+
{ icon:'⏱', title:`5h hits pause (${t5}%)`, value: h5===0?'Already paused':h5?'in ~'+fmtHours(h5):`${Math.round(fiveH??0)}% — healthy`, cls: h5===0?'red':h5&&h5<2?'amber':'green' },
|
|
616
|
+
{ icon:'📅', title:`Weekly hits pause (${100-tw}%)`, value: hw===0?'Already paused':hw?'in ~'+fmtHours(hw):`${Math.round(weekly??0)}% — healthy`, cls: hw===0?'red':hw&&hw<12?'amber':'green' },
|
|
617
|
+
{ icon:'🛑', title:`Weekly hard stop (${ts}%)`, value: hws===0?'Already stopped':hws?'in ~'+fmtHours(hws):`${Math.round(weekly??0)}% — safe`, cls: hws===0?'red':hws&&hws<24?'amber':'green' },
|
|
618
|
+
{ icon:'🔄', title:'Weekly budget resets', value: resetStr, cls:'green' },
|
|
619
|
+
];
|
|
620
|
+
el.innerHTML = items.map(i=>`
|
|
621
|
+
<div class="pred-item">
|
|
622
|
+
<div class="pred-icon">${i.icon}</div>
|
|
623
|
+
<div><div class="pred-title">${i.title}</div><div class="pred-value ${i.cls}">${i.value}</div></div>
|
|
624
|
+
</div>`).join('');
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── Slider track fill ─────────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
function updateSliderTrack(el) {
|
|
630
|
+
if (el.type !== 'range') return
|
|
631
|
+
const pct = ((el.value - el.min) / (el.max - el.min)) * 100
|
|
632
|
+
el.style.background = `linear-gradient(to right, var(--blue) ${pct}%, var(--border) ${pct}%)`
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
document.addEventListener('input', e => { if (e.target.type === 'range') updateSliderTrack(e.target) })
|
|
636
|
+
|
|
637
|
+
// ── Settings modal ────────────────────────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
function fmtHour12(h) {
|
|
640
|
+
if (h === 0) return '12:00 AM';
|
|
641
|
+
if (h === 12) return '12:00 PM';
|
|
642
|
+
return h < 12 ? `${h}:00 AM` : `${h-12}:00 PM`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function buildTimezoneSelect(currentTz) {
|
|
646
|
+
const sel = document.getElementById('s-TIMEZONE')
|
|
647
|
+
if (!sel) return
|
|
648
|
+
|
|
649
|
+
// Get all IANA timezones from the browser, group by region
|
|
650
|
+
const all = Intl.supportedValuesOf('timeZone')
|
|
651
|
+
const groups = {}
|
|
652
|
+
for (const tz of all) {
|
|
653
|
+
const region = tz.includes('/') ? tz.split('/')[0] : 'Other'
|
|
654
|
+
if (!groups[region]) groups[region] = []
|
|
655
|
+
groups[region].push(tz)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
sel.innerHTML = ''
|
|
659
|
+
for (const region of Object.keys(groups).sort()) {
|
|
660
|
+
const og = document.createElement('optgroup')
|
|
661
|
+
og.label = region
|
|
662
|
+
for (const tz of groups[region]) {
|
|
663
|
+
const opt = document.createElement('option')
|
|
664
|
+
opt.value = tz
|
|
665
|
+
// Show offset + city: "America/New_York (EST, UTC-5)"
|
|
666
|
+
try {
|
|
667
|
+
const abbr = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'short' })
|
|
668
|
+
.formatToParts(new Date()).find(p => p.type === 'timeZoneName')?.value ?? ''
|
|
669
|
+
opt.textContent = `${tz.split('/').slice(1).join('/').replace(/_/g,' ')} (${abbr})`
|
|
670
|
+
} catch {
|
|
671
|
+
opt.textContent = tz
|
|
672
|
+
}
|
|
673
|
+
if (tz === currentTz) opt.selected = true
|
|
674
|
+
og.appendChild(opt)
|
|
675
|
+
}
|
|
676
|
+
sel.appendChild(og)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function buildHourSelects() {
|
|
681
|
+
['s-CODING_START_H','s-CODING_END_H'].forEach(id => {
|
|
682
|
+
const sel = document.getElementById(id);
|
|
683
|
+
if (!sel || sel.options.length) return;
|
|
684
|
+
for (let h = 0; h < 24; h++) {
|
|
685
|
+
const opt = document.createElement('option');
|
|
686
|
+
opt.value = h;
|
|
687
|
+
opt.textContent = fmtHour12(h);
|
|
688
|
+
sel.appendChild(opt);
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const SETTING_KEYS = ['CODING_START_H','CODING_END_H','TIMEZONE','FIVE_HOUR_PAUSE_PCT','WEEKLY_RESERVE_PCT','WEEKLY_HARD_STOP_PCT','POLL_INTERVAL_MIN','CACHE_STALE_MIN'];
|
|
694
|
+
|
|
695
|
+
async function openSettings() {
|
|
696
|
+
buildHourSelects();
|
|
697
|
+
loadProviders();
|
|
698
|
+
const res = await fetch('/settings');
|
|
699
|
+
const cfg = await res.json();
|
|
700
|
+
buildTimezoneSelect(cfg.TIMEZONE ?? 'America/Los_Angeles');
|
|
701
|
+
const SLIDER_KEYS = { FIVE_HOUR_PAUSE_PCT:'%', WEEKLY_RESERVE_PCT:'%', WEEKLY_HARD_STOP_PCT:'%', POLL_INTERVAL_MIN:'m', CACHE_STALE_MIN:'m' }
|
|
702
|
+
for (const k of SETTING_KEYS) {
|
|
703
|
+
const el = document.getElementById('s-'+k);
|
|
704
|
+
if (!el) continue;
|
|
705
|
+
el.value = cfg[k] ?? '';
|
|
706
|
+
const vEl = document.getElementById('v-'+k);
|
|
707
|
+
if (vEl && SLIDER_KEYS[k]) vEl.textContent = (cfg[k] ?? '') + SLIDER_KEYS[k];
|
|
708
|
+
updateSliderTrack(el);
|
|
709
|
+
}
|
|
710
|
+
document.getElementById('save-status').style.display = 'none';
|
|
711
|
+
document.getElementById('modal-overlay').classList.add('open');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function closeSettings() {
|
|
715
|
+
document.getElementById('modal-overlay').classList.remove('open');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function maybeCloseModal(e) {
|
|
719
|
+
if (e.target === document.getElementById('modal-overlay')) closeSettings();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function saveSettings() {
|
|
723
|
+
const body = {};
|
|
724
|
+
for (const k of SETTING_KEYS) {
|
|
725
|
+
const el = document.getElementById('s-'+k);
|
|
726
|
+
if (el) body[k] = el.value;
|
|
727
|
+
}
|
|
728
|
+
const res = await fetch('/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });
|
|
729
|
+
if (res.ok) {
|
|
730
|
+
const ss = document.getElementById('save-status');
|
|
731
|
+
ss.style.display = 'block';
|
|
732
|
+
setTimeout(() => { ss.style.display='none'; closeSettings(); refresh(); }, 1000);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── Main refresh ──────────────────────────────────────────────────────────────
|
|
737
|
+
|
|
738
|
+
async function refresh() {
|
|
739
|
+
try {
|
|
740
|
+
const [statusRes, historyRes] = await Promise.all([fetch('/status'), fetch('/history?hours=48')]);
|
|
741
|
+
const status = await statusRes.json();
|
|
742
|
+
const history = (await historyRes.json()).history ?? [];
|
|
743
|
+
|
|
744
|
+
renderStatus(status);
|
|
745
|
+
renderPredictions(status, history);
|
|
746
|
+
|
|
747
|
+
const now = Date.now(), start = now - 48*3600000;
|
|
748
|
+
const fiveH = history.map(h=>({x:new Date(h.bucket).getTime(), y:h.five_hour_pct}));
|
|
749
|
+
const weekly = history.map(h=>({x:new Date(h.bucket).getTime(), y:h.weekly_pct}));
|
|
750
|
+
if (status.usage?.five_hour_pct != null) fiveH.push({x:now, y:status.usage.five_hour_pct});
|
|
751
|
+
if (status.usage?.weekly_pct != null) weekly.push({x:now, y:status.usage.weekly_pct});
|
|
752
|
+
renderChart(fiveH, weekly, start, now, status.thresholds || {});
|
|
753
|
+
|
|
754
|
+
document.getElementById('refresh-ts').textContent =
|
|
755
|
+
'updated ' + new Date().toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
|
756
|
+
} catch(e) { console.error('refresh failed', e); }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function init() {
|
|
760
|
+
// Trigger immediate sync before first paint so data is fresh
|
|
761
|
+
await fetch('/sync', { method: 'POST' }).catch(() => {});
|
|
762
|
+
await refresh();
|
|
763
|
+
setInterval(refresh, 60000);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ── Queue tab ──────────────────────────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
let qPage = 0
|
|
769
|
+
let qPageSize = 25
|
|
770
|
+
const qExpanded = new Set()
|
|
771
|
+
const qSessionCache = new Map()
|
|
772
|
+
|
|
773
|
+
// ── Providers ─────────────────────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
let _providers = []
|
|
776
|
+
|
|
777
|
+
async function loadProviders() {
|
|
778
|
+
const data = await fetch('/providers').then(r => r.json()).catch(() => null)
|
|
779
|
+
if (!data?.providers) return
|
|
780
|
+
_providers = data.providers
|
|
781
|
+
// populate add-task dropdown
|
|
782
|
+
const sel = document.getElementById('q-provider')
|
|
783
|
+
sel.innerHTML = `<option value="">default (${_providers.find(p => p.is_default)?.name ?? '—'})</option>` +
|
|
784
|
+
_providers.map(p => `<option value="${p.id}">${p.name}${p.is_default ? ' ★' : ''}</option>`).join('')
|
|
785
|
+
// render settings list
|
|
786
|
+
renderProviderList()
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function renderProviderList() {
|
|
790
|
+
const el = document.getElementById('provider-list')
|
|
791
|
+
if (!el) return
|
|
792
|
+
if (!_providers.length) { el.innerHTML = '<div style="color:var(--muted);font-size:12px;font-style:italic">No providers</div>'; return }
|
|
793
|
+
el.innerHTML = _providers.map(p => `
|
|
794
|
+
<div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:start;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;margin-bottom:6px">
|
|
795
|
+
<div>
|
|
796
|
+
<div style="font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px">
|
|
797
|
+
${escHtml(p.name)}
|
|
798
|
+
${p.is_default ? '<span style="font-size:10px;background:rgba(59,130,246,.2);color:var(--blue);padding:1px 6px;border-radius:3px">default</span>' : ''}
|
|
799
|
+
</div>
|
|
800
|
+
<div style="font-size:11px;color:var(--muted);font-family:var(--mono);margin-top:2px">${escHtml(p.command)}</div>
|
|
801
|
+
<div style="font-size:10px;color:#64748b;font-family:var(--mono);margin-top:2px">${escHtml(p.args_template)}</div>
|
|
802
|
+
</div>
|
|
803
|
+
<div style="display:flex;gap:4px;flex-shrink:0">
|
|
804
|
+
${!p.is_default ? `<button onclick="providerSetDefault(${p.id})" title="Set as default" style="background:var(--card);border:1px solid var(--border);color:var(--muted);border-radius:4px;padding:2px 6px;font-size:11px;cursor:pointer">★</button>` : ''}
|
|
805
|
+
<button onclick="providerEdit(${p.id})" style="background:var(--card);border:1px solid var(--border);color:var(--muted);border-radius:4px;padding:2px 6px;font-size:11px;cursor:pointer">✎</button>
|
|
806
|
+
${!p.is_default ? `<button onclick="providerDelete(${p.id})" style="background:rgba(239,68,68,.15);border:1px solid rgba(239,68,68,.3);color:var(--red);border-radius:4px;padding:2px 6px;font-size:11px;cursor:pointer">✕</button>` : ''}
|
|
807
|
+
</div>
|
|
808
|
+
</div>`).join('')
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function providerShowAdd() {
|
|
812
|
+
document.getElementById('provider-add-form').style.display = 'block'
|
|
813
|
+
document.getElementById('p-name').focus()
|
|
814
|
+
}
|
|
815
|
+
function providerHideAdd() {
|
|
816
|
+
document.getElementById('provider-add-form').style.display = 'none'
|
|
817
|
+
document.getElementById('p-name').value = ''
|
|
818
|
+
document.getElementById('p-command').value = ''
|
|
819
|
+
document.getElementById('p-args').value = ''
|
|
820
|
+
}
|
|
821
|
+
async function providerSaveAdd() {
|
|
822
|
+
const name = document.getElementById('p-name').value.trim()
|
|
823
|
+
const command = document.getElementById('p-command').value.trim()
|
|
824
|
+
const args = document.getElementById('p-args').value.trim()
|
|
825
|
+
if (!name || !command) { alert('Name and command required'); return }
|
|
826
|
+
let argsTemplate = args || '["{{task}}"]'
|
|
827
|
+
try { JSON.parse(argsTemplate) } catch { alert('Args must be a valid JSON array, e.g. ["run","{{task}}"]'); return }
|
|
828
|
+
const r = await fetch('/providers', { method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({name, command, args_template: argsTemplate}) }).then(r=>r.json())
|
|
829
|
+
if (r.ok) { providerHideAdd(); loadProviders() } else { alert(r.error) }
|
|
830
|
+
}
|
|
831
|
+
async function providerSetDefault(id) {
|
|
832
|
+
await fetch(`/providers/${id}/default`, { method:'POST' })
|
|
833
|
+
loadProviders()
|
|
834
|
+
}
|
|
835
|
+
async function providerDelete(id) {
|
|
836
|
+
if (!confirm('Delete this provider?')) return
|
|
837
|
+
const r = await fetch(`/providers/${id}`, { method:'DELETE' }).then(r=>r.json())
|
|
838
|
+
if (r.ok) { loadProviders() } else { alert(r.error) }
|
|
839
|
+
}
|
|
840
|
+
function providerEdit(id) {
|
|
841
|
+
const p = _providers.find(x => x.id === id)
|
|
842
|
+
if (!p) return
|
|
843
|
+
const name = prompt('Name:', p.name); if (name === null) return
|
|
844
|
+
const command = prompt('Command:', p.command); if (command === null) return
|
|
845
|
+
const args = prompt('Args template (JSON array):', p.args_template); if (args === null) return
|
|
846
|
+
try { JSON.parse(args) } catch { alert('Invalid JSON array'); return }
|
|
847
|
+
fetch(`/providers/${id}`, { method:'PUT', headers:{'content-type':'application/json'}, body:JSON.stringify({name, command, args_template: args}) })
|
|
848
|
+
.then(() => loadProviders())
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ── Projects ──────────────────────────────────────────────────────────────────
|
|
852
|
+
|
|
853
|
+
async function loadProjects() {
|
|
854
|
+
const data = await fetch('/projects').then(r => r.json()).catch(() => null)
|
|
855
|
+
const sel = document.getElementById('q-cwd-select')
|
|
856
|
+
const projects = data?.projects ?? []
|
|
857
|
+
sel.innerHTML =
|
|
858
|
+
`<option value="">— home dir (blank) —</option>` +
|
|
859
|
+
projects.map(p => {
|
|
860
|
+
const label = p.cwd.replace('/Users/missa/', '~/').replace('/Users/missa', '~')
|
|
861
|
+
return `<option value="${p.cwd}">${label}</option>`
|
|
862
|
+
}).join('') +
|
|
863
|
+
`<option value="__custom__">Other…</option>`
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function qCwdSelectChange() {
|
|
867
|
+
const sel = document.getElementById('q-cwd-select')
|
|
868
|
+
const custom = document.getElementById('q-cwd-custom')
|
|
869
|
+
if (sel.value === '__custom__') {
|
|
870
|
+
custom.style.display = 'block'
|
|
871
|
+
custom.focus()
|
|
872
|
+
} else {
|
|
873
|
+
custom.style.display = 'none'
|
|
874
|
+
custom.value = ''
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function loadModels() {
|
|
879
|
+
const sel = document.getElementById('q-model')
|
|
880
|
+
const current = sel.value
|
|
881
|
+
const data = await fetch('/models').then(r => r.json()).catch(() => null)
|
|
882
|
+
if (!data?.models?.length) return
|
|
883
|
+
sel.innerHTML = data.models
|
|
884
|
+
.map(m => `<option value="${m.id}"${m.id === current || (!current && m.id === 'claude-sonnet-4-6') ? ' selected' : ''}>${m.name.replace('Claude ','')}</option>`)
|
|
885
|
+
.join('')
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
let activeTab = 'dash'
|
|
889
|
+
|
|
890
|
+
function switchTab(tab) {
|
|
891
|
+
activeTab = tab
|
|
892
|
+
document.querySelector('main:not(#queue-panel)').style.display = tab === 'dash' ? '' : 'none'
|
|
893
|
+
document.getElementById('queue-panel').style.display = tab === 'queue' ? '' : 'none'
|
|
894
|
+
document.getElementById('tab-dash').style.cssText = tab === 'dash'
|
|
895
|
+
? 'background:var(--accent);color:white;border:none;border-radius:6px;padding:5px 10px;cursor:pointer;font-family:inherit;font-size:12px'
|
|
896
|
+
: 'background:var(--card);color:var(--muted);border:1px solid var(--border);border-radius:6px;padding:5px 10px;cursor:pointer;font-family:inherit;font-size:12px'
|
|
897
|
+
document.getElementById('tab-queue').style.cssText = tab === 'queue'
|
|
898
|
+
? 'background:var(--blue);color:white;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;letter-spacing:0.01em;opacity:1'
|
|
899
|
+
: 'background:var(--blue);color:white;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;letter-spacing:0.01em;opacity:0.65'
|
|
900
|
+
if (tab === 'queue') { loadProviders(); loadModels(); loadProjects(); refreshQueue() }
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const SCHEDULE_LABELS = { once:'', hourly:'⏰ hourly', daily:'📅 daily', weekly:'🗓 weekly', monthly:'📆 monthly' }
|
|
904
|
+
|
|
905
|
+
function scheduleChip(t) {
|
|
906
|
+
if (!t.schedule || t.schedule === 'once') return ''
|
|
907
|
+
const label = SCHEDULE_LABELS[t.schedule] || t.schedule
|
|
908
|
+
if (t.status === 'scheduled' && t.next_run_at) {
|
|
909
|
+
return `<span style="font-size:10px;background:rgba(167,139,250,.15);color:#a78bfa;padding:1px 6px;border-radius:3px;margin-left:4px">${label} · runs ${qTimeSince(t.next_run_at)}</span>`
|
|
910
|
+
}
|
|
911
|
+
return `<span style="font-size:10px;background:rgba(167,139,250,.15);color:#a78bfa;padding:1px 6px;border-radius:3px;margin-left:4px">${label}</span>`
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function qTimeSince(iso) {
|
|
915
|
+
if (!iso) return ''
|
|
916
|
+
const d = (Date.now() - new Date(iso).getTime()) / 1000
|
|
917
|
+
if (d < 60) return `${Math.round(d)}s ago`
|
|
918
|
+
if (d < 3600) return `${Math.round(d/60)}m ago`
|
|
919
|
+
return `${Math.round(d/3600)}h ago`
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function qBadge(status) {
|
|
923
|
+
const colors = { scheduled:'#a78bfa', pending:'#f59e0b', running:'#3b82f6', done:'#22c55e', failed:'#ef4444' }
|
|
924
|
+
const c = colors[status] || '#64748b'
|
|
925
|
+
return `<span style="display:inline-block;padding:2px 7px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase;background:${c}22;color:${c}">${status}</span>`
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async function refreshQueue() {
|
|
929
|
+
const [statsRes, tasksRes] = await Promise.all([
|
|
930
|
+
fetch('/queue/stats').then(r => r.json()).catch(() => ({})),
|
|
931
|
+
fetch(`/queue?limit=${qPageSize}&offset=${qPage * qPageSize}`).then(r => r.json()).catch(() => ({ tasks: [], total: 0 })),
|
|
932
|
+
])
|
|
933
|
+
|
|
934
|
+
document.getElementById('q-stat-scheduled').textContent = `${statsRes.scheduled ?? 0} scheduled`
|
|
935
|
+
document.getElementById('q-stat-pending').textContent = `${statsRes.pending ?? 0} pending`
|
|
936
|
+
document.getElementById('q-stat-running').textContent = `${statsRes.running ?? 0} running`
|
|
937
|
+
document.getElementById('q-stat-done').textContent = `${statsRes.done ?? 0} done`
|
|
938
|
+
document.getElementById('q-stat-failed').textContent = `${statsRes.failed ?? 0} failed`
|
|
939
|
+
|
|
940
|
+
const tasks = tasksRes.tasks ?? []
|
|
941
|
+
const total = tasksRes.total ?? 0
|
|
942
|
+
const totalPages = Math.max(1, Math.ceil(total / qPageSize))
|
|
943
|
+
|
|
944
|
+
renderTaskList(tasks)
|
|
945
|
+
renderPagination(total, totalPages)
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function renderTaskList(tasks) {
|
|
949
|
+
const tEl = document.getElementById('q-task-list')
|
|
950
|
+
if (!tasks.length) {
|
|
951
|
+
tEl.innerHTML = '<div style="color:var(--muted);text-align:center;padding:20px;font-style:italic">No tasks yet</div>'
|
|
952
|
+
return
|
|
953
|
+
}
|
|
954
|
+
tEl.innerHTML = tasks.map(t => {
|
|
955
|
+
const expanded = qExpanded.has(t.id)
|
|
956
|
+
const hasSession = !!t.session_path
|
|
957
|
+
const expandIcon = expanded ? '▾' : '▸'
|
|
958
|
+
return `
|
|
959
|
+
<div id="task-row-${t.id}" style="border-bottom:1px solid var(--border)">
|
|
960
|
+
<div style="display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;padding:10px 0;cursor:pointer" onclick="qToggleExpand(${t.id}, event)">
|
|
961
|
+
<span style="color:var(--muted);font-size:11px;min-width:28px;padding-top:2px">#${t.id}</span>
|
|
962
|
+
<div>
|
|
963
|
+
<div style="font-size:13px;line-height:1.5;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
|
964
|
+
<span style="color:var(--muted);font-size:11px;user-select:none">${expandIcon}</span>
|
|
965
|
+
<span style="color:var(--text)">${escHtml(qDescPreview(t.description))}</span>
|
|
966
|
+
${scheduleChip(t)}
|
|
967
|
+
</div>
|
|
968
|
+
<div style="color:var(--muted);font-size:11px;margin-top:3px;padding-left:18px">
|
|
969
|
+
${t.cwd ? `<span>${t.cwd}</span> · ` : ''}${(() => { const p = _providers.find(x => x.id === t.provider_id) || _providers.find(x => x.is_default); return p ? `<span style="color:#94a3b8">${escHtml(p.name)}</span> · ` : '' })()}${t.model ? `<span style="color:#818cf8">${t.model.replace('claude-','')}</span> · ` : ''}added ${qTimeSince(t.created_at)}${t.finished_at ? ` · finished ${qTimeSince(t.finished_at)}` : ''}${t.tool_calls ? ` · ${t.tool_calls} tools` : ''}${t.tokens_used ? ` · ${t.tokens_used.toLocaleString()} tok` : ''}
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
<div style="display:flex;gap:6px;align-items:center" onclick="event.stopPropagation()">
|
|
973
|
+
${qBadge(t.status)}
|
|
974
|
+
${t.status === 'pending' ? `<button id="force-${t.id}" onclick="queueForceRun(${t.id})" style="background:#f59e0b;color:#0f1117;border:none;border-radius:4px;padding:3px 7px;cursor:pointer;font-size:11px;font-weight:600">▶</button>` : ''}
|
|
975
|
+
${t.status !== 'running' ? `<button onclick="queueDeleteTask(${t.id})" style="background:#ef4444;color:white;border:none;border-radius:4px;padding:3px 7px;cursor:pointer;font-size:11px" title="Delete task">✕</button>` : ''}
|
|
976
|
+
</div>
|
|
977
|
+
</div>
|
|
978
|
+
<div id="task-expand-${t.id}" style="display:${expanded ? 'block' : 'none'};padding:0 0 12px 38px">
|
|
979
|
+
${expanded ? renderTaskDetail(t, hasSession) : ''}
|
|
980
|
+
</div>
|
|
981
|
+
</div>`
|
|
982
|
+
}).join('')
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function escHtml(s) {
|
|
986
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function renderMd(text) {
|
|
990
|
+
try { return marked.parse(text || '') } catch { return escHtml(text || '') }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function qDescPreview(desc) {
|
|
994
|
+
const line = (desc || '').split('\n')[0].trim()
|
|
995
|
+
if (line.length > 90) return line.slice(0, 88) + '…'
|
|
996
|
+
if ((desc || '').length > line.length) return line + ' …'
|
|
997
|
+
return line
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function renderTaskDetail(t, hasSession) {
|
|
1001
|
+
const cached = qSessionCache.get(t.id)
|
|
1002
|
+
let html = ''
|
|
1003
|
+
html += `<div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;margin-bottom:8px;max-height:320px;overflow-y:auto" class="md-body">${renderMd(t.description)}</div>`
|
|
1004
|
+
if (t.result) {
|
|
1005
|
+
html += `<div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;margin-bottom:8px;max-height:240px;overflow-y:auto" class="md-body">${renderMd(t.result)}</div>`
|
|
1006
|
+
}
|
|
1007
|
+
if (!hasSession) {
|
|
1008
|
+
html += `<div style="color:var(--muted);font-size:11px;font-style:italic">No session recorded</div>`
|
|
1009
|
+
} else if (!cached) {
|
|
1010
|
+
html += `<div id="session-loading-${t.id}" style="color:var(--muted);font-size:11px">Loading session…</div>`
|
|
1011
|
+
loadSession(t.id)
|
|
1012
|
+
} else if (cached.error) {
|
|
1013
|
+
html += `<div style="color:var(--red);font-size:11px">${cached.error}</div>`
|
|
1014
|
+
} else {
|
|
1015
|
+
html += renderSession(cached.turns)
|
|
1016
|
+
}
|
|
1017
|
+
return html
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function loadSession(taskId) {
|
|
1021
|
+
try {
|
|
1022
|
+
const data = await fetch(`/queue/${taskId}/session`).then(r => r.json())
|
|
1023
|
+
qSessionCache.set(taskId, data)
|
|
1024
|
+
} catch(e) {
|
|
1025
|
+
qSessionCache.set(taskId, { error: e.message })
|
|
1026
|
+
}
|
|
1027
|
+
const expandEl = document.getElementById(`task-expand-${taskId}`)
|
|
1028
|
+
if (!expandEl || !qExpanded.has(taskId)) return
|
|
1029
|
+
// Re-render the detail section
|
|
1030
|
+
const tasks = Array.from(document.querySelectorAll('[id^="task-row-"]'))
|
|
1031
|
+
// Simple: just refresh queue but preserve expansion
|
|
1032
|
+
refreshQueue()
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function renderSession(turns) {
|
|
1036
|
+
if (!turns?.length) return '<div style="color:var(--muted);font-size:11px;font-style:italic">Empty session</div>'
|
|
1037
|
+
return turns.map((turn, i) => {
|
|
1038
|
+
if (turn.role === 'user' && i === 0) return '' // skip first user turn (= task description)
|
|
1039
|
+
if (turn.role === 'user') {
|
|
1040
|
+
return `<div style="margin:6px 0;padding:6px 10px;background:rgba(255,255,255,.04);border-radius:6px;font-size:11px;color:var(--muted)">${escHtml(turn.text || '')}</div>`
|
|
1041
|
+
}
|
|
1042
|
+
// assistant turn
|
|
1043
|
+
let html = '<div style="margin:8px 0">'
|
|
1044
|
+
if (turn.thinking) {
|
|
1045
|
+
const id = 'think-' + Math.random().toString(36).slice(2)
|
|
1046
|
+
html += `<div style="margin-bottom:6px">
|
|
1047
|
+
<button onclick="const d=document.getElementById('${id}');d.style.display=d.style.display?'':'none'"
|
|
1048
|
+
style="background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.3);color:#a78bfa;border-radius:4px;padding:2px 8px;font-size:10px;cursor:pointer;font-family:inherit">
|
|
1049
|
+
💭 Reasoning (${Math.round(turn.thinking.length/1000)}k chars)
|
|
1050
|
+
</button>
|
|
1051
|
+
<div id="${id}" style="display:none;margin-top:6px;background:rgba(139,92,246,.05);border:1px solid rgba(139,92,246,.2);border-radius:6px;padding:8px;font-size:11px;font-family:var(--mono);white-space:pre-wrap;color:#c4b5fd;max-height:300px;overflow-y:auto">${escHtml(turn.thinking)}</div>
|
|
1052
|
+
</div>`
|
|
1053
|
+
}
|
|
1054
|
+
if (turn.text) {
|
|
1055
|
+
html += `<div style="font-size:12px;color:var(--text);white-space:pre-wrap;line-height:1.6;margin-bottom:4px">${escHtml(turn.text)}</div>`
|
|
1056
|
+
}
|
|
1057
|
+
for (const tc of (turn.tool_calls || [])) {
|
|
1058
|
+
const inputStr = JSON.stringify(tc.input, null, 2)
|
|
1059
|
+
const resultStr = tc.result || ''
|
|
1060
|
+
const tcId = 'tc-' + Math.random().toString(36).slice(2)
|
|
1061
|
+
html += `<div style="margin:4px 0;border:1px solid var(--border);border-radius:6px;overflow:hidden;font-size:11px;font-family:var(--mono)">
|
|
1062
|
+
<div onclick="const d=document.getElementById('${tcId}');d.style.display=d.style.display?'':'block'"
|
|
1063
|
+
style="display:flex;align-items:center;gap:8px;padding:5px 10px;background:rgba(59,130,246,.08);cursor:pointer;user-select:none">
|
|
1064
|
+
<span style="color:#60a5fa;font-weight:600">${escHtml(tc.name)}</span>
|
|
1065
|
+
<span style="color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(summarizeInput(tc.input))}</span>
|
|
1066
|
+
<span style="color:var(--muted);font-size:10px">${resultStr ? '✓' : ''}</span>
|
|
1067
|
+
</div>
|
|
1068
|
+
<div id="${tcId}" style="display:none">
|
|
1069
|
+
<div style="padding:6px 10px;background:rgba(0,0,0,.2);white-space:pre-wrap;color:var(--muted);max-height:200px;overflow-y:auto">${escHtml(inputStr.slice(0,2000))}</div>
|
|
1070
|
+
${resultStr ? `<div style="padding:6px 10px;border-top:1px solid var(--border);white-space:pre-wrap;color:#86efac;max-height:200px;overflow-y:auto">${escHtml(resultStr)}</div>` : ''}
|
|
1071
|
+
</div>
|
|
1072
|
+
</div>`
|
|
1073
|
+
}
|
|
1074
|
+
html += '</div>'
|
|
1075
|
+
return html
|
|
1076
|
+
}).join('')
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function summarizeInput(input) {
|
|
1080
|
+
if (!input || typeof input !== 'object') return ''
|
|
1081
|
+
const v = input.command || input.file_path || input.path || input.prompt || input.query || Object.values(input)[0] || ''
|
|
1082
|
+
return String(v).slice(0, 120)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function renderPagination(total, totalPages) {
|
|
1086
|
+
const pEl = document.getElementById('q-pagination')
|
|
1087
|
+
if (!pEl) return
|
|
1088
|
+
const start = qPage * qPageSize + 1
|
|
1089
|
+
const end = Math.min(start + qPageSize - 1, total)
|
|
1090
|
+
pEl.innerHTML = `
|
|
1091
|
+
<div style="display:flex;align-items:center;gap:10px;margin-top:12px;font-size:12px;color:var(--muted)">
|
|
1092
|
+
<span>${total ? `${start}–${end} of ${total}` : '0 tasks'}</span>
|
|
1093
|
+
<select onchange="qSetPageSize(+this.value)" style="background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);padding:2px 6px;font-size:12px;cursor:pointer">
|
|
1094
|
+
${[25,50,100].map(n => `<option value="${n}"${n===qPageSize?' selected':''}>${n} / page</option>`).join('')}
|
|
1095
|
+
</select>
|
|
1096
|
+
<div style="display:flex;gap:4px">
|
|
1097
|
+
<button onclick="qSetPage(0)" ${qPage===0?'disabled':''} style="background:var(--bg);border:1px solid var(--border);border-radius:4px;color:${qPage===0?'var(--muted)':'var(--text)'};padding:2px 8px;cursor:${qPage===0?'default':'pointer'};font-size:12px">«</button>
|
|
1098
|
+
<button onclick="qSetPage(${qPage-1})" ${qPage===0?'disabled':''} style="background:var(--bg);border:1px solid var(--border);border-radius:4px;color:${qPage===0?'var(--muted)':'var(--text)'};padding:2px 8px;cursor:${qPage===0?'default':'pointer'};font-size:12px">‹</button>
|
|
1099
|
+
<span style="padding:2px 10px;background:var(--card);border:1px solid var(--border);border-radius:4px">${qPage+1} / ${totalPages}</span>
|
|
1100
|
+
<button onclick="qSetPage(${qPage+1})" ${qPage>=totalPages-1?'disabled':''} style="background:var(--bg);border:1px solid var(--border);border-radius:4px;color:${qPage>=totalPages-1?'var(--muted)':'var(--text)'};padding:2px 8px;cursor:${qPage>=totalPages-1?'default':'pointer'};font-size:12px">›</button>
|
|
1101
|
+
<button onclick="qSetPage(${totalPages-1})" ${qPage>=totalPages-1?'disabled':''} style="background:var(--bg);border:1px solid var(--border);border-radius:4px;color:${qPage>=totalPages-1?'var(--muted)':'var(--text)'};padding:2px 8px;cursor:${qPage>=totalPages-1?'default':'pointer'};font-size:12px">»</button>
|
|
1102
|
+
</div>
|
|
1103
|
+
</div>`
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function qSetPage(p) { qPage = p; refreshQueue() }
|
|
1107
|
+
function qSetPageSize(s) { qPageSize = s; qPage = 0; refreshQueue() }
|
|
1108
|
+
|
|
1109
|
+
function qToggleExpand(taskId, event) {
|
|
1110
|
+
if (qExpanded.has(taskId)) { qExpanded.delete(taskId) } else { qExpanded.add(taskId) }
|
|
1111
|
+
refreshQueue()
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function queueAddTask() {
|
|
1115
|
+
const desc = document.getElementById('q-desc').value.trim()
|
|
1116
|
+
const selVal = document.getElementById('q-cwd-select').value
|
|
1117
|
+
const cwd = selVal === '__custom__'
|
|
1118
|
+
? document.getElementById('q-cwd-custom').value.trim()
|
|
1119
|
+
: selVal
|
|
1120
|
+
const model = document.getElementById('q-model').value
|
|
1121
|
+
const providerRaw = document.getElementById('q-provider').value
|
|
1122
|
+
const provider_id = providerRaw ? parseInt(providerRaw) : undefined
|
|
1123
|
+
const schedule = document.getElementById('q-schedule').value || 'once'
|
|
1124
|
+
if (!desc) return alert('Description required')
|
|
1125
|
+
const r = await fetch('/queue', {
|
|
1126
|
+
method: 'POST',
|
|
1127
|
+
headers: { 'content-type': 'application/json' },
|
|
1128
|
+
body: JSON.stringify({ description: desc, cwd, model, provider_id, schedule }),
|
|
1129
|
+
}).then(r => r.json())
|
|
1130
|
+
if (r.ok) {
|
|
1131
|
+
document.getElementById('q-desc').value = ''
|
|
1132
|
+
document.getElementById('q-cwd-select').value = ''
|
|
1133
|
+
document.getElementById('q-cwd-custom').style.display = 'none'
|
|
1134
|
+
document.getElementById('q-cwd-custom').value = ''
|
|
1135
|
+
refreshQueue()
|
|
1136
|
+
} else {
|
|
1137
|
+
alert(r.error)
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function queueDeleteTask(id) {
|
|
1142
|
+
if (!confirm('Delete this task? This cannot be undone.')) return
|
|
1143
|
+
await fetch(`/queue/${id}`, { method: 'DELETE' })
|
|
1144
|
+
refreshQueue()
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async function queueForceRun(id) {
|
|
1148
|
+
const btn = document.getElementById(`force-${id}`)
|
|
1149
|
+
if (btn) { btn.textContent = '⏳'; btn.disabled = true }
|
|
1150
|
+
const r = await fetch(`/queue/${id}/force-run`, { method: 'POST' }).then(r => r.json()).catch(() => ({ ok: false, error: 'request failed' }))
|
|
1151
|
+
if (!r.ok) { if (btn) { btn.textContent = '▶'; btn.disabled = false } alert(r.error); return }
|
|
1152
|
+
const poll = setInterval(async () => {
|
|
1153
|
+
const stats = await fetch('/queue/stats').then(r => r.json()).catch(() => ({}))
|
|
1154
|
+
if (!stats.running) { clearInterval(poll); refreshQueue() }
|
|
1155
|
+
}, 2000)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
init();
|
|
1159
|
+
</script>
|
|
1160
|
+
</body>
|
|
1161
|
+
</html>
|