@tidegap/cli 0.0.0-stage → 0.19.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/LICENSE +201 -0
- package/README.md +136 -2
- package/dist/commands/clients.d.ts +20 -0
- package/dist/commands/clients.d.ts.map +1 -0
- package/dist/commands/clients.js +36 -0
- package/dist/commands/clients.js.map +1 -0
- package/dist/commands/env-file.d.ts +54 -0
- package/dist/commands/env-file.d.ts.map +1 -0
- package/dist/commands/env-file.js +153 -0
- package/dist/commands/env-file.js.map +1 -0
- package/dist/commands/install.d.ts +83 -0
- package/dist/commands/install.d.ts.map +1 -0
- package/dist/commands/install.js +297 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/queue.d.ts +139 -0
- package/dist/commands/queue.d.ts.map +1 -0
- package/dist/commands/queue.js +491 -0
- package/dist/commands/queue.js.map +1 -0
- package/dist/commands/receipts.d.ts +15 -0
- package/dist/commands/receipts.d.ts.map +1 -0
- package/dist/commands/receipts.js +70 -0
- package/dist/commands/receipts.js.map +1 -0
- package/dist/commands/register-wake.d.ts +21 -0
- package/dist/commands/register-wake.d.ts.map +1 -0
- package/dist/commands/register-wake.js +76 -0
- package/dist/commands/register-wake.js.map +1 -0
- package/dist/commands/stats.d.ts +31 -0
- package/dist/commands/stats.d.ts.map +1 -0
- package/dist/commands/stats.js +165 -0
- package/dist/commands/stats.js.map +1 -0
- package/dist/commands/tick.d.ts +73 -0
- package/dist/commands/tick.d.ts.map +1 -0
- package/dist/commands/tick.js +294 -0
- package/dist/commands/tick.js.map +1 -0
- package/dist/commands/ui.d.ts +33 -0
- package/dist/commands/ui.d.ts.map +1 -0
- package/dist/commands/ui.js +119 -0
- package/dist/commands/ui.js.map +1 -0
- package/dist/commands/verify.d.ts +32 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +135 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/env.d.ts +57 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +105 -0
- package/dist/env.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +366 -0
- package/dist/index.js.map +1 -0
- package/dist/platform/index.d.ts +30 -0
- package/dist/platform/index.d.ts.map +1 -0
- package/dist/platform/index.js +66 -0
- package/dist/platform/index.js.map +1 -0
- package/dist/platform/linux.d.ts +81 -0
- package/dist/platform/linux.d.ts.map +1 -0
- package/dist/platform/linux.js +158 -0
- package/dist/platform/linux.js.map +1 -0
- package/dist/platform/macos.d.ts +69 -0
- package/dist/platform/macos.d.ts.map +1 -0
- package/dist/platform/macos.js +151 -0
- package/dist/platform/macos.js.map +1 -0
- package/dist/platform/windows.d.ts +31 -0
- package/dist/platform/windows.d.ts.map +1 -0
- package/dist/platform/windows.js +58 -0
- package/dist/platform/windows.js.map +1 -0
- package/dist/ui/api.d.ts +66 -0
- package/dist/ui/api.d.ts.map +1 -0
- package/dist/ui/api.js +613 -0
- package/dist/ui/api.js.map +1 -0
- package/dist/ui/clients.d.ts +65 -0
- package/dist/ui/clients.d.ts.map +1 -0
- package/dist/ui/clients.js +179 -0
- package/dist/ui/clients.js.map +1 -0
- package/dist/ui/discovery.d.ts +25 -0
- package/dist/ui/discovery.d.ts.map +1 -0
- package/dist/ui/discovery.js +46 -0
- package/dist/ui/discovery.js.map +1 -0
- package/dist/ui/dispatcher.d.ts +84 -0
- package/dist/ui/dispatcher.d.ts.map +1 -0
- package/dist/ui/dispatcher.js +150 -0
- package/dist/ui/dispatcher.js.map +1 -0
- package/dist/ui/events.d.ts +27 -0
- package/dist/ui/events.d.ts.map +1 -0
- package/dist/ui/events.js +85 -0
- package/dist/ui/events.js.map +1 -0
- package/dist/ui/server.d.ts +84 -0
- package/dist/ui/server.d.ts.map +1 -0
- package/dist/ui/server.js +322 -0
- package/dist/ui/server.js.map +1 -0
- package/dist/ui/summary.d.ts +47 -0
- package/dist/ui/summary.d.ts.map +1 -0
- package/dist/ui/summary.js +120 -0
- package/dist/ui/summary.js.map +1 -0
- package/package.json +53 -3
- package/ui/agenda.js +256 -0
- package/ui/api.js +114 -0
- package/ui/app.css +713 -0
- package/ui/app.js +599 -0
- package/ui/clients.js +104 -0
- package/ui/detail.js +431 -0
- package/ui/dialog.js +284 -0
- package/ui/dnd.js +131 -0
- package/ui/dom.js +255 -0
- package/ui/index.html +38 -0
- package/ui/journal.js +207 -0
- package/ui/keys.js +92 -0
- package/ui/row.js +153 -0
- package/ui/state.js +412 -0
- package/ui/timeline.js +202 -0
- package/ui/toast.js +69 -0
- package/ui/tokens.css +108 -0
- package/ui/views.js +31 -0
package/ui/state.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// Page state: the task lists, dispatcher status, forecasts and series
|
|
2
|
+
// projections, plus optimistic writes.
|
|
3
|
+
//
|
|
4
|
+
// A write is an "op": its patch is applied to the task at read time, so a
|
|
5
|
+
// refresh that lands before the server has the change cannot flash the old
|
|
6
|
+
// state back. The op is confirmed when the change stream names the task and
|
|
7
|
+
// a re-fetch shows the new state; a 4xx/5xx removes the patch (rollback)
|
|
8
|
+
// and shows the server's message. Cancel is a delayed commit: the POST is
|
|
9
|
+
// sent after the 5 s Undo window, or on pagehide.
|
|
10
|
+
|
|
11
|
+
import { api, ApiError, postKeepalive, request, SessionExpired, tasksPath } from "./api.js";
|
|
12
|
+
import { FINISHED, PENDING, title } from "./dom.js";
|
|
13
|
+
import { errorToast, toast } from "./toast.js";
|
|
14
|
+
|
|
15
|
+
const FORECAST_TTL_MS = 10 * 60_000;
|
|
16
|
+
const CONFIRM_FALLBACK_MS = 5_000;
|
|
17
|
+
const UNDO_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
export const state = {
|
|
20
|
+
loaded: false,
|
|
21
|
+
error: null,
|
|
22
|
+
expired: false,
|
|
23
|
+
connected: true,
|
|
24
|
+
pending: [],
|
|
25
|
+
finished: [],
|
|
26
|
+
status: null,
|
|
27
|
+
/** region -> {at, points, source, error} */
|
|
28
|
+
forecasts: new Map(),
|
|
29
|
+
/** series id -> /api/series body */
|
|
30
|
+
series: new Map(),
|
|
31
|
+
/** task id -> op */
|
|
32
|
+
ops: new Map(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const listeners = new Set();
|
|
36
|
+
const taskListeners = new Set();
|
|
37
|
+
|
|
38
|
+
/** Re-render hook: called after every state change. */
|
|
39
|
+
export function subscribe(fn) {
|
|
40
|
+
listeners.add(fn);
|
|
41
|
+
return () => listeners.delete(fn);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Called with the ids of every change-stream event (after the re-fetch). */
|
|
45
|
+
export function onTaskEvent(fn) {
|
|
46
|
+
taskListeners.add(fn);
|
|
47
|
+
return () => taskListeners.delete(fn);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let notifyQueued = false;
|
|
51
|
+
export function notify() {
|
|
52
|
+
if (notifyQueued) return;
|
|
53
|
+
notifyQueued = true;
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
notifyQueued = false;
|
|
56
|
+
for (const fn of listeners) fn();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── reads ───────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/** Every known task with optimistic patches applied, keyed by id. */
|
|
63
|
+
function allTasks() {
|
|
64
|
+
const out = new Map();
|
|
65
|
+
for (const t of [...state.finished, ...state.pending]) out.set(t.task_id, t);
|
|
66
|
+
for (const [id, op] of state.ops) {
|
|
67
|
+
const t = out.get(id);
|
|
68
|
+
if (t) out.set(id, { ...t, ...op.patch, _op: op });
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function pendingTasks() {
|
|
74
|
+
return [...allTasks().values()].filter((t) => PENDING.includes(t.status));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function finishedTasks() {
|
|
78
|
+
return [...allTasks().values()].filter((t) => FINISHED.includes(t.status));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function findTask(id) {
|
|
82
|
+
return allTasks().get(id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let seq = 0;
|
|
86
|
+
|
|
87
|
+
/** Re-fetch lists and status. Older answers that land late are dropped. */
|
|
88
|
+
export async function refresh() {
|
|
89
|
+
const mine = ++seq;
|
|
90
|
+
try {
|
|
91
|
+
const [p, f, st] = await Promise.all([
|
|
92
|
+
api.list(`status=${PENDING.join(",")}`),
|
|
93
|
+
api.list(`status=${FINISHED.join(",")}&limit=1000`),
|
|
94
|
+
api.status(),
|
|
95
|
+
]);
|
|
96
|
+
if (mine !== seq) return false;
|
|
97
|
+
state.pending = p.tasks;
|
|
98
|
+
state.finished = f.tasks;
|
|
99
|
+
state.status = st;
|
|
100
|
+
state.loaded = true;
|
|
101
|
+
state.error = null;
|
|
102
|
+
notify();
|
|
103
|
+
void loadSeries();
|
|
104
|
+
void loadForecasts();
|
|
105
|
+
return true;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (mine !== seq) return false;
|
|
108
|
+
if (err instanceof SessionExpired) state.expired = true;
|
|
109
|
+
else state.error = err;
|
|
110
|
+
notify();
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function refreshStatus() {
|
|
116
|
+
try {
|
|
117
|
+
state.status = await api.status();
|
|
118
|
+
notify();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err instanceof SessionExpired) {
|
|
121
|
+
state.expired = true;
|
|
122
|
+
notify();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** series id -> signature of its pending rows when the projection was fetched */
|
|
128
|
+
const seriesSig = new Map();
|
|
129
|
+
|
|
130
|
+
async function loadSeries() {
|
|
131
|
+
const sigs = new Map();
|
|
132
|
+
for (const t of state.pending) {
|
|
133
|
+
if (!t.series_id || !t.repeat || t.repeat === "none") continue;
|
|
134
|
+
sigs.set(t.series_id, `${sigs.get(t.series_id) || ""}|${JSON.stringify(t)}`);
|
|
135
|
+
}
|
|
136
|
+
for (const id of [...state.series.keys()]) {
|
|
137
|
+
if (!sigs.has(id)) {
|
|
138
|
+
state.series.delete(id);
|
|
139
|
+
seriesSig.delete(id);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// One request per series whose pending rows changed, not one per series on
|
|
143
|
+
// every stream event.
|
|
144
|
+
const stale = [...sigs].filter(([id, sig]) => seriesSig.get(id) !== sig || !state.series.has(id));
|
|
145
|
+
if (stale.length === 0) return;
|
|
146
|
+
await Promise.all(
|
|
147
|
+
stale.map(async ([id, sig]) => {
|
|
148
|
+
try {
|
|
149
|
+
state.series.set(id, await api.series(id));
|
|
150
|
+
seriesSig.set(id, sig);
|
|
151
|
+
} catch {
|
|
152
|
+
state.series.delete(id);
|
|
153
|
+
seriesSig.delete(id);
|
|
154
|
+
}
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
notify();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Regions with pending work, plus the default region. */
|
|
161
|
+
function regions() {
|
|
162
|
+
const out = new Set(state.pending.map((t) => t.region).filter(Boolean));
|
|
163
|
+
if (state.status?.default_region) out.add(state.status.default_region);
|
|
164
|
+
return [...out];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function loadForecasts() {
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const todo = regions().filter((r) => {
|
|
170
|
+
const f = state.forecasts.get(r);
|
|
171
|
+
return !f || now - f.at > FORECAST_TTL_MS;
|
|
172
|
+
});
|
|
173
|
+
if (todo.length === 0) return;
|
|
174
|
+
await Promise.all(
|
|
175
|
+
todo.map(async (region) => {
|
|
176
|
+
try {
|
|
177
|
+
const f = await api.forecast(region, 72);
|
|
178
|
+
state.forecasts.set(region, { at: now, points: f.points, source: f.source, region: f.region, error: null });
|
|
179
|
+
} catch (err) {
|
|
180
|
+
state.forecasts.set(region, { at: now, points: [], source: null, region, error: err });
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
notify();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Forecast point covering `ms` for `region`, or undefined. */
|
|
188
|
+
export function forecastAt(region, ms) {
|
|
189
|
+
const f = state.forecasts.get(region);
|
|
190
|
+
if (!f || !Number.isFinite(ms)) return undefined;
|
|
191
|
+
const hour = Math.floor(ms / 3_600_000);
|
|
192
|
+
return f.points.find((p) => Math.floor(Date.parse(p.t) / 3_600_000) === hour);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── writes ──────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Run one optimistic write.
|
|
199
|
+
* id task id
|
|
200
|
+
* patch fields applied to the row at once
|
|
201
|
+
* label pending text shown in the row ("Moving…")
|
|
202
|
+
* send () => Promise (the POST)
|
|
203
|
+
* confirm (freshTask) => toast text once the stream confirms
|
|
204
|
+
* Returns the POST result, or undefined when it failed.
|
|
205
|
+
*/
|
|
206
|
+
export async function perform({ id, patch, label, send, confirm, action }) {
|
|
207
|
+
const op = { id, patch: patch || {}, label, confirm, action, posted: false, timer: null };
|
|
208
|
+
state.ops.set(id, op);
|
|
209
|
+
notify();
|
|
210
|
+
return commit(op, send);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function commit(op, send) {
|
|
214
|
+
try {
|
|
215
|
+
const out = await send();
|
|
216
|
+
op.posted = true;
|
|
217
|
+
op.result = out;
|
|
218
|
+
// The stream normally confirms within a second; if it is down, a plain
|
|
219
|
+
// re-fetch confirms instead.
|
|
220
|
+
// The server emits before it answers, so the event may already be here.
|
|
221
|
+
if (op.seen) void confirmOps([op.id]);
|
|
222
|
+
else op.timer = setTimeout(() => confirmOps([op.id]), CONFIRM_FALLBACK_MS);
|
|
223
|
+
return out;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
state.ops.delete(op.id);
|
|
226
|
+
notify();
|
|
227
|
+
if (err instanceof SessionExpired) {
|
|
228
|
+
state.expired = true;
|
|
229
|
+
notify();
|
|
230
|
+
} else {
|
|
231
|
+
// 409 carries the core's one-line refusal; other statuses (500 "action
|
|
232
|
+
// failed", 400 validation) say only that the server did not apply it.
|
|
233
|
+
const msg = !(err instanceof ApiError)
|
|
234
|
+
? "Network error; nothing changed."
|
|
235
|
+
: err.status === 409 || err.status === 400
|
|
236
|
+
? err.message
|
|
237
|
+
: "the server could not apply it; nothing changed.";
|
|
238
|
+
errorToast(`${capital(op.action || "Action")} failed: ${msg}`);
|
|
239
|
+
void refresh();
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const CONFIRM_RETRIES = 5;
|
|
246
|
+
|
|
247
|
+
async function confirmOps(ids) {
|
|
248
|
+
const ready = ids.map((id) => state.ops.get(id)).filter((op) => op && op.posted);
|
|
249
|
+
if (ready.length === 0) return;
|
|
250
|
+
if (!(await refresh())) {
|
|
251
|
+
// A failed or superseded refresh says nothing about the write: keep the
|
|
252
|
+
// optimistic patch and try again, so the old state never flashes back.
|
|
253
|
+
for (const op of ready) {
|
|
254
|
+
if (state.ops.get(op.id) !== op) continue;
|
|
255
|
+
op.tries = (op.tries || 0) + 1;
|
|
256
|
+
clearTimeout(op.timer);
|
|
257
|
+
if (op.tries < CONFIRM_RETRIES) op.timer = setTimeout(() => confirmOps([op.id]), 1000);
|
|
258
|
+
else state.ops.delete(op.id);
|
|
259
|
+
}
|
|
260
|
+
if (ready.some((op) => !state.ops.has(op.id))) notify();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
for (const op of ready) {
|
|
264
|
+
if (state.ops.get(op.id) !== op) continue;
|
|
265
|
+
clearTimeout(op.timer);
|
|
266
|
+
state.ops.delete(op.id);
|
|
267
|
+
const fresh = findTask(op.id) || op.result;
|
|
268
|
+
if (op.confirm) {
|
|
269
|
+
const msg = op.confirm(fresh);
|
|
270
|
+
if (msg) toast(msg.text || msg, msg.action ? { action: msg.action } : {});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
notify();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function capital(s) {
|
|
277
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Delayed cancels: id -> {op, toast, series} */
|
|
281
|
+
const delayed = new Map();
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Cancel with Undo (KTD12). The row looks cancelled at once; the POST goes
|
|
285
|
+
* out when the toast expires. Undo drops the patch and sends nothing.
|
|
286
|
+
*/
|
|
287
|
+
export function cancelWithUndo(task, { series = false } = {}) {
|
|
288
|
+
if (delayed.has(task.task_id)) return;
|
|
289
|
+
const id = task.task_id;
|
|
290
|
+
const op = {
|
|
291
|
+
id,
|
|
292
|
+
patch: { status: "cancelled", completed_at: new Date().toISOString(), repeat: series ? "none" : task.repeat },
|
|
293
|
+
label: "Cancelling…",
|
|
294
|
+
action: series ? "stop series" : "cancel",
|
|
295
|
+
posted: false,
|
|
296
|
+
timer: null,
|
|
297
|
+
delayed: true,
|
|
298
|
+
confirm: () => (series ? `Series stopped: “${title(task)}”` : `Cancelled “${title(task)}”`),
|
|
299
|
+
};
|
|
300
|
+
state.ops.set(id, op);
|
|
301
|
+
notify();
|
|
302
|
+
const t = toast(series ? `Stopping series “${title(task)}”` : `Cancelled “${title(task)}”`, {
|
|
303
|
+
timeout: UNDO_MS,
|
|
304
|
+
countdown: true,
|
|
305
|
+
action: {
|
|
306
|
+
label: "Undo",
|
|
307
|
+
run: () => {
|
|
308
|
+
delayed.delete(id);
|
|
309
|
+
state.ops.delete(id);
|
|
310
|
+
notify();
|
|
311
|
+
toast(`Kept “${title(task)}”`);
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
onTimeout: () => {
|
|
315
|
+
if (!delayed.has(id)) return;
|
|
316
|
+
delayed.delete(id);
|
|
317
|
+
op.delayed = false;
|
|
318
|
+
op.label = "Cancelling…";
|
|
319
|
+
void commit(op, () => api.cancel(id, series));
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
delayed.set(id, { op, toast: t, series });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** True while a cancel for `id` waits in its Undo window. */
|
|
326
|
+
export function cancelPending(id) {
|
|
327
|
+
return delayed.has(id);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Send every delayed cancel now (the page is going away). */
|
|
331
|
+
export function flushDelayed() {
|
|
332
|
+
if (delayed.size === 0) return;
|
|
333
|
+
for (const [id, d] of delayed) {
|
|
334
|
+
postKeepalive(tasksPath(id, "cancel"), d.series ? { series: true } : {});
|
|
335
|
+
// The cancel is sent: a page restored from the back/forward cache must
|
|
336
|
+
// not offer Undo for it or keep the row in its pending look.
|
|
337
|
+
d.toast.element.remove();
|
|
338
|
+
state.ops.delete(id);
|
|
339
|
+
}
|
|
340
|
+
delayed.clear();
|
|
341
|
+
notify();
|
|
342
|
+
void refresh();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── change stream (SSE read over fetch so the bearer header applies) ─────
|
|
346
|
+
|
|
347
|
+
export async function stream() {
|
|
348
|
+
let delay = 1000;
|
|
349
|
+
let reconnect = false;
|
|
350
|
+
while (!state.expired) {
|
|
351
|
+
try {
|
|
352
|
+
const res = await request("/api/events");
|
|
353
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
354
|
+
state.connected = true;
|
|
355
|
+
notify();
|
|
356
|
+
delay = 1000;
|
|
357
|
+
const reader = res.body.getReader();
|
|
358
|
+
const dec = new TextDecoder();
|
|
359
|
+
let buf = "";
|
|
360
|
+
for (;;) {
|
|
361
|
+
const { value, done } = await reader.read();
|
|
362
|
+
if (done) break;
|
|
363
|
+
buf += dec.decode(value, { stream: true });
|
|
364
|
+
let i;
|
|
365
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
366
|
+
const msg = buf.slice(0, i);
|
|
367
|
+
buf = buf.slice(i + 2);
|
|
368
|
+
handleEvent(msg, reconnect);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (err instanceof SessionExpired) {
|
|
373
|
+
state.expired = true;
|
|
374
|
+
notify();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
state.connected = false;
|
|
379
|
+
notify();
|
|
380
|
+
reconnect = true;
|
|
381
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
382
|
+
delay = Math.min(delay * 2, 30_000);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function handleEvent(msg, reconnect) {
|
|
387
|
+
const event = /^event: (.+)$/m.exec(msg)?.[1];
|
|
388
|
+
const data = /^data: (.*)$/m.exec(msg)?.[1];
|
|
389
|
+
if (event === "hello") {
|
|
390
|
+
// Changes made while the stream was down produce no event of their own.
|
|
391
|
+
if (reconnect) void refresh().then(() => emitTaskEvent([]));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (event !== "tasks") return;
|
|
395
|
+
let ids = [];
|
|
396
|
+
try {
|
|
397
|
+
ids = JSON.parse(data || "{}").ids || [];
|
|
398
|
+
} catch {
|
|
399
|
+
ids = [];
|
|
400
|
+
}
|
|
401
|
+
for (const id of ids) {
|
|
402
|
+
const op = state.ops.get(id);
|
|
403
|
+
if (op && !op.posted) op.seen = true;
|
|
404
|
+
}
|
|
405
|
+
const mine = ids.filter((id) => state.ops.get(id)?.posted);
|
|
406
|
+
const work = mine.length ? confirmOps(mine) : refresh();
|
|
407
|
+
void work.then(() => emitTaskEvent(ids));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function emitTaskEvent(ids) {
|
|
411
|
+
for (const fn of taskListeners) fn(ids);
|
|
412
|
+
}
|
package/ui/timeline.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Timeline: the forecast curve for the next hours with every pending task
|
|
2
|
+
// placed at its scheduled hour. The area under the curve takes the carbon
|
|
3
|
+
// band colour of each hour. Clicking a marker selects its row in the list
|
|
4
|
+
// under the chart (same row anatomy as the Agenda).
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
by,
|
|
8
|
+
BAND_LABEL,
|
|
9
|
+
bandChip,
|
|
10
|
+
dayLabel,
|
|
11
|
+
h,
|
|
12
|
+
hm,
|
|
13
|
+
nextDayStart,
|
|
14
|
+
parse,
|
|
15
|
+
s,
|
|
16
|
+
title,
|
|
17
|
+
when,
|
|
18
|
+
} from "./dom.js";
|
|
19
|
+
import { runAt } from "./agenda.js";
|
|
20
|
+
import { statusTag, taskRow } from "./row.js";
|
|
21
|
+
import { notify, pendingTasks, state } from "./state.js";
|
|
22
|
+
import { emptyState, errorCard, skeletonRows } from "./views.js";
|
|
23
|
+
|
|
24
|
+
const HOUR = 3_600_000;
|
|
25
|
+
let selected = null;
|
|
26
|
+
|
|
27
|
+
export function selectMarker(id) {
|
|
28
|
+
selected = id;
|
|
29
|
+
notify();
|
|
30
|
+
requestAnimationFrame(() => {
|
|
31
|
+
const row = document.querySelector(`#view .row[data-task-id="${CSS.escape(id)}"]`);
|
|
32
|
+
if (row) {
|
|
33
|
+
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
34
|
+
row.focus({ preventScroll: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function primaryRegion(tasks) {
|
|
40
|
+
const counts = new Map();
|
|
41
|
+
for (const t of tasks) counts.set(t.region, (counts.get(t.region) || 0) + 1);
|
|
42
|
+
let best = state.status?.default_region;
|
|
43
|
+
let n = 0;
|
|
44
|
+
for (const [r, c] of counts) if (c > n) [best, n] = [r, c];
|
|
45
|
+
return best;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderTimeline(root, { selectedId, width } = {}) {
|
|
49
|
+
root.append(h("div", { class: "view-head" }, h("h1", { class: "view-title" }, "Timeline")));
|
|
50
|
+
if (!state.loaded) {
|
|
51
|
+
root.append(state.error ? errorCard("Could not load tasks.") : h("div", { class: "sk sk-chart", "aria-busy": "true", "aria-label": "Loading forecast" }), skeletonRows(3));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const tasks = pendingTasks().sort((a, b) => (runAt(a) || 0) - (runAt(b) || 0));
|
|
55
|
+
const region = primaryRegion(tasks);
|
|
56
|
+
const f = state.forecasts.get(region);
|
|
57
|
+
const head = root.firstElementChild;
|
|
58
|
+
head.append(h("p", { class: "view-sub" }, `${region} · gCO2e per kWh · next ${f?.points?.length || 72} h`));
|
|
59
|
+
|
|
60
|
+
if (!f) {
|
|
61
|
+
root.append(h("div", { class: "sk sk-chart", "aria-busy": "true", "aria-label": "Loading forecast" }));
|
|
62
|
+
} else if (f.error || f.points.length < 2) {
|
|
63
|
+
root.append(errorCard("Forecast unavailable. The task list below is still current."));
|
|
64
|
+
} else {
|
|
65
|
+
const chartWrap = h("figure", { class: "chart-wrap" });
|
|
66
|
+
chartWrap.append(chart(f.points, tasks, region, width, selectedId || selected));
|
|
67
|
+
const inRange = tasks.filter((t) => markerTime(t, f.points) !== null).length;
|
|
68
|
+
if (inRange === 0) chartWrap.append(h("p", { class: "chart-empty" }, "No tasks in the next 72 h"));
|
|
69
|
+
chartWrap.append(h("figcaption", { class: "chart-legend" },
|
|
70
|
+
Object.keys(BAND_LABEL).map((b) => bandChip(b)),
|
|
71
|
+
h("span", { class: "legend-note" },
|
|
72
|
+
`source ${f.source}${f.source === "mock" ? " (synthetic, not a measurement)" : ""} · dots are tasks at their scheduled hour, ticks are deadlines`)));
|
|
73
|
+
root.append(chartWrap);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (tasks.length === 0) {
|
|
77
|
+
root.append(emptyState({ title: "No pending tasks.", body: "Deferred tasks appear on the curve at the hour tidegap picked.", glyph: "∿" }));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const list = h("ul", { class: "rows" });
|
|
81
|
+
for (const t of tasks) {
|
|
82
|
+
const at = runAt(t);
|
|
83
|
+
list.append(taskRow(t, {
|
|
84
|
+
ctx: "timeline",
|
|
85
|
+
time: Number.isFinite(at) ? hm(at) : "",
|
|
86
|
+
meta: [
|
|
87
|
+
t.status === "running" || t.status === "submitted" ? statusTag(t) : null,
|
|
88
|
+
Number.isFinite(at) ? when(at) : null,
|
|
89
|
+
Number.isFinite(parse(t.deadline)) ? by(parse(t.deadline)) : null,
|
|
90
|
+
].filter(Boolean),
|
|
91
|
+
selected: t.task_id === (selectedId || selected),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
root.append(h("section", { class: "group" }, h("div", { class: "day-head" }, h("h2", { class: "day-title" }, "Pending, by run time"), h("span", { class: "day-count" }, String(tasks.length))), list));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Scheduled hour of `t` when the forecast covers it, else null. */
|
|
98
|
+
function markerTime(t, points) {
|
|
99
|
+
if (t.status === "submitted") return null;
|
|
100
|
+
const at = runAt(t);
|
|
101
|
+
const t0 = Date.parse(points[0].t);
|
|
102
|
+
const t1 = Date.parse(points[points.length - 1].t) + HOUR;
|
|
103
|
+
return Number.isFinite(at) && at >= t0 && at < t1 ? at : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function chart(points, tasks, region, width = 960, selectedId) {
|
|
107
|
+
const W = Math.max(320, Math.round(width));
|
|
108
|
+
const H = W < 720 ? 160 : 220;
|
|
109
|
+
const L = 36, R = 12, T = 18, B = 26;
|
|
110
|
+
const t0 = Date.parse(points[0].t);
|
|
111
|
+
const t1 = Date.parse(points[points.length - 1].t) + HOUR;
|
|
112
|
+
const gMax = Math.max(...points.map((p) => p.g)) * 1.15 || 1;
|
|
113
|
+
const x = (ms) => L + ((ms - t0) / (t1 - t0)) * (W - L - R);
|
|
114
|
+
const y = (g) => T + (1 - g / gMax) * (H - T - B);
|
|
115
|
+
const base = H - B;
|
|
116
|
+
|
|
117
|
+
const svg = s("svg", {
|
|
118
|
+
class: "chart",
|
|
119
|
+
width: W,
|
|
120
|
+
height: H,
|
|
121
|
+
viewBox: `0 0 ${W} ${H}`,
|
|
122
|
+
role: "img",
|
|
123
|
+
"aria-label": `Forecast carbon intensity for ${region} with pending tasks at their scheduled hour. The same tasks are listed below the chart.`,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Band thresholds as faint guides.
|
|
127
|
+
for (const g of [100, 250, 450, 700]) {
|
|
128
|
+
if (g >= gMax) break;
|
|
129
|
+
svg.append(s("line", { class: "guide", x1: L, x2: W - R, y1: y(g), y2: y(g) }));
|
|
130
|
+
svg.append(s("text", { class: "axis-label", x: L - 6, y: y(g) + 4, "text-anchor": "end" }, String(g)));
|
|
131
|
+
}
|
|
132
|
+
svg.append(s("text", { class: "axis-label", x: L - 6, y: base + 4, "text-anchor": "end" }, "0"));
|
|
133
|
+
|
|
134
|
+
// Band-coloured area, one segment per hour, under a smooth-ish curve.
|
|
135
|
+
const mids = points.map((p) => ({ x: x(Date.parse(p.t) + HOUR / 2), y: y(p.g), band: p.band }));
|
|
136
|
+
const pts = [{ x: L, y: mids[0].y, band: mids[0].band }, ...mids, { x: x(t1), y: mids[mids.length - 1].y, band: mids[mids.length - 1].band }];
|
|
137
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
138
|
+
const a = pts[i], b = pts[i + 1];
|
|
139
|
+
svg.append(s("path", {
|
|
140
|
+
class: `area area-${i === 0 ? b.band : a.band}`,
|
|
141
|
+
d: `M${a.x.toFixed(1)},${base} L${a.x.toFixed(1)},${a.y.toFixed(1)} L${b.x.toFixed(1)},${b.y.toFixed(1)} L${b.x.toFixed(1)},${base} Z`,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
svg.append(s("path", { class: "curve", d: pts.map((p, i) => `${i ? "L" : "M"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") }));
|
|
145
|
+
svg.append(s("line", { class: "axis", x1: L, x2: W - R, y1: base, y2: base }));
|
|
146
|
+
|
|
147
|
+
// Day boundaries and hour labels.
|
|
148
|
+
for (let d = nextDayStart(t0); d < t1; d = nextDayStart(d)) {
|
|
149
|
+
svg.append(s("line", { class: "day-line", x1: x(d), x2: x(d), y1: T - 6, y2: base }));
|
|
150
|
+
svg.append(s("text", { class: "day-label", x: x(d) + 4, y: T - 6 }, dayLabel(d)));
|
|
151
|
+
}
|
|
152
|
+
const step = W < 720 ? 12 : 6;
|
|
153
|
+
for (let ms = Math.ceil(t0 / HOUR) * HOUR; ms < t1; ms += HOUR) {
|
|
154
|
+
const hr = new Date(ms).getHours();
|
|
155
|
+
if (hr % step !== 0 || hr === 0) continue;
|
|
156
|
+
svg.append(s("text", { class: "axis-label", x: x(ms), y: H - 8, "text-anchor": "middle" }, hm(ms)));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Now.
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
if (now >= t0 && now <= t1) {
|
|
162
|
+
svg.append(s("line", { class: "now-line", x1: x(now), x2: x(now), y1: T - 6, y2: base }));
|
|
163
|
+
svg.append(s("text", { class: "now-label", x: x(now) + 4, y: base - 6 }, "now"));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Deadline ticks.
|
|
167
|
+
for (const t of tasks) {
|
|
168
|
+
const dl = parse(t.deadline);
|
|
169
|
+
if (!Number.isFinite(dl) || dl < t0 || dl > t1) continue;
|
|
170
|
+
svg.append(s("line", { class: "deadline-tick", x1: x(dl), x2: x(dl), y1: base - 8, y2: base + 4 },
|
|
171
|
+
s("title", {}, `Deadline: ${title(t)} by ${when(dl)}`)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Task markers, grouped per hour.
|
|
175
|
+
const slots = new Map();
|
|
176
|
+
for (const t of tasks) {
|
|
177
|
+
const at = markerTime(t, points);
|
|
178
|
+
if (at === null) continue;
|
|
179
|
+
const slot = Math.floor(at / HOUR);
|
|
180
|
+
slots.set(slot, [...(slots.get(slot) || []), t]);
|
|
181
|
+
}
|
|
182
|
+
for (const [slot, group] of slots) {
|
|
183
|
+
const p = points.find((q) => Math.floor(Date.parse(q.t) / HOUR) === slot);
|
|
184
|
+
const cx = x(slot * HOUR + HOUR / 2);
|
|
185
|
+
const cy = p ? y(p.g) : base;
|
|
186
|
+
const ids = group.map((t) => t.task_id);
|
|
187
|
+
const isSel = ids.includes(selectedId);
|
|
188
|
+
const label = `${group.map(title).join(", ")} at ${hm(slot * HOUR)}${p ? `, ${BAND_LABEL[p.band]}` : ""}`;
|
|
189
|
+
const g = s("g", {
|
|
190
|
+
class: ["marker", p && `marker-${p.band}`, isSel && "is-selected"].filter(Boolean).join(" "),
|
|
191
|
+
tabindex: "0",
|
|
192
|
+
role: "button",
|
|
193
|
+
"aria-label": label,
|
|
194
|
+
"data-select": ids[0],
|
|
195
|
+
}, s("title", {}, label));
|
|
196
|
+
g.append(s("circle", { class: "marker-halo", cx, cy, r: group.length > 1 ? 13 : 10 }));
|
|
197
|
+
g.append(s("circle", { class: "marker-dot", cx, cy, r: group.length > 1 ? 8 : 5 }));
|
|
198
|
+
if (group.length > 1) g.append(s("text", { class: "marker-count", x: cx, y: cy + 3.5, "text-anchor": "middle" }, String(group.length)));
|
|
199
|
+
svg.append(g);
|
|
200
|
+
}
|
|
201
|
+
return svg;
|
|
202
|
+
}
|
package/ui/toast.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Toasts: short confirmations at the bottom of the page. Info toasts use
|
|
2
|
+
// role="status", errors role="alert". A toast may carry one action button
|
|
3
|
+
// (Undo, Start dispatcher, Open).
|
|
4
|
+
|
|
5
|
+
import { h } from "./dom.js";
|
|
6
|
+
|
|
7
|
+
const MAX_VISIBLE = 3;
|
|
8
|
+
|
|
9
|
+
function region() {
|
|
10
|
+
return document.getElementById("toasts");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Show a toast. Returns `{dismiss}`. `timeout` 0 keeps it until dismissed.
|
|
15
|
+
* `onTimeout` runs when the toast expires on its own (not on dismiss or
|
|
16
|
+
* on its action), which is how Undo's delayed commit works.
|
|
17
|
+
*/
|
|
18
|
+
export function toast(message, opts = {}) {
|
|
19
|
+
const { kind = "info", action, timeout = kind === "error" ? 8000 : 4500, onTimeout, countdown = false } = opts;
|
|
20
|
+
const root = region();
|
|
21
|
+
let timer = null;
|
|
22
|
+
let done = false;
|
|
23
|
+
const el = h("div", {
|
|
24
|
+
class: ["toast", `toast-${kind}`, countdown && "toast-countdown"],
|
|
25
|
+
role: kind === "error" ? "alert" : "status",
|
|
26
|
+
});
|
|
27
|
+
const close = (why) => {
|
|
28
|
+
if (done) return;
|
|
29
|
+
done = true;
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
el.classList.add("toast-out");
|
|
32
|
+
setTimeout(() => el.remove(), 220);
|
|
33
|
+
if (why === "timeout" && onTimeout) onTimeout();
|
|
34
|
+
};
|
|
35
|
+
el.append(h("span", { class: "toast-msg" }, message));
|
|
36
|
+
if (action) {
|
|
37
|
+
el.append(
|
|
38
|
+
h("button", {
|
|
39
|
+
type: "button",
|
|
40
|
+
class: "toast-action",
|
|
41
|
+
onclick: () => {
|
|
42
|
+
close("action");
|
|
43
|
+
action.run();
|
|
44
|
+
},
|
|
45
|
+
}, action.label),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
el.append(h("button", { type: "button", class: "toast-close", "aria-label": "Dismiss", onclick: () => close("dismiss") }, "×"));
|
|
49
|
+
if (countdown && timeout > 0) {
|
|
50
|
+
const bar = h("span", { class: "toast-bar", "aria-hidden": "true" });
|
|
51
|
+
bar.style.animationDuration = `${timeout}ms`;
|
|
52
|
+
el.append(bar);
|
|
53
|
+
}
|
|
54
|
+
root.append(el);
|
|
55
|
+
// Evict the oldest toast that carries no pending commit. An Undo toast
|
|
56
|
+
// stays until it expires (its expiry is the commit), so the stack may grow
|
|
57
|
+
// past the cap when several cancels wait at once.
|
|
58
|
+
while (root.children.length > MAX_VISIBLE) {
|
|
59
|
+
const old = [...root.children].find((c) => !c.classList.contains("toast-countdown"));
|
|
60
|
+
if (!old) break;
|
|
61
|
+
old.remove();
|
|
62
|
+
}
|
|
63
|
+
if (timeout > 0) timer = setTimeout(() => close("timeout"), timeout);
|
|
64
|
+
return { dismiss: () => close("dismiss"), expire: () => close("timeout"), element: el };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function errorToast(message, action) {
|
|
68
|
+
return toast(message, { kind: "error", action });
|
|
69
|
+
}
|