@iworkflow/viewer 0.0.0-stage → 0.16.3
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 +10 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +315 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +270 -0
- package/dist/page.d.ts +1 -0
- package/package.json +54 -4
- package/src/cli.ts +55 -0
- package/src/index.ts +104 -0
- package/src/page.ts +158 -0
package/README.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @iworkflow/viewer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Просмотрщик процессов для обоих движков: список с отбором, процесс со входом, итогом и шагами, переходы к родителю и дочерним. Только чтение.
|
|
4
|
+
|
|
5
|
+
- На своей машине: `DBOS_SYSTEM_DATABASE_URL=… bunx -p @iworkflow/viewer -p @iworkflow/dbos iworkflow-viewer`
|
|
6
|
+
(`--engine temporal` с переменными `TEMPORAL_*`, `--port 8233`, `--host 127.0.0.1`). Стенд: `kubectl port-forward` до базы.
|
|
7
|
+
- В модуле: `flowViewer(client)` — обработчик `fetch`. Elysia `app.mount("/admin/flows", flowViewer(client))` срезает
|
|
8
|
+
префикс сама; Bun.serve без среза — `flowViewer(client, { basePath: "/admin/flows" })`.
|
|
9
|
+
Входа нет: модуль ставит его за свою проверку прав. Вход и итог процессов — персональные данные.
|
|
10
|
+
- API: `GET <base>/api/flows?app&flow&status&idPrefix&since&limit` — `client.list`, `GET <base>/api/flows/<id>` — `client.inspect`.
|
|
11
|
+
Неверный отбор, limit больше 1000 — 400; нет процесса — 404. CLI отвечает только на локальный Host (DNS rebinding).
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
import { checkList, FLOW_STATUSES, isFlowError } from "@iworkflow/core";
|
|
8
|
+
|
|
9
|
+
// src/page.ts
|
|
10
|
+
var PAGE = `<!doctype html>
|
|
11
|
+
<html lang="ru">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="utf-8">
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
15
|
+
<title>Процессы iworkflow</title>
|
|
16
|
+
<style>
|
|
17
|
+
:root { --bg:#fff; --fg:#1b1f24; --muted:#636c76; --line:#d8dee4; --hover:#f3f5f7; --sel:#e6efff;
|
|
18
|
+
--ok:#1a7f37; --bad:#cf222e; --run:#0969da; --warn:#9a6700; font: 14px/1.4 system-ui, sans-serif; }
|
|
19
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#0d1117; --fg:#e6edf3; --muted:#8d96a0; --line:#30363d;
|
|
20
|
+
--hover:#161b22; --sel:#1c2a40; --ok:#3fb950; --bad:#f85149; --run:#58a6ff; --warn:#d29922; } }
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
body { margin:0; background:var(--bg); color:var(--fg); }
|
|
23
|
+
header { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:10px 16px; border-bottom:1px solid var(--line); }
|
|
24
|
+
header h1 { font-size:16px; margin:0 12px 0 0; }
|
|
25
|
+
input, select, button { font:inherit; color:inherit; background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:4px 8px; }
|
|
26
|
+
button { cursor:pointer; }
|
|
27
|
+
main { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); min-height:calc(100vh - 52px); }
|
|
28
|
+
@media (max-width: 900px) { main { grid-template-columns:1fr; } }
|
|
29
|
+
section { padding:8px 16px; overflow:auto; }
|
|
30
|
+
#detail { border-left:1px solid var(--line); }
|
|
31
|
+
table { width:100%; border-collapse:collapse; }
|
|
32
|
+
th, td { text-align:left; padding:5px 6px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
33
|
+
th { color:var(--muted); font-weight:500; }
|
|
34
|
+
tbody tr.row { cursor:pointer; } tbody tr.row:hover { background:var(--hover); } tr.sel { background:var(--sel); }
|
|
35
|
+
.mono { font-family: ui-monospace, monospace; font-size:12px; word-break:break-all; }
|
|
36
|
+
.st { font-weight:600; } .completed { color:var(--ok); } .failed, .terminated { color:var(--bad); }
|
|
37
|
+
.running { color:var(--run); } .timed-out { color:var(--warn); }
|
|
38
|
+
.err { color:var(--bad); white-space:pre-wrap; }
|
|
39
|
+
.muted { color:var(--muted); }
|
|
40
|
+
pre { margin:4px 0; padding:8px; background:var(--hover); border-radius:6px; overflow:auto; max-height:300px; font-size:12px; }
|
|
41
|
+
details > summary { cursor:pointer; color:var(--muted); }
|
|
42
|
+
a { color:var(--run); }
|
|
43
|
+
h2 { font-size:15px; margin:8px 0; word-break:break-all; }
|
|
44
|
+
dl { display:grid; grid-template-columns:max-content 1fr; gap:2px 12px; margin:0 0 8px; }
|
|
45
|
+
dt { color:var(--muted); }
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<header>
|
|
50
|
+
<h1>Процессы</h1>
|
|
51
|
+
<input id="app" placeholder="модуль" size="10">
|
|
52
|
+
<input id="flow" placeholder="процесс" size="14">
|
|
53
|
+
<select id="status"><option value="">все статусы</option><option>running</option><option>completed</option>
|
|
54
|
+
<option>failed</option><option>terminated</option><option>timed-out</option></select>
|
|
55
|
+
<input id="idPrefix" placeholder="начало id" size="16">
|
|
56
|
+
<select id="limit"><option>50</option><option selected>100</option><option>500</option></select>
|
|
57
|
+
<button id="refresh">Обновить</button>
|
|
58
|
+
<span id="msg" class="muted"></span>
|
|
59
|
+
</header>
|
|
60
|
+
<main>
|
|
61
|
+
<section><table><thead><tr><th>id</th><th>процесс</th><th>статус</th><th>начат</th><th>длит.</th></tr></thead>
|
|
62
|
+
<tbody id="list"></tbody></table></section>
|
|
63
|
+
<section id="detail"><p class="muted">Выбери процесс в списке.</p></section>
|
|
64
|
+
</main>
|
|
65
|
+
<script>
|
|
66
|
+
const $ = (id) => document.getElementById(id);
|
|
67
|
+
// Адрес API — от адреса страницы: под любым префиксом, со слэшем в конце и без.
|
|
68
|
+
const ROOT = location.pathname.endsWith("/") ? location.pathname : location.pathname + "/";
|
|
69
|
+
// Ответ на устаревший запрос не перерисовывает экран: последний клик побеждает.
|
|
70
|
+
let listSeq = 0, detailSeq = 0;
|
|
71
|
+
const FIELDS = ["app", "flow", "status", "idPrefix", "limit"];
|
|
72
|
+
function el(tag, attrs, ...kids) {
|
|
73
|
+
const e = document.createElement(tag);
|
|
74
|
+
for (const [k, v] of Object.entries(attrs || {})) if (v != null) e.setAttribute(k, v);
|
|
75
|
+
for (const k of kids) if (k != null) e.append(k instanceof Node ? k : String(k));
|
|
76
|
+
return e;
|
|
77
|
+
}
|
|
78
|
+
const time = (s) => s ? new Date(s).toLocaleString() : "";
|
|
79
|
+
function dur(a, b) {
|
|
80
|
+
if (!a) return "";
|
|
81
|
+
const ms = (b ? new Date(b) : new Date()) - new Date(a);
|
|
82
|
+
if (ms < 1000) return ms + " мс";
|
|
83
|
+
if (ms < 60000) return (ms / 1000).toFixed(1) + " с";
|
|
84
|
+
if (ms < 3600000) return Math.round(ms / 60000) + " мин";
|
|
85
|
+
return (ms / 3600000).toFixed(1) + " ч";
|
|
86
|
+
}
|
|
87
|
+
const pretty = (v) => JSON.stringify(v, null, 2);
|
|
88
|
+
async function get(path) {
|
|
89
|
+
const r = await fetch(path, { headers: { accept: "application/json" } });
|
|
90
|
+
const body = await r.json().catch(() => ({ error: r.statusText }));
|
|
91
|
+
if (!r.ok) throw new Error(body.error || r.statusText);
|
|
92
|
+
return body;
|
|
93
|
+
}
|
|
94
|
+
function readHash() { return new URLSearchParams(location.hash.slice(1)); }
|
|
95
|
+
function writeHash(id) {
|
|
96
|
+
const q = new URLSearchParams();
|
|
97
|
+
for (const f of FIELDS) if ($(f).value) q.set(f, $(f).value);
|
|
98
|
+
if (id) q.set("id", id);
|
|
99
|
+
history.replaceState(null, "", "#" + q);
|
|
100
|
+
}
|
|
101
|
+
let selected = readHash().get("id");
|
|
102
|
+
async function loadList() {
|
|
103
|
+
const q = new URLSearchParams();
|
|
104
|
+
for (const f of FIELDS) if ($(f).value) q.set(f, $(f).value);
|
|
105
|
+
$("msg").textContent = "загрузка…";
|
|
106
|
+
const seq = ++listSeq;
|
|
107
|
+
try {
|
|
108
|
+
const rows = await get(ROOT + "api/flows?" + q);
|
|
109
|
+
if (seq !== listSeq) return;
|
|
110
|
+
const body = $("list");
|
|
111
|
+
body.replaceChildren(...rows.map((f) => {
|
|
112
|
+
const tr = el("tr", { class: "row" + (f.id === selected ? " sel" : "") },
|
|
113
|
+
el("td", { class: "mono" }, f.id), el("td", {}, f.flow.app + " / " + f.flow.flow),
|
|
114
|
+
el("td", { class: "st " + f.status }, f.status), el("td", {}, time(f.startedAt)), el("td", {}, dur(f.startedAt, f.closedAt)));
|
|
115
|
+
tr.onclick = () => select(f.id);
|
|
116
|
+
return tr;
|
|
117
|
+
}));
|
|
118
|
+
$("msg").textContent = rows.length + " шт.";
|
|
119
|
+
} catch (e) { if (seq === listSeq) $("msg").textContent = "ошибка: " + e.message; }
|
|
120
|
+
}
|
|
121
|
+
const link = (id) => { const a = el("a", { href: "#" }, id); a.onclick = (ev) => { ev.preventDefault(); select(id); }; return a; };
|
|
122
|
+
function value(label, v) {
|
|
123
|
+
if (v === undefined) return null;
|
|
124
|
+
return el("details", { open: "" }, el("summary", {}, label), el("pre", {}, pretty(v)));
|
|
125
|
+
}
|
|
126
|
+
async function select(id) {
|
|
127
|
+
selected = id;
|
|
128
|
+
writeHash(id);
|
|
129
|
+
for (const tr of $("list").children) tr.classList.toggle("sel", tr.firstChild.textContent === id);
|
|
130
|
+
const box = $("detail");
|
|
131
|
+
box.replaceChildren(el("p", { class: "muted" }, "загрузка…"));
|
|
132
|
+
const seq = ++detailSeq;
|
|
133
|
+
try {
|
|
134
|
+
const d = await get(ROOT + "api/flows/" + encodeURIComponent(id));
|
|
135
|
+
if (seq !== detailSeq) return;
|
|
136
|
+
const steps = el("tbody", {}, ...d.steps.map((s, i) => el("tr", {},
|
|
137
|
+
el("td", { class: "muted" }, i + 1),
|
|
138
|
+
el("td", {}, s.name, s.childId ? el("div", { class: "mono" }, "→ ", link(s.childId)) : null),
|
|
139
|
+
el("td", {}, time(s.startedAt)), el("td", {}, dur(s.startedAt, s.closedAt || d.closedAt)),
|
|
140
|
+
el("td", {}, s.error ? el("div", { class: "err" }, s.error) : null,
|
|
141
|
+
s.output !== undefined ? el("details", {}, el("summary", {}, "итог"), el("pre", {}, pretty(s.output))) : null))));
|
|
142
|
+
box.replaceChildren(...[
|
|
143
|
+
el("h2", { class: "mono" }, d.id),
|
|
144
|
+
el("dl", {},
|
|
145
|
+
el("dt", {}, "процесс"), el("dd", {}, d.flow.app + " / " + d.flow.flow),
|
|
146
|
+
el("dt", {}, "статус"), el("dd", { class: "st " + d.status }, d.status),
|
|
147
|
+
el("dt", {}, "начат"), el("dd", {}, time(d.startedAt)),
|
|
148
|
+
el("dt", {}, "закончен"), el("dd", {}, d.closedAt ? time(d.closedAt) + " (" + dur(d.startedAt, d.closedAt) + ")" : "—"),
|
|
149
|
+
d.parentId ? el("dt", {}, "родитель") : null, d.parentId ? el("dd", { class: "mono" }, link(d.parentId)) : null),
|
|
150
|
+
d.error ? el("div", { class: "err" }, d.error) : null,
|
|
151
|
+
value("вход", d.input), value("итог", d.output),
|
|
152
|
+
el("h3", {}, "Шаги (" + d.steps.length + ")"),
|
|
153
|
+
el("table", {}, el("thead", {}, el("tr", {}, el("th", {}, "#"), el("th", {}, "шаг"), el("th", {}, "начат"),
|
|
154
|
+
el("th", {}, "длит."), el("th", {}, "итог"))), steps)].filter(Boolean));
|
|
155
|
+
} catch (e) { if (seq === detailSeq) box.replaceChildren(el("p", { class: "err" }, e.message)); }
|
|
156
|
+
}
|
|
157
|
+
const init = readHash();
|
|
158
|
+
for (const f of FIELDS) if (init.get(f)) $(f).value = init.get(f);
|
|
159
|
+
for (const f of FIELDS) $(f).addEventListener("change", () => { writeHash(selected); loadList(); });
|
|
160
|
+
$("refresh").onclick = () => { loadList(); if (selected) select(selected); };
|
|
161
|
+
loadList();
|
|
162
|
+
if (selected) select(selected);
|
|
163
|
+
</script>
|
|
164
|
+
</body>
|
|
165
|
+
</html>
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
// src/index.ts
|
|
169
|
+
var MAX_LIMIT = 1000;
|
|
170
|
+
|
|
171
|
+
class BadQuery extends Error {
|
|
172
|
+
}
|
|
173
|
+
function plain(v, ancestors = []) {
|
|
174
|
+
if (typeof v === "bigint")
|
|
175
|
+
return v.toString();
|
|
176
|
+
if (!v || typeof v !== "object")
|
|
177
|
+
return v;
|
|
178
|
+
if (ancestors.includes(v))
|
|
179
|
+
return "[цикл]";
|
|
180
|
+
const up = [...ancestors, v];
|
|
181
|
+
if (v instanceof Date)
|
|
182
|
+
return v.toJSON();
|
|
183
|
+
if (v instanceof Error)
|
|
184
|
+
return plain({ ...v, name: v.name, message: v.message }, up);
|
|
185
|
+
if (v instanceof Map)
|
|
186
|
+
return plain(Object.fromEntries([...v].map(([k, x]) => [String(k), x])), up);
|
|
187
|
+
if (v instanceof Set)
|
|
188
|
+
return plain([...v], up);
|
|
189
|
+
if (typeof v.toJSON === "function")
|
|
190
|
+
return plain(v.toJSON(), up);
|
|
191
|
+
if (Array.isArray(v))
|
|
192
|
+
return v.map((x) => plain(x, up));
|
|
193
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x, up)]));
|
|
194
|
+
}
|
|
195
|
+
var toJson = (v) => JSON.stringify(plain(v));
|
|
196
|
+
var json = (body, status = 200) => new Response(toJson(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
|
|
197
|
+
function listQuery(q) {
|
|
198
|
+
const o = {};
|
|
199
|
+
const idPrefix = q.get("idPrefix");
|
|
200
|
+
if (idPrefix)
|
|
201
|
+
o.idPrefix = idPrefix;
|
|
202
|
+
const app = q.get("app");
|
|
203
|
+
const flow = q.get("flow");
|
|
204
|
+
if (flow && !app)
|
|
205
|
+
throw new BadQuery("flow без app: процесс ищется в модуле");
|
|
206
|
+
if (app)
|
|
207
|
+
o.app = app;
|
|
208
|
+
if (app && flow)
|
|
209
|
+
o.flow = { app, flow };
|
|
210
|
+
const status = q.get("status");
|
|
211
|
+
if (status) {
|
|
212
|
+
if (!FLOW_STATUSES.includes(status))
|
|
213
|
+
throw new BadQuery(`нет статуса ${status}`);
|
|
214
|
+
o.status = status;
|
|
215
|
+
}
|
|
216
|
+
const since = q.get("since");
|
|
217
|
+
if (since) {
|
|
218
|
+
const d = new Date(since);
|
|
219
|
+
if (Number.isNaN(d.getTime()))
|
|
220
|
+
throw new BadQuery(`since — не дата: ${since}`);
|
|
221
|
+
o.since = d;
|
|
222
|
+
}
|
|
223
|
+
const limit = q.get("limit");
|
|
224
|
+
if (limit)
|
|
225
|
+
o.limit = Number(limit);
|
|
226
|
+
if ((o.limit ?? 0) > MAX_LIMIT)
|
|
227
|
+
throw new BadQuery(`limit — не больше ${MAX_LIMIT}`);
|
|
228
|
+
try {
|
|
229
|
+
checkList(o);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
throw new BadQuery(e.message);
|
|
232
|
+
}
|
|
233
|
+
return o;
|
|
234
|
+
}
|
|
235
|
+
function flowViewer(client, opts = {}) {
|
|
236
|
+
const base = (opts.basePath ?? "").replace(/\/+$/, "");
|
|
237
|
+
return async (req) => {
|
|
238
|
+
const url = new URL(req.url);
|
|
239
|
+
if (req.method !== "GET")
|
|
240
|
+
return json({ error: "только чтение: GET" }, 405);
|
|
241
|
+
let path = url.pathname;
|
|
242
|
+
if (base && (path === base || path.startsWith(`${base}/`)))
|
|
243
|
+
path = path.slice(base.length);
|
|
244
|
+
try {
|
|
245
|
+
if (path === "/" || path === "")
|
|
246
|
+
return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
247
|
+
if (path === "/api/flows")
|
|
248
|
+
return json(await client.list(listQuery(url.searchParams)));
|
|
249
|
+
if (path.startsWith("/api/flows/")) {
|
|
250
|
+
let id;
|
|
251
|
+
try {
|
|
252
|
+
id = decodeURIComponent(path.slice("/api/flows/".length));
|
|
253
|
+
} catch {
|
|
254
|
+
throw new BadQuery("id: неверная кодировка");
|
|
255
|
+
}
|
|
256
|
+
if (!id)
|
|
257
|
+
throw new BadQuery("нет id");
|
|
258
|
+
return json(await client.inspect(id));
|
|
259
|
+
}
|
|
260
|
+
return json({ error: "нет такого пути" }, 404);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
if (e instanceof BadQuery)
|
|
263
|
+
return json({ error: e.message }, 400);
|
|
264
|
+
if (isFlowError(e) && e.reason === "not-found")
|
|
265
|
+
return json({ error: e.message }, 404);
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/cli.ts
|
|
272
|
+
var args = process.argv.slice(2);
|
|
273
|
+
var opt = (name, def) => {
|
|
274
|
+
const i = args.indexOf(`--${name}`);
|
|
275
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : def;
|
|
276
|
+
};
|
|
277
|
+
var engine = opt("engine", process.env.IWF_ENGINE ?? "dbos");
|
|
278
|
+
var port = Number(opt("port", "8233"));
|
|
279
|
+
var host = opt("host", "127.0.0.1");
|
|
280
|
+
async function connect() {
|
|
281
|
+
if (engine === "dbos")
|
|
282
|
+
return (await import("@iworkflow/dbos")).dbosClient();
|
|
283
|
+
if (engine === "temporal")
|
|
284
|
+
return (await import("@iworkflow/temporal")).temporalClient();
|
|
285
|
+
throw new Error(`--engine: dbos или temporal, а не ${engine}`);
|
|
286
|
+
}
|
|
287
|
+
var client = await connect();
|
|
288
|
+
var handle = flowViewer(client);
|
|
289
|
+
var LOOPBACK = ["127.0.0.1", "localhost", "[::1]", "::1"];
|
|
290
|
+
var local = LOOPBACK.includes(host);
|
|
291
|
+
var allowed = new Set(LOOPBACK.flatMap((h) => [`${h}:${port}`, ...port === 80 ? [h] : []]));
|
|
292
|
+
var server = createServer(async (req, res) => {
|
|
293
|
+
if (local && !allowed.has(req.headers.host ?? "")) {
|
|
294
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("чужой Host");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const r = await handle(new Request(new URL(req.url ?? "/", `http://${req.headers.host}`).href, { method: req.method }));
|
|
299
|
+
const headers = {};
|
|
300
|
+
r.headers.forEach((v, k) => headers[k] = v);
|
|
301
|
+
res.writeHead(r.status, headers);
|
|
302
|
+
res.end(Buffer.from(await r.arrayBuffer()));
|
|
303
|
+
} catch (e) {
|
|
304
|
+
console.error(e);
|
|
305
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end(String(e?.message ?? e));
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
server.listen(port, host, () => {
|
|
309
|
+
console.log(`iworkflow-viewer (${engine}): http://${host}:${port}/`);
|
|
310
|
+
if (!local)
|
|
311
|
+
console.warn("внимание: адрес не локальный, входа у просмотрщика нет");
|
|
312
|
+
});
|
|
313
|
+
var stop = () => server.close(() => void client.close().then(() => process.exit(0)));
|
|
314
|
+
process.on("SIGINT", stop);
|
|
315
|
+
process.on("SIGTERM", stop);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type EngineClient, type ListOptions } from "@iworkflow/core";
|
|
2
|
+
export interface ViewerOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Префикс пути, если сервер его не срезает (Bun.serve), например "/admin/flows". Elysia `mount`
|
|
5
|
+
* срезает сам — тогда не нужен; путь без префикса обработчик тоже принимает.
|
|
6
|
+
*/
|
|
7
|
+
basePath?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Больше за раз не отдаём: list DBOS листает базу, Temporal читает всё совпавшее. */
|
|
10
|
+
export declare const MAX_LIMIT = 1000;
|
|
11
|
+
/** Неверный запрос — 400; прочие ошибки (в том числе RangeError движка) — не вина запроса. */
|
|
12
|
+
export declare class BadQuery extends Error {
|
|
13
|
+
}
|
|
14
|
+
/** Отбор списка из строки запроса; неверное значение — BadQuery (400). */
|
|
15
|
+
export declare function listQuery(q: URLSearchParams): ListOptions;
|
|
16
|
+
/** Обработчик fetch: GET / — страница, /api/flows — список, /api/flows/<id> — процесс (под basePath, если задан). */
|
|
17
|
+
export declare function flowViewer(client: EngineClient, opts?: ViewerOptions): (req: Request) => Promise<Response>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { checkList, FLOW_STATUSES, isFlowError } from "@iworkflow/core";
|
|
3
|
+
|
|
4
|
+
// src/page.ts
|
|
5
|
+
var PAGE = `<!doctype html>
|
|
6
|
+
<html lang="ru">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="utf-8">
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
10
|
+
<title>Процессы iworkflow</title>
|
|
11
|
+
<style>
|
|
12
|
+
:root { --bg:#fff; --fg:#1b1f24; --muted:#636c76; --line:#d8dee4; --hover:#f3f5f7; --sel:#e6efff;
|
|
13
|
+
--ok:#1a7f37; --bad:#cf222e; --run:#0969da; --warn:#9a6700; font: 14px/1.4 system-ui, sans-serif; }
|
|
14
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#0d1117; --fg:#e6edf3; --muted:#8d96a0; --line:#30363d;
|
|
15
|
+
--hover:#161b22; --sel:#1c2a40; --ok:#3fb950; --bad:#f85149; --run:#58a6ff; --warn:#d29922; } }
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
body { margin:0; background:var(--bg); color:var(--fg); }
|
|
18
|
+
header { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:10px 16px; border-bottom:1px solid var(--line); }
|
|
19
|
+
header h1 { font-size:16px; margin:0 12px 0 0; }
|
|
20
|
+
input, select, button { font:inherit; color:inherit; background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:4px 8px; }
|
|
21
|
+
button { cursor:pointer; }
|
|
22
|
+
main { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); min-height:calc(100vh - 52px); }
|
|
23
|
+
@media (max-width: 900px) { main { grid-template-columns:1fr; } }
|
|
24
|
+
section { padding:8px 16px; overflow:auto; }
|
|
25
|
+
#detail { border-left:1px solid var(--line); }
|
|
26
|
+
table { width:100%; border-collapse:collapse; }
|
|
27
|
+
th, td { text-align:left; padding:5px 6px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
28
|
+
th { color:var(--muted); font-weight:500; }
|
|
29
|
+
tbody tr.row { cursor:pointer; } tbody tr.row:hover { background:var(--hover); } tr.sel { background:var(--sel); }
|
|
30
|
+
.mono { font-family: ui-monospace, monospace; font-size:12px; word-break:break-all; }
|
|
31
|
+
.st { font-weight:600; } .completed { color:var(--ok); } .failed, .terminated { color:var(--bad); }
|
|
32
|
+
.running { color:var(--run); } .timed-out { color:var(--warn); }
|
|
33
|
+
.err { color:var(--bad); white-space:pre-wrap; }
|
|
34
|
+
.muted { color:var(--muted); }
|
|
35
|
+
pre { margin:4px 0; padding:8px; background:var(--hover); border-radius:6px; overflow:auto; max-height:300px; font-size:12px; }
|
|
36
|
+
details > summary { cursor:pointer; color:var(--muted); }
|
|
37
|
+
a { color:var(--run); }
|
|
38
|
+
h2 { font-size:15px; margin:8px 0; word-break:break-all; }
|
|
39
|
+
dl { display:grid; grid-template-columns:max-content 1fr; gap:2px 12px; margin:0 0 8px; }
|
|
40
|
+
dt { color:var(--muted); }
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<header>
|
|
45
|
+
<h1>Процессы</h1>
|
|
46
|
+
<input id="app" placeholder="модуль" size="10">
|
|
47
|
+
<input id="flow" placeholder="процесс" size="14">
|
|
48
|
+
<select id="status"><option value="">все статусы</option><option>running</option><option>completed</option>
|
|
49
|
+
<option>failed</option><option>terminated</option><option>timed-out</option></select>
|
|
50
|
+
<input id="idPrefix" placeholder="начало id" size="16">
|
|
51
|
+
<select id="limit"><option>50</option><option selected>100</option><option>500</option></select>
|
|
52
|
+
<button id="refresh">Обновить</button>
|
|
53
|
+
<span id="msg" class="muted"></span>
|
|
54
|
+
</header>
|
|
55
|
+
<main>
|
|
56
|
+
<section><table><thead><tr><th>id</th><th>процесс</th><th>статус</th><th>начат</th><th>длит.</th></tr></thead>
|
|
57
|
+
<tbody id="list"></tbody></table></section>
|
|
58
|
+
<section id="detail"><p class="muted">Выбери процесс в списке.</p></section>
|
|
59
|
+
</main>
|
|
60
|
+
<script>
|
|
61
|
+
const $ = (id) => document.getElementById(id);
|
|
62
|
+
// Адрес API — от адреса страницы: под любым префиксом, со слэшем в конце и без.
|
|
63
|
+
const ROOT = location.pathname.endsWith("/") ? location.pathname : location.pathname + "/";
|
|
64
|
+
// Ответ на устаревший запрос не перерисовывает экран: последний клик побеждает.
|
|
65
|
+
let listSeq = 0, detailSeq = 0;
|
|
66
|
+
const FIELDS = ["app", "flow", "status", "idPrefix", "limit"];
|
|
67
|
+
function el(tag, attrs, ...kids) {
|
|
68
|
+
const e = document.createElement(tag);
|
|
69
|
+
for (const [k, v] of Object.entries(attrs || {})) if (v != null) e.setAttribute(k, v);
|
|
70
|
+
for (const k of kids) if (k != null) e.append(k instanceof Node ? k : String(k));
|
|
71
|
+
return e;
|
|
72
|
+
}
|
|
73
|
+
const time = (s) => s ? new Date(s).toLocaleString() : "";
|
|
74
|
+
function dur(a, b) {
|
|
75
|
+
if (!a) return "";
|
|
76
|
+
const ms = (b ? new Date(b) : new Date()) - new Date(a);
|
|
77
|
+
if (ms < 1000) return ms + " мс";
|
|
78
|
+
if (ms < 60000) return (ms / 1000).toFixed(1) + " с";
|
|
79
|
+
if (ms < 3600000) return Math.round(ms / 60000) + " мин";
|
|
80
|
+
return (ms / 3600000).toFixed(1) + " ч";
|
|
81
|
+
}
|
|
82
|
+
const pretty = (v) => JSON.stringify(v, null, 2);
|
|
83
|
+
async function get(path) {
|
|
84
|
+
const r = await fetch(path, { headers: { accept: "application/json" } });
|
|
85
|
+
const body = await r.json().catch(() => ({ error: r.statusText }));
|
|
86
|
+
if (!r.ok) throw new Error(body.error || r.statusText);
|
|
87
|
+
return body;
|
|
88
|
+
}
|
|
89
|
+
function readHash() { return new URLSearchParams(location.hash.slice(1)); }
|
|
90
|
+
function writeHash(id) {
|
|
91
|
+
const q = new URLSearchParams();
|
|
92
|
+
for (const f of FIELDS) if ($(f).value) q.set(f, $(f).value);
|
|
93
|
+
if (id) q.set("id", id);
|
|
94
|
+
history.replaceState(null, "", "#" + q);
|
|
95
|
+
}
|
|
96
|
+
let selected = readHash().get("id");
|
|
97
|
+
async function loadList() {
|
|
98
|
+
const q = new URLSearchParams();
|
|
99
|
+
for (const f of FIELDS) if ($(f).value) q.set(f, $(f).value);
|
|
100
|
+
$("msg").textContent = "загрузка…";
|
|
101
|
+
const seq = ++listSeq;
|
|
102
|
+
try {
|
|
103
|
+
const rows = await get(ROOT + "api/flows?" + q);
|
|
104
|
+
if (seq !== listSeq) return;
|
|
105
|
+
const body = $("list");
|
|
106
|
+
body.replaceChildren(...rows.map((f) => {
|
|
107
|
+
const tr = el("tr", { class: "row" + (f.id === selected ? " sel" : "") },
|
|
108
|
+
el("td", { class: "mono" }, f.id), el("td", {}, f.flow.app + " / " + f.flow.flow),
|
|
109
|
+
el("td", { class: "st " + f.status }, f.status), el("td", {}, time(f.startedAt)), el("td", {}, dur(f.startedAt, f.closedAt)));
|
|
110
|
+
tr.onclick = () => select(f.id);
|
|
111
|
+
return tr;
|
|
112
|
+
}));
|
|
113
|
+
$("msg").textContent = rows.length + " шт.";
|
|
114
|
+
} catch (e) { if (seq === listSeq) $("msg").textContent = "ошибка: " + e.message; }
|
|
115
|
+
}
|
|
116
|
+
const link = (id) => { const a = el("a", { href: "#" }, id); a.onclick = (ev) => { ev.preventDefault(); select(id); }; return a; };
|
|
117
|
+
function value(label, v) {
|
|
118
|
+
if (v === undefined) return null;
|
|
119
|
+
return el("details", { open: "" }, el("summary", {}, label), el("pre", {}, pretty(v)));
|
|
120
|
+
}
|
|
121
|
+
async function select(id) {
|
|
122
|
+
selected = id;
|
|
123
|
+
writeHash(id);
|
|
124
|
+
for (const tr of $("list").children) tr.classList.toggle("sel", tr.firstChild.textContent === id);
|
|
125
|
+
const box = $("detail");
|
|
126
|
+
box.replaceChildren(el("p", { class: "muted" }, "загрузка…"));
|
|
127
|
+
const seq = ++detailSeq;
|
|
128
|
+
try {
|
|
129
|
+
const d = await get(ROOT + "api/flows/" + encodeURIComponent(id));
|
|
130
|
+
if (seq !== detailSeq) return;
|
|
131
|
+
const steps = el("tbody", {}, ...d.steps.map((s, i) => el("tr", {},
|
|
132
|
+
el("td", { class: "muted" }, i + 1),
|
|
133
|
+
el("td", {}, s.name, s.childId ? el("div", { class: "mono" }, "→ ", link(s.childId)) : null),
|
|
134
|
+
el("td", {}, time(s.startedAt)), el("td", {}, dur(s.startedAt, s.closedAt || d.closedAt)),
|
|
135
|
+
el("td", {}, s.error ? el("div", { class: "err" }, s.error) : null,
|
|
136
|
+
s.output !== undefined ? el("details", {}, el("summary", {}, "итог"), el("pre", {}, pretty(s.output))) : null))));
|
|
137
|
+
box.replaceChildren(...[
|
|
138
|
+
el("h2", { class: "mono" }, d.id),
|
|
139
|
+
el("dl", {},
|
|
140
|
+
el("dt", {}, "процесс"), el("dd", {}, d.flow.app + " / " + d.flow.flow),
|
|
141
|
+
el("dt", {}, "статус"), el("dd", { class: "st " + d.status }, d.status),
|
|
142
|
+
el("dt", {}, "начат"), el("dd", {}, time(d.startedAt)),
|
|
143
|
+
el("dt", {}, "закончен"), el("dd", {}, d.closedAt ? time(d.closedAt) + " (" + dur(d.startedAt, d.closedAt) + ")" : "—"),
|
|
144
|
+
d.parentId ? el("dt", {}, "родитель") : null, d.parentId ? el("dd", { class: "mono" }, link(d.parentId)) : null),
|
|
145
|
+
d.error ? el("div", { class: "err" }, d.error) : null,
|
|
146
|
+
value("вход", d.input), value("итог", d.output),
|
|
147
|
+
el("h3", {}, "Шаги (" + d.steps.length + ")"),
|
|
148
|
+
el("table", {}, el("thead", {}, el("tr", {}, el("th", {}, "#"), el("th", {}, "шаг"), el("th", {}, "начат"),
|
|
149
|
+
el("th", {}, "длит."), el("th", {}, "итог"))), steps)].filter(Boolean));
|
|
150
|
+
} catch (e) { if (seq === detailSeq) box.replaceChildren(el("p", { class: "err" }, e.message)); }
|
|
151
|
+
}
|
|
152
|
+
const init = readHash();
|
|
153
|
+
for (const f of FIELDS) if (init.get(f)) $(f).value = init.get(f);
|
|
154
|
+
for (const f of FIELDS) $(f).addEventListener("change", () => { writeHash(selected); loadList(); });
|
|
155
|
+
$("refresh").onclick = () => { loadList(); if (selected) select(selected); };
|
|
156
|
+
loadList();
|
|
157
|
+
if (selected) select(selected);
|
|
158
|
+
</script>
|
|
159
|
+
</body>
|
|
160
|
+
</html>
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
// src/index.ts
|
|
164
|
+
var MAX_LIMIT = 1000;
|
|
165
|
+
|
|
166
|
+
class BadQuery extends Error {
|
|
167
|
+
}
|
|
168
|
+
function plain(v, ancestors = []) {
|
|
169
|
+
if (typeof v === "bigint")
|
|
170
|
+
return v.toString();
|
|
171
|
+
if (!v || typeof v !== "object")
|
|
172
|
+
return v;
|
|
173
|
+
if (ancestors.includes(v))
|
|
174
|
+
return "[цикл]";
|
|
175
|
+
const up = [...ancestors, v];
|
|
176
|
+
if (v instanceof Date)
|
|
177
|
+
return v.toJSON();
|
|
178
|
+
if (v instanceof Error)
|
|
179
|
+
return plain({ ...v, name: v.name, message: v.message }, up);
|
|
180
|
+
if (v instanceof Map)
|
|
181
|
+
return plain(Object.fromEntries([...v].map(([k, x]) => [String(k), x])), up);
|
|
182
|
+
if (v instanceof Set)
|
|
183
|
+
return plain([...v], up);
|
|
184
|
+
if (typeof v.toJSON === "function")
|
|
185
|
+
return plain(v.toJSON(), up);
|
|
186
|
+
if (Array.isArray(v))
|
|
187
|
+
return v.map((x) => plain(x, up));
|
|
188
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x, up)]));
|
|
189
|
+
}
|
|
190
|
+
var toJson = (v) => JSON.stringify(plain(v));
|
|
191
|
+
var json = (body, status = 200) => new Response(toJson(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
|
|
192
|
+
function listQuery(q) {
|
|
193
|
+
const o = {};
|
|
194
|
+
const idPrefix = q.get("idPrefix");
|
|
195
|
+
if (idPrefix)
|
|
196
|
+
o.idPrefix = idPrefix;
|
|
197
|
+
const app = q.get("app");
|
|
198
|
+
const flow = q.get("flow");
|
|
199
|
+
if (flow && !app)
|
|
200
|
+
throw new BadQuery("flow без app: процесс ищется в модуле");
|
|
201
|
+
if (app)
|
|
202
|
+
o.app = app;
|
|
203
|
+
if (app && flow)
|
|
204
|
+
o.flow = { app, flow };
|
|
205
|
+
const status = q.get("status");
|
|
206
|
+
if (status) {
|
|
207
|
+
if (!FLOW_STATUSES.includes(status))
|
|
208
|
+
throw new BadQuery(`нет статуса ${status}`);
|
|
209
|
+
o.status = status;
|
|
210
|
+
}
|
|
211
|
+
const since = q.get("since");
|
|
212
|
+
if (since) {
|
|
213
|
+
const d = new Date(since);
|
|
214
|
+
if (Number.isNaN(d.getTime()))
|
|
215
|
+
throw new BadQuery(`since — не дата: ${since}`);
|
|
216
|
+
o.since = d;
|
|
217
|
+
}
|
|
218
|
+
const limit = q.get("limit");
|
|
219
|
+
if (limit)
|
|
220
|
+
o.limit = Number(limit);
|
|
221
|
+
if ((o.limit ?? 0) > MAX_LIMIT)
|
|
222
|
+
throw new BadQuery(`limit — не больше ${MAX_LIMIT}`);
|
|
223
|
+
try {
|
|
224
|
+
checkList(o);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
throw new BadQuery(e.message);
|
|
227
|
+
}
|
|
228
|
+
return o;
|
|
229
|
+
}
|
|
230
|
+
function flowViewer(client, opts = {}) {
|
|
231
|
+
const base = (opts.basePath ?? "").replace(/\/+$/, "");
|
|
232
|
+
return async (req) => {
|
|
233
|
+
const url = new URL(req.url);
|
|
234
|
+
if (req.method !== "GET")
|
|
235
|
+
return json({ error: "только чтение: GET" }, 405);
|
|
236
|
+
let path = url.pathname;
|
|
237
|
+
if (base && (path === base || path.startsWith(`${base}/`)))
|
|
238
|
+
path = path.slice(base.length);
|
|
239
|
+
try {
|
|
240
|
+
if (path === "/" || path === "")
|
|
241
|
+
return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
242
|
+
if (path === "/api/flows")
|
|
243
|
+
return json(await client.list(listQuery(url.searchParams)));
|
|
244
|
+
if (path.startsWith("/api/flows/")) {
|
|
245
|
+
let id;
|
|
246
|
+
try {
|
|
247
|
+
id = decodeURIComponent(path.slice("/api/flows/".length));
|
|
248
|
+
} catch {
|
|
249
|
+
throw new BadQuery("id: неверная кодировка");
|
|
250
|
+
}
|
|
251
|
+
if (!id)
|
|
252
|
+
throw new BadQuery("нет id");
|
|
253
|
+
return json(await client.inspect(id));
|
|
254
|
+
}
|
|
255
|
+
return json({ error: "нет такого пути" }, 404);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
if (e instanceof BadQuery)
|
|
258
|
+
return json({ error: e.message }, 400);
|
|
259
|
+
if (isFlowError(e) && e.reason === "not-found")
|
|
260
|
+
return json({ error: e.message }, 404);
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export {
|
|
266
|
+
BadQuery,
|
|
267
|
+
MAX_LIMIT,
|
|
268
|
+
flowViewer,
|
|
269
|
+
listQuery
|
|
270
|
+
};
|
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const PAGE = "<!doctype html>\n<html lang=\"ru\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>\u041F\u0440\u043E\u0446\u0435\u0441\u0441\u044B iworkflow</title>\n<style>\n:root { --bg:#fff; --fg:#1b1f24; --muted:#636c76; --line:#d8dee4; --hover:#f3f5f7; --sel:#e6efff;\n --ok:#1a7f37; --bad:#cf222e; --run:#0969da; --warn:#9a6700; font: 14px/1.4 system-ui, sans-serif; }\n@media (prefers-color-scheme: dark) { :root { --bg:#0d1117; --fg:#e6edf3; --muted:#8d96a0; --line:#30363d;\n --hover:#161b22; --sel:#1c2a40; --ok:#3fb950; --bad:#f85149; --run:#58a6ff; --warn:#d29922; } }\n* { box-sizing: border-box; }\nbody { margin:0; background:var(--bg); color:var(--fg); }\nheader { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:10px 16px; border-bottom:1px solid var(--line); }\nheader h1 { font-size:16px; margin:0 12px 0 0; }\ninput, select, button { font:inherit; color:inherit; background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:4px 8px; }\nbutton { cursor:pointer; }\nmain { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); min-height:calc(100vh - 52px); }\n@media (max-width: 900px) { main { grid-template-columns:1fr; } }\nsection { padding:8px 16px; overflow:auto; }\n#detail { border-left:1px solid var(--line); }\ntable { width:100%; border-collapse:collapse; }\nth, td { text-align:left; padding:5px 6px; border-bottom:1px solid var(--line); vertical-align:top; }\nth { color:var(--muted); font-weight:500; }\ntbody tr.row { cursor:pointer; } tbody tr.row:hover { background:var(--hover); } tr.sel { background:var(--sel); }\n.mono { font-family: ui-monospace, monospace; font-size:12px; word-break:break-all; }\n.st { font-weight:600; } .completed { color:var(--ok); } .failed, .terminated { color:var(--bad); }\n.running { color:var(--run); } .timed-out { color:var(--warn); }\n.err { color:var(--bad); white-space:pre-wrap; }\n.muted { color:var(--muted); }\npre { margin:4px 0; padding:8px; background:var(--hover); border-radius:6px; overflow:auto; max-height:300px; font-size:12px; }\ndetails > summary { cursor:pointer; color:var(--muted); }\na { color:var(--run); }\nh2 { font-size:15px; margin:8px 0; word-break:break-all; }\ndl { display:grid; grid-template-columns:max-content 1fr; gap:2px 12px; margin:0 0 8px; }\ndt { color:var(--muted); }\n</style>\n</head>\n<body>\n<header>\n <h1>\u041F\u0440\u043E\u0446\u0435\u0441\u0441\u044B</h1>\n <input id=\"app\" placeholder=\"\u043C\u043E\u0434\u0443\u043B\u044C\" size=\"10\">\n <input id=\"flow\" placeholder=\"\u043F\u0440\u043E\u0446\u0435\u0441\u0441\" size=\"14\">\n <select id=\"status\"><option value=\"\">\u0432\u0441\u0435 \u0441\u0442\u0430\u0442\u0443\u0441\u044B</option><option>running</option><option>completed</option>\n <option>failed</option><option>terminated</option><option>timed-out</option></select>\n <input id=\"idPrefix\" placeholder=\"\u043D\u0430\u0447\u0430\u043B\u043E id\" size=\"16\">\n <select id=\"limit\"><option>50</option><option selected>100</option><option>500</option></select>\n <button id=\"refresh\">\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C</button>\n <span id=\"msg\" class=\"muted\"></span>\n</header>\n<main>\n <section><table><thead><tr><th>id</th><th>\u043F\u0440\u043E\u0446\u0435\u0441\u0441</th><th>\u0441\u0442\u0430\u0442\u0443\u0441</th><th>\u043D\u0430\u0447\u0430\u0442</th><th>\u0434\u043B\u0438\u0442.</th></tr></thead>\n <tbody id=\"list\"></tbody></table></section>\n <section id=\"detail\"><p class=\"muted\">\u0412\u044B\u0431\u0435\u0440\u0438 \u043F\u0440\u043E\u0446\u0435\u0441\u0441 \u0432 \u0441\u043F\u0438\u0441\u043A\u0435.</p></section>\n</main>\n<script>\nconst $ = (id) => document.getElementById(id);\n// \u0410\u0434\u0440\u0435\u0441 API \u2014 \u043E\u0442 \u0430\u0434\u0440\u0435\u0441\u0430 \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u044B: \u043F\u043E\u0434 \u043B\u044E\u0431\u044B\u043C \u043F\u0440\u0435\u0444\u0438\u043A\u0441\u043E\u043C, \u0441\u043E \u0441\u043B\u044D\u0448\u0435\u043C \u0432 \u043A\u043E\u043D\u0446\u0435 \u0438 \u0431\u0435\u0437.\nconst ROOT = location.pathname.endsWith(\"/\") ? location.pathname : location.pathname + \"/\";\n// \u041E\u0442\u0432\u0435\u0442 \u043D\u0430 \u0443\u0441\u0442\u0430\u0440\u0435\u0432\u0448\u0438\u0439 \u0437\u0430\u043F\u0440\u043E\u0441 \u043D\u0435 \u043F\u0435\u0440\u0435\u0440\u0438\u0441\u043E\u0432\u044B\u0432\u0430\u0435\u0442 \u044D\u043A\u0440\u0430\u043D: \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0438\u0439 \u043A\u043B\u0438\u043A \u043F\u043E\u0431\u0435\u0436\u0434\u0430\u0435\u0442.\nlet listSeq = 0, detailSeq = 0;\nconst FIELDS = [\"app\", \"flow\", \"status\", \"idPrefix\", \"limit\"];\nfunction el(tag, attrs, ...kids) {\n const e = document.createElement(tag);\n for (const [k, v] of Object.entries(attrs || {})) if (v != null) e.setAttribute(k, v);\n for (const k of kids) if (k != null) e.append(k instanceof Node ? k : String(k));\n return e;\n}\nconst time = (s) => s ? new Date(s).toLocaleString() : \"\";\nfunction dur(a, b) {\n if (!a) return \"\";\n const ms = (b ? new Date(b) : new Date()) - new Date(a);\n if (ms < 1000) return ms + \" \u043C\u0441\";\n if (ms < 60000) return (ms / 1000).toFixed(1) + \" \u0441\";\n if (ms < 3600000) return Math.round(ms / 60000) + \" \u043C\u0438\u043D\";\n return (ms / 3600000).toFixed(1) + \" \u0447\";\n}\nconst pretty = (v) => JSON.stringify(v, null, 2);\nasync function get(path) {\n const r = await fetch(path, { headers: { accept: \"application/json\" } });\n const body = await r.json().catch(() => ({ error: r.statusText }));\n if (!r.ok) throw new Error(body.error || r.statusText);\n return body;\n}\nfunction readHash() { return new URLSearchParams(location.hash.slice(1)); }\nfunction writeHash(id) {\n const q = new URLSearchParams();\n for (const f of FIELDS) if ($(f).value) q.set(f, $(f).value);\n if (id) q.set(\"id\", id);\n history.replaceState(null, \"\", \"#\" + q);\n}\nlet selected = readHash().get(\"id\");\nasync function loadList() {\n const q = new URLSearchParams();\n for (const f of FIELDS) if ($(f).value) q.set(f, $(f).value);\n $(\"msg\").textContent = \"\u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0430\u2026\";\n const seq = ++listSeq;\n try {\n const rows = await get(ROOT + \"api/flows?\" + q);\n if (seq !== listSeq) return;\n const body = $(\"list\");\n body.replaceChildren(...rows.map((f) => {\n const tr = el(\"tr\", { class: \"row\" + (f.id === selected ? \" sel\" : \"\") },\n el(\"td\", { class: \"mono\" }, f.id), el(\"td\", {}, f.flow.app + \" / \" + f.flow.flow),\n el(\"td\", { class: \"st \" + f.status }, f.status), el(\"td\", {}, time(f.startedAt)), el(\"td\", {}, dur(f.startedAt, f.closedAt)));\n tr.onclick = () => select(f.id);\n return tr;\n }));\n $(\"msg\").textContent = rows.length + \" \u0448\u0442.\";\n } catch (e) { if (seq === listSeq) $(\"msg\").textContent = \"\u043E\u0448\u0438\u0431\u043A\u0430: \" + e.message; }\n}\nconst link = (id) => { const a = el(\"a\", { href: \"#\" }, id); a.onclick = (ev) => { ev.preventDefault(); select(id); }; return a; };\nfunction value(label, v) {\n if (v === undefined) return null;\n return el(\"details\", { open: \"\" }, el(\"summary\", {}, label), el(\"pre\", {}, pretty(v)));\n}\nasync function select(id) {\n selected = id;\n writeHash(id);\n for (const tr of $(\"list\").children) tr.classList.toggle(\"sel\", tr.firstChild.textContent === id);\n const box = $(\"detail\");\n box.replaceChildren(el(\"p\", { class: \"muted\" }, \"\u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0430\u2026\"));\n const seq = ++detailSeq;\n try {\n const d = await get(ROOT + \"api/flows/\" + encodeURIComponent(id));\n if (seq !== detailSeq) return;\n const steps = el(\"tbody\", {}, ...d.steps.map((s, i) => el(\"tr\", {},\n el(\"td\", { class: \"muted\" }, i + 1),\n el(\"td\", {}, s.name, s.childId ? el(\"div\", { class: \"mono\" }, \"\u2192 \", link(s.childId)) : null),\n el(\"td\", {}, time(s.startedAt)), el(\"td\", {}, dur(s.startedAt, s.closedAt || d.closedAt)),\n el(\"td\", {}, s.error ? el(\"div\", { class: \"err\" }, s.error) : null,\n s.output !== undefined ? el(\"details\", {}, el(\"summary\", {}, \"\u0438\u0442\u043E\u0433\"), el(\"pre\", {}, pretty(s.output))) : null))));\n box.replaceChildren(...[\n el(\"h2\", { class: \"mono\" }, d.id),\n el(\"dl\", {},\n el(\"dt\", {}, \"\u043F\u0440\u043E\u0446\u0435\u0441\u0441\"), el(\"dd\", {}, d.flow.app + \" / \" + d.flow.flow),\n el(\"dt\", {}, \"\u0441\u0442\u0430\u0442\u0443\u0441\"), el(\"dd\", { class: \"st \" + d.status }, d.status),\n el(\"dt\", {}, \"\u043D\u0430\u0447\u0430\u0442\"), el(\"dd\", {}, time(d.startedAt)),\n el(\"dt\", {}, \"\u0437\u0430\u043A\u043E\u043D\u0447\u0435\u043D\"), el(\"dd\", {}, d.closedAt ? time(d.closedAt) + \" (\" + dur(d.startedAt, d.closedAt) + \")\" : \"\u2014\"),\n d.parentId ? el(\"dt\", {}, \"\u0440\u043E\u0434\u0438\u0442\u0435\u043B\u044C\") : null, d.parentId ? el(\"dd\", { class: \"mono\" }, link(d.parentId)) : null),\n d.error ? el(\"div\", { class: \"err\" }, d.error) : null,\n value(\"\u0432\u0445\u043E\u0434\", d.input), value(\"\u0438\u0442\u043E\u0433\", d.output),\n el(\"h3\", {}, \"\u0428\u0430\u0433\u0438 (\" + d.steps.length + \")\"),\n el(\"table\", {}, el(\"thead\", {}, el(\"tr\", {}, el(\"th\", {}, \"#\"), el(\"th\", {}, \"\u0448\u0430\u0433\"), el(\"th\", {}, \"\u043D\u0430\u0447\u0430\u0442\"),\n el(\"th\", {}, \"\u0434\u043B\u0438\u0442.\"), el(\"th\", {}, \"\u0438\u0442\u043E\u0433\"))), steps)].filter(Boolean));\n } catch (e) { if (seq === detailSeq) box.replaceChildren(el(\"p\", { class: \"err\" }, e.message)); }\n}\nconst init = readHash();\nfor (const f of FIELDS) if (init.get(f)) $(f).value = init.get(f);\nfor (const f of FIELDS) $(f).addEventListener(\"change\", () => { writeHash(selected); loadList(); });\n$(\"refresh\").onclick = () => { loadList(); if (selected) select(selected); };\nloadList();\nif (selected) select(selected);\n</script>\n</body>\n</html>\n";
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iworkflow/viewer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.16.3",
|
|
4
|
+
"description": "Read-only process viewer for @iworkflow/core engines (Temporal, DBOS)",
|
|
5
|
+
"keywords": ["workflow", "viewer", "dbos", "temporal"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/iconicompany/iworkflow.git",
|
|
10
|
+
"directory": "packages/viewer"
|
|
11
|
+
},
|
|
12
|
+
"files": ["src", "!src/**/*.test.ts", "dist"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"bun": "./src/index.ts",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"iworkflow-viewer": "./dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
|
|
30
|
+
"type-check": "tsc --noEmit -p tsconfig.json",
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"prepack": "bun run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@iworkflow/core": "^0.16.3"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@iworkflow/dbos": "^0.16.3",
|
|
39
|
+
"@iworkflow/temporal": "^0.16.3"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@iworkflow/dbos": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"@iworkflow/temporal": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@iworkflow/dbos": "^0.16.3",
|
|
51
|
+
"@iworkflow/temporal": "^0.16.3"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// iworkflow-viewer [--engine dbos|temporal] [--port 8233] [--host 127.0.0.1] — просмотрщик на своей машине.
|
|
3
|
+
// Адрес движка — как у модулей: DBOS_SYSTEM_DATABASE_URL или TEMPORAL_* (см. README движка).
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
|
|
6
|
+
import type { EngineClient } from "@iworkflow/core";
|
|
7
|
+
import { flowViewer } from "./index";
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const opt = (name: string, def: string) => {
|
|
11
|
+
const i = args.indexOf(`--${name}`);
|
|
12
|
+
return i >= 0 && args[i + 1] ? args[i + 1]! : def;
|
|
13
|
+
};
|
|
14
|
+
const engine = opt("engine", process.env.IWF_ENGINE ?? "dbos");
|
|
15
|
+
const port = Number(opt("port", "8233"));
|
|
16
|
+
const host = opt("host", "127.0.0.1");
|
|
17
|
+
|
|
18
|
+
async function connect(): Promise<EngineClient> {
|
|
19
|
+
// Движок — необязательная зависимость: ставится тот, что нужен.
|
|
20
|
+
if (engine === "dbos") return (await import("@iworkflow/dbos")).dbosClient();
|
|
21
|
+
if (engine === "temporal") return (await import("@iworkflow/temporal")).temporalClient();
|
|
22
|
+
throw new Error(`--engine: dbos или temporal, а не ${engine}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const client = await connect();
|
|
26
|
+
const handle = flowViewer(client);
|
|
27
|
+
// DNS rebinding: чужой сайт, переназначивший своё имя на 127.0.0.1, ходит сюда со своим Host — отказ.
|
|
28
|
+
// Не локальный --host открыт намеренно (с предупреждением): там Host любой.
|
|
29
|
+
const LOOPBACK = ["127.0.0.1", "localhost", "[::1]", "::1"];
|
|
30
|
+
const local = LOOPBACK.includes(host);
|
|
31
|
+
const allowed = new Set(LOOPBACK.flatMap((h) => [`${h}:${port}`, ...(port === 80 ? [h] : [])]));
|
|
32
|
+
const server = createServer(async (req, res) => {
|
|
33
|
+
if (local && !allowed.has(req.headers.host ?? "")) {
|
|
34
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("чужой Host");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const r = await handle(new Request(new URL(req.url ?? "/", `http://${req.headers.host}`).href, { method: req.method }));
|
|
39
|
+
const headers: Record<string, string> = {};
|
|
40
|
+
r.headers.forEach((v, k) => (headers[k] = v));
|
|
41
|
+
res.writeHead(r.status, headers);
|
|
42
|
+
res.end(Buffer.from(await r.arrayBuffer()));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error(e);
|
|
45
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end(String((e as Error)?.message ?? e));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
server.listen(port, host, () => {
|
|
49
|
+
console.log(`iworkflow-viewer (${engine}): http://${host}:${port}/`);
|
|
50
|
+
// Доступ не проверяется: на чужом адресе процессы с ПДн видит любой, кто дотянется.
|
|
51
|
+
if (!local) console.warn("внимание: адрес не локальный, входа у просмотрщика нет");
|
|
52
|
+
});
|
|
53
|
+
const stop = () => server.close(() => void client.close().then(() => process.exit(0)));
|
|
54
|
+
process.on("SIGINT", stop);
|
|
55
|
+
process.on("SIGTERM", stop);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Просмотрщик процессов поверх EngineClient: список, процесс со входом, итогом и шагами. Только чтение.
|
|
2
|
+
// Доступ не проверяет: модуль, встраивающий обработчик, ставит его за свой вход (данные процессов — ПДн).
|
|
3
|
+
import { checkList, FLOW_STATUSES, isFlowError, type EngineClient, type FlowStatus, type ListOptions } from "@iworkflow/core";
|
|
4
|
+
|
|
5
|
+
import { PAGE } from "./page";
|
|
6
|
+
|
|
7
|
+
export interface ViewerOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Префикс пути, если сервер его не срезает (Bun.serve), например "/admin/flows". Elysia `mount`
|
|
10
|
+
* срезает сам — тогда не нужен; путь без префикса обработчик тоже принимает.
|
|
11
|
+
*/
|
|
12
|
+
basePath?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Больше за раз не отдаём: list DBOS листает базу, Temporal читает всё совпавшее. */
|
|
16
|
+
export const MAX_LIMIT = 1000;
|
|
17
|
+
|
|
18
|
+
/** Неверный запрос — 400; прочие ошибки (в том числе RangeError движка) — не вина запроса. */
|
|
19
|
+
export class BadQuery extends Error {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Копия для JSON: bigint — строкой, Error — name и message, Map — объектом, Set — массивом; цикл (объект среди
|
|
23
|
+
* своих предков) — "[цикл]", общий объект дважды — не цикл. Результат toJSON разбирается так же.
|
|
24
|
+
*/
|
|
25
|
+
function plain(v: unknown, ancestors: object[] = []): unknown {
|
|
26
|
+
if (typeof v === "bigint") return v.toString();
|
|
27
|
+
if (!v || typeof v !== "object") return v;
|
|
28
|
+
if (ancestors.includes(v)) return "[цикл]";
|
|
29
|
+
const up = [...ancestors, v];
|
|
30
|
+
if (v instanceof Date) return v.toJSON();
|
|
31
|
+
if (v instanceof Error) return plain({ ...v, name: v.name, message: v.message }, up);
|
|
32
|
+
if (v instanceof Map) return plain(Object.fromEntries([...v].map(([k, x]) => [String(k), x])), up);
|
|
33
|
+
if (v instanceof Set) return plain([...v], up);
|
|
34
|
+
if (typeof (v as { toJSON?: unknown }).toJSON === "function") return plain((v as { toJSON(): unknown }).toJSON(), up);
|
|
35
|
+
if (Array.isArray(v)) return v.map((x) => plain(x, up));
|
|
36
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x, up)]));
|
|
37
|
+
}
|
|
38
|
+
const toJson = (v: unknown) => JSON.stringify(plain(v));
|
|
39
|
+
|
|
40
|
+
const json = (body: unknown, status = 200) =>
|
|
41
|
+
new Response(toJson(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
|
|
42
|
+
|
|
43
|
+
/** Отбор списка из строки запроса; неверное значение — BadQuery (400). */
|
|
44
|
+
export function listQuery(q: URLSearchParams): ListOptions {
|
|
45
|
+
const o: ListOptions = {};
|
|
46
|
+
const idPrefix = q.get("idPrefix");
|
|
47
|
+
if (idPrefix) o.idPrefix = idPrefix;
|
|
48
|
+
const app = q.get("app");
|
|
49
|
+
const flow = q.get("flow");
|
|
50
|
+
if (flow && !app) throw new BadQuery("flow без app: процесс ищется в модуле");
|
|
51
|
+
if (app) o.app = app;
|
|
52
|
+
if (app && flow) o.flow = { app, flow };
|
|
53
|
+
const status = q.get("status");
|
|
54
|
+
if (status) {
|
|
55
|
+
if (!FLOW_STATUSES.includes(status as FlowStatus)) throw new BadQuery(`нет статуса ${status}`);
|
|
56
|
+
o.status = status as FlowStatus;
|
|
57
|
+
}
|
|
58
|
+
const since = q.get("since");
|
|
59
|
+
if (since) {
|
|
60
|
+
const d = new Date(since);
|
|
61
|
+
if (Number.isNaN(d.getTime())) throw new BadQuery(`since — не дата: ${since}`);
|
|
62
|
+
o.since = d;
|
|
63
|
+
}
|
|
64
|
+
const limit = q.get("limit");
|
|
65
|
+
if (limit) o.limit = Number(limit);
|
|
66
|
+
if ((o.limit ?? 0) > MAX_LIMIT) throw new BadQuery(`limit — не больше ${MAX_LIMIT}`);
|
|
67
|
+
try {
|
|
68
|
+
checkList(o);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new BadQuery((e as Error).message);
|
|
71
|
+
}
|
|
72
|
+
return o;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Обработчик fetch: GET / — страница, /api/flows — список, /api/flows/<id> — процесс (под basePath, если задан). */
|
|
76
|
+
export function flowViewer(client: EngineClient, opts: ViewerOptions = {}): (req: Request) => Promise<Response> {
|
|
77
|
+
const base = (opts.basePath ?? "").replace(/\/+$/, "");
|
|
78
|
+
return async (req) => {
|
|
79
|
+
const url = new URL(req.url);
|
|
80
|
+
if (req.method !== "GET") return json({ error: "только чтение: GET" }, 405);
|
|
81
|
+
let path = url.pathname;
|
|
82
|
+
if (base && (path === base || path.startsWith(`${base}/`))) path = path.slice(base.length);
|
|
83
|
+
try {
|
|
84
|
+
// Страница зовёт api/… от своего адреса — работает и без слэша в конце, и под чужим префиксом.
|
|
85
|
+
if (path === "/" || path === "") return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
86
|
+
if (path === "/api/flows") return json(await client.list(listQuery(url.searchParams)));
|
|
87
|
+
if (path.startsWith("/api/flows/")) {
|
|
88
|
+
let id: string;
|
|
89
|
+
try {
|
|
90
|
+
id = decodeURIComponent(path.slice("/api/flows/".length));
|
|
91
|
+
} catch {
|
|
92
|
+
throw new BadQuery("id: неверная кодировка");
|
|
93
|
+
}
|
|
94
|
+
if (!id) throw new BadQuery("нет id");
|
|
95
|
+
return json(await client.inspect(id));
|
|
96
|
+
}
|
|
97
|
+
return json({ error: "нет такого пути" }, 404);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e instanceof BadQuery) return json({ error: e.message }, 400);
|
|
100
|
+
if (isFlowError(e) && e.reason === "not-found") return json({ error: e.message }, 404);
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/page.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Страница просмотрщика: одна, без сборки и внешних скриптов. Данные процессов вставляются только textContent.
|
|
2
|
+
export const PAGE = `<!doctype html>
|
|
3
|
+
<html lang="ru">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>Процессы iworkflow</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root { --bg:#fff; --fg:#1b1f24; --muted:#636c76; --line:#d8dee4; --hover:#f3f5f7; --sel:#e6efff;
|
|
10
|
+
--ok:#1a7f37; --bad:#cf222e; --run:#0969da; --warn:#9a6700; font: 14px/1.4 system-ui, sans-serif; }
|
|
11
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#0d1117; --fg:#e6edf3; --muted:#8d96a0; --line:#30363d;
|
|
12
|
+
--hover:#161b22; --sel:#1c2a40; --ok:#3fb950; --bad:#f85149; --run:#58a6ff; --warn:#d29922; } }
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
body { margin:0; background:var(--bg); color:var(--fg); }
|
|
15
|
+
header { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:10px 16px; border-bottom:1px solid var(--line); }
|
|
16
|
+
header h1 { font-size:16px; margin:0 12px 0 0; }
|
|
17
|
+
input, select, button { font:inherit; color:inherit; background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:4px 8px; }
|
|
18
|
+
button { cursor:pointer; }
|
|
19
|
+
main { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); min-height:calc(100vh - 52px); }
|
|
20
|
+
@media (max-width: 900px) { main { grid-template-columns:1fr; } }
|
|
21
|
+
section { padding:8px 16px; overflow:auto; }
|
|
22
|
+
#detail { border-left:1px solid var(--line); }
|
|
23
|
+
table { width:100%; border-collapse:collapse; }
|
|
24
|
+
th, td { text-align:left; padding:5px 6px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
25
|
+
th { color:var(--muted); font-weight:500; }
|
|
26
|
+
tbody tr.row { cursor:pointer; } tbody tr.row:hover { background:var(--hover); } tr.sel { background:var(--sel); }
|
|
27
|
+
.mono { font-family: ui-monospace, monospace; font-size:12px; word-break:break-all; }
|
|
28
|
+
.st { font-weight:600; } .completed { color:var(--ok); } .failed, .terminated { color:var(--bad); }
|
|
29
|
+
.running { color:var(--run); } .timed-out { color:var(--warn); }
|
|
30
|
+
.err { color:var(--bad); white-space:pre-wrap; }
|
|
31
|
+
.muted { color:var(--muted); }
|
|
32
|
+
pre { margin:4px 0; padding:8px; background:var(--hover); border-radius:6px; overflow:auto; max-height:300px; font-size:12px; }
|
|
33
|
+
details > summary { cursor:pointer; color:var(--muted); }
|
|
34
|
+
a { color:var(--run); }
|
|
35
|
+
h2 { font-size:15px; margin:8px 0; word-break:break-all; }
|
|
36
|
+
dl { display:grid; grid-template-columns:max-content 1fr; gap:2px 12px; margin:0 0 8px; }
|
|
37
|
+
dt { color:var(--muted); }
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<header>
|
|
42
|
+
<h1>Процессы</h1>
|
|
43
|
+
<input id="app" placeholder="модуль" size="10">
|
|
44
|
+
<input id="flow" placeholder="процесс" size="14">
|
|
45
|
+
<select id="status"><option value="">все статусы</option><option>running</option><option>completed</option>
|
|
46
|
+
<option>failed</option><option>terminated</option><option>timed-out</option></select>
|
|
47
|
+
<input id="idPrefix" placeholder="начало id" size="16">
|
|
48
|
+
<select id="limit"><option>50</option><option selected>100</option><option>500</option></select>
|
|
49
|
+
<button id="refresh">Обновить</button>
|
|
50
|
+
<span id="msg" class="muted"></span>
|
|
51
|
+
</header>
|
|
52
|
+
<main>
|
|
53
|
+
<section><table><thead><tr><th>id</th><th>процесс</th><th>статус</th><th>начат</th><th>длит.</th></tr></thead>
|
|
54
|
+
<tbody id="list"></tbody></table></section>
|
|
55
|
+
<section id="detail"><p class="muted">Выбери процесс в списке.</p></section>
|
|
56
|
+
</main>
|
|
57
|
+
<script>
|
|
58
|
+
const $ = (id) => document.getElementById(id);
|
|
59
|
+
// Адрес API — от адреса страницы: под любым префиксом, со слэшем в конце и без.
|
|
60
|
+
const ROOT = location.pathname.endsWith("/") ? location.pathname : location.pathname + "/";
|
|
61
|
+
// Ответ на устаревший запрос не перерисовывает экран: последний клик побеждает.
|
|
62
|
+
let listSeq = 0, detailSeq = 0;
|
|
63
|
+
const FIELDS = ["app", "flow", "status", "idPrefix", "limit"];
|
|
64
|
+
function el(tag, attrs, ...kids) {
|
|
65
|
+
const e = document.createElement(tag);
|
|
66
|
+
for (const [k, v] of Object.entries(attrs || {})) if (v != null) e.setAttribute(k, v);
|
|
67
|
+
for (const k of kids) if (k != null) e.append(k instanceof Node ? k : String(k));
|
|
68
|
+
return e;
|
|
69
|
+
}
|
|
70
|
+
const time = (s) => s ? new Date(s).toLocaleString() : "";
|
|
71
|
+
function dur(a, b) {
|
|
72
|
+
if (!a) return "";
|
|
73
|
+
const ms = (b ? new Date(b) : new Date()) - new Date(a);
|
|
74
|
+
if (ms < 1000) return ms + " мс";
|
|
75
|
+
if (ms < 60000) return (ms / 1000).toFixed(1) + " с";
|
|
76
|
+
if (ms < 3600000) return Math.round(ms / 60000) + " мин";
|
|
77
|
+
return (ms / 3600000).toFixed(1) + " ч";
|
|
78
|
+
}
|
|
79
|
+
const pretty = (v) => JSON.stringify(v, null, 2);
|
|
80
|
+
async function get(path) {
|
|
81
|
+
const r = await fetch(path, { headers: { accept: "application/json" } });
|
|
82
|
+
const body = await r.json().catch(() => ({ error: r.statusText }));
|
|
83
|
+
if (!r.ok) throw new Error(body.error || r.statusText);
|
|
84
|
+
return body;
|
|
85
|
+
}
|
|
86
|
+
function readHash() { return new URLSearchParams(location.hash.slice(1)); }
|
|
87
|
+
function writeHash(id) {
|
|
88
|
+
const q = new URLSearchParams();
|
|
89
|
+
for (const f of FIELDS) if ($(f).value) q.set(f, $(f).value);
|
|
90
|
+
if (id) q.set("id", id);
|
|
91
|
+
history.replaceState(null, "", "#" + q);
|
|
92
|
+
}
|
|
93
|
+
let selected = readHash().get("id");
|
|
94
|
+
async function loadList() {
|
|
95
|
+
const q = new URLSearchParams();
|
|
96
|
+
for (const f of FIELDS) if ($(f).value) q.set(f, $(f).value);
|
|
97
|
+
$("msg").textContent = "загрузка…";
|
|
98
|
+
const seq = ++listSeq;
|
|
99
|
+
try {
|
|
100
|
+
const rows = await get(ROOT + "api/flows?" + q);
|
|
101
|
+
if (seq !== listSeq) return;
|
|
102
|
+
const body = $("list");
|
|
103
|
+
body.replaceChildren(...rows.map((f) => {
|
|
104
|
+
const tr = el("tr", { class: "row" + (f.id === selected ? " sel" : "") },
|
|
105
|
+
el("td", { class: "mono" }, f.id), el("td", {}, f.flow.app + " / " + f.flow.flow),
|
|
106
|
+
el("td", { class: "st " + f.status }, f.status), el("td", {}, time(f.startedAt)), el("td", {}, dur(f.startedAt, f.closedAt)));
|
|
107
|
+
tr.onclick = () => select(f.id);
|
|
108
|
+
return tr;
|
|
109
|
+
}));
|
|
110
|
+
$("msg").textContent = rows.length + " шт.";
|
|
111
|
+
} catch (e) { if (seq === listSeq) $("msg").textContent = "ошибка: " + e.message; }
|
|
112
|
+
}
|
|
113
|
+
const link = (id) => { const a = el("a", { href: "#" }, id); a.onclick = (ev) => { ev.preventDefault(); select(id); }; return a; };
|
|
114
|
+
function value(label, v) {
|
|
115
|
+
if (v === undefined) return null;
|
|
116
|
+
return el("details", { open: "" }, el("summary", {}, label), el("pre", {}, pretty(v)));
|
|
117
|
+
}
|
|
118
|
+
async function select(id) {
|
|
119
|
+
selected = id;
|
|
120
|
+
writeHash(id);
|
|
121
|
+
for (const tr of $("list").children) tr.classList.toggle("sel", tr.firstChild.textContent === id);
|
|
122
|
+
const box = $("detail");
|
|
123
|
+
box.replaceChildren(el("p", { class: "muted" }, "загрузка…"));
|
|
124
|
+
const seq = ++detailSeq;
|
|
125
|
+
try {
|
|
126
|
+
const d = await get(ROOT + "api/flows/" + encodeURIComponent(id));
|
|
127
|
+
if (seq !== detailSeq) return;
|
|
128
|
+
const steps = el("tbody", {}, ...d.steps.map((s, i) => el("tr", {},
|
|
129
|
+
el("td", { class: "muted" }, i + 1),
|
|
130
|
+
el("td", {}, s.name, s.childId ? el("div", { class: "mono" }, "→ ", link(s.childId)) : null),
|
|
131
|
+
el("td", {}, time(s.startedAt)), el("td", {}, dur(s.startedAt, s.closedAt || d.closedAt)),
|
|
132
|
+
el("td", {}, s.error ? el("div", { class: "err" }, s.error) : null,
|
|
133
|
+
s.output !== undefined ? el("details", {}, el("summary", {}, "итог"), el("pre", {}, pretty(s.output))) : null))));
|
|
134
|
+
box.replaceChildren(...[
|
|
135
|
+
el("h2", { class: "mono" }, d.id),
|
|
136
|
+
el("dl", {},
|
|
137
|
+
el("dt", {}, "процесс"), el("dd", {}, d.flow.app + " / " + d.flow.flow),
|
|
138
|
+
el("dt", {}, "статус"), el("dd", { class: "st " + d.status }, d.status),
|
|
139
|
+
el("dt", {}, "начат"), el("dd", {}, time(d.startedAt)),
|
|
140
|
+
el("dt", {}, "закончен"), el("dd", {}, d.closedAt ? time(d.closedAt) + " (" + dur(d.startedAt, d.closedAt) + ")" : "—"),
|
|
141
|
+
d.parentId ? el("dt", {}, "родитель") : null, d.parentId ? el("dd", { class: "mono" }, link(d.parentId)) : null),
|
|
142
|
+
d.error ? el("div", { class: "err" }, d.error) : null,
|
|
143
|
+
value("вход", d.input), value("итог", d.output),
|
|
144
|
+
el("h3", {}, "Шаги (" + d.steps.length + ")"),
|
|
145
|
+
el("table", {}, el("thead", {}, el("tr", {}, el("th", {}, "#"), el("th", {}, "шаг"), el("th", {}, "начат"),
|
|
146
|
+
el("th", {}, "длит."), el("th", {}, "итог"))), steps)].filter(Boolean));
|
|
147
|
+
} catch (e) { if (seq === detailSeq) box.replaceChildren(el("p", { class: "err" }, e.message)); }
|
|
148
|
+
}
|
|
149
|
+
const init = readHash();
|
|
150
|
+
for (const f of FIELDS) if (init.get(f)) $(f).value = init.get(f);
|
|
151
|
+
for (const f of FIELDS) $(f).addEventListener("change", () => { writeHash(selected); loadList(); });
|
|
152
|
+
$("refresh").onclick = () => { loadList(); if (selected) select(selected); };
|
|
153
|
+
loadList();
|
|
154
|
+
if (selected) select(selected);
|
|
155
|
+
</script>
|
|
156
|
+
</body>
|
|
157
|
+
</html>
|
|
158
|
+
`;
|