@tsplc/debug 0.0.0-stage → 0.2.17
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/dist/format.js +121 -0
- package/dist/format.js.map +11 -0
- package/dist/index.js +1681 -0
- package/dist/index.js.map +24 -0
- package/dist/manifest.js +201 -0
- package/dist/manifest.js.map +10 -0
- package/dist/paths.js +138 -0
- package/dist/paths.js.map +10 -0
- package/package.json +46 -4
- package/src/adapter.ts +59 -0
- package/src/api.ts +182 -0
- package/src/debugger.ts +509 -0
- package/src/hardware.ts +125 -0
- package/src/http.ts +155 -0
- package/src/index.ts +8 -0
- package/src/jobs.ts +115 -0
- package/src/login.ts +284 -0
- package/src/manifest.ts +271 -0
- package/src/opcua.ts +69 -0
- package/src/paths.ts +185 -0
- package/src/telemetry.ts +110 -0
- package/src/timeline.ts +78 -0
- package/src/trace.ts +45 -0
- package/src/ui/bits.ts +41 -0
- package/src/ui/client.ts +48 -0
- package/src/ui/dev.ts +579 -0
- package/src/ui/dom.ts +168 -0
- package/src/ui/fixture.ts +548 -0
- package/src/ui/format.ts +125 -0
- package/src/ui/hardware.ts +368 -0
- package/src/ui/header.ts +179 -0
- package/src/ui/index.ts +20 -0
- package/src/ui/jobs.ts +249 -0
- package/src/ui/main.ts +74 -0
- package/src/ui/overview.ts +123 -0
- package/src/ui/page.ts +178 -0
- package/src/ui/recent.ts +322 -0
- package/src/ui/requests.ts +81 -0
- package/src/ui/store.ts +227 -0
- package/src/ui/tree.ts +156 -0
- package/src/ui/variables.ts +469 -0
- package/src/watch.ts +32 -0
- package/README.md +0 -3
package/src/ui/jobs.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { JobState, RequestTrace } from "../api.ts";
|
|
2
|
+
import { type View, empty, h, pathLink, recordSourceLink, sourceLink } from "./dom.ts";
|
|
3
|
+
import { formatDuration } from "./format.ts";
|
|
4
|
+
import type { Store } from "./store.ts";
|
|
5
|
+
|
|
6
|
+
type JobFilter = "all" | "busy" | "failed";
|
|
7
|
+
|
|
8
|
+
/** Failed while idle since its last unhandled failure: a later start that ended well clears it. */
|
|
9
|
+
const failed = (job: JobState) =>
|
|
10
|
+
job.busy === false &&
|
|
11
|
+
job.failure !== undefined &&
|
|
12
|
+
(job.startedAt === undefined || job.failure.at >= job.startedAt);
|
|
13
|
+
|
|
14
|
+
const jobStatus = (job: JobState) => {
|
|
15
|
+
if (job.cancelling) return "cancelling";
|
|
16
|
+
if (job.busy) return "busy";
|
|
17
|
+
if (job.busy === null) return "unknown";
|
|
18
|
+
if (failed(job)) return "failed";
|
|
19
|
+
return "idle";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const isActive = (job: JobState) => job.busy === true || failed(job);
|
|
23
|
+
|
|
24
|
+
const failureName = (store: Store, failure: NonNullable<JobState["failure"]>) =>
|
|
25
|
+
failure.name ?? store.manifest.errors[String(failure.code)] ?? `error ${failure.code}`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A traced request's job, as a link: the user's job that awaits a compiler-made service job, which
|
|
29
|
+
* is the one whose id the request carries, or that job itself.
|
|
30
|
+
*/
|
|
31
|
+
export const requestJobLink = (store: Store, id: number, sequence: number | undefined) => {
|
|
32
|
+
const { jobs } = store.manifest;
|
|
33
|
+
const job =
|
|
34
|
+
jobs.find((job) => !job.generated && job.requests.some((call) => call.job === id)) ??
|
|
35
|
+
jobs.find((job) => job.id === id);
|
|
36
|
+
return h(
|
|
37
|
+
"a",
|
|
38
|
+
{ href: `#/jobs?id=${job?.id ?? id}` },
|
|
39
|
+
job ? `${job.name}${job.instance ? ` · ${job.instance}` : ""}` : `job ${id}`,
|
|
40
|
+
` #${sequence ?? "?"}`,
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const requestStatus = (trace: RequestTrace) =>
|
|
45
|
+
h(
|
|
46
|
+
"span",
|
|
47
|
+
{ class: `st st-${trace.status}` },
|
|
48
|
+
trace.status,
|
|
49
|
+
trace.httpStatus === undefined ? "" : ` ${trace.httpStatus}`,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** One job: what it is, where it is waiting and what it asked the backend. */
|
|
53
|
+
export const jobRow = (store: Store, job: JobState, highlighted = false) => {
|
|
54
|
+
const definition = store.manifest.jobs.find((candidate) => candidate.id === job.id);
|
|
55
|
+
const status = jobStatus(job);
|
|
56
|
+
const trace = job.request?.trace;
|
|
57
|
+
const quality = store.connected ? job.quality : "stale";
|
|
58
|
+
return h(
|
|
59
|
+
"tr",
|
|
60
|
+
{ class: highlighted ? "highlight" : undefined, id: `job-${job.id}` },
|
|
61
|
+
h(
|
|
62
|
+
"td",
|
|
63
|
+
{},
|
|
64
|
+
h("b", { class: "mono" }, definition?.name ?? `job ${job.id}`),
|
|
65
|
+
definition ? h("div", {}, sourceLink(store, definition.declaration)) : "",
|
|
66
|
+
),
|
|
67
|
+
h(
|
|
68
|
+
"td",
|
|
69
|
+
{},
|
|
70
|
+
definition?.instance ? pathLink(definition.instance) : h("span", { class: "dim" }, "—"),
|
|
71
|
+
),
|
|
72
|
+
h(
|
|
73
|
+
"td",
|
|
74
|
+
{},
|
|
75
|
+
h(
|
|
76
|
+
"span",
|
|
77
|
+
{
|
|
78
|
+
class: `st st-${status}`,
|
|
79
|
+
title:
|
|
80
|
+
status === "unknown" ? "Not read: shown jobs are read through the login" : undefined,
|
|
81
|
+
},
|
|
82
|
+
status,
|
|
83
|
+
),
|
|
84
|
+
quality === "good" || job.busy === null
|
|
85
|
+
? ""
|
|
86
|
+
: h("span", { class: `meta s-${quality}` }, ` ${quality}`),
|
|
87
|
+
),
|
|
88
|
+
h(
|
|
89
|
+
"td",
|
|
90
|
+
{ class: "await" },
|
|
91
|
+
job.unread
|
|
92
|
+
? h("span", { class: "dim" }, "details not read (too many busy jobs)")
|
|
93
|
+
: job.current
|
|
94
|
+
? h(
|
|
95
|
+
"span",
|
|
96
|
+
{},
|
|
97
|
+
h("span", { class: "tag" }, job.current.kind),
|
|
98
|
+
" ",
|
|
99
|
+
h("code", {}, job.current.text),
|
|
100
|
+
" ",
|
|
101
|
+
sourceLink(store, job.current.range),
|
|
102
|
+
)
|
|
103
|
+
: h("span", { class: "dim" }, "—"),
|
|
104
|
+
),
|
|
105
|
+
h(
|
|
106
|
+
"td",
|
|
107
|
+
{ class: "mono num" },
|
|
108
|
+
job.elapsedMs === undefined ? "—" : formatDuration(job.elapsedMs),
|
|
109
|
+
),
|
|
110
|
+
h(
|
|
111
|
+
"td",
|
|
112
|
+
{ class: "err" },
|
|
113
|
+
job.failure
|
|
114
|
+
? h(
|
|
115
|
+
"span",
|
|
116
|
+
{ title: `Unhandled at ${job.failure.at}` },
|
|
117
|
+
failureName(store, job.failure),
|
|
118
|
+
" ",
|
|
119
|
+
recordSourceLink(store, job.failure.source),
|
|
120
|
+
)
|
|
121
|
+
: "",
|
|
122
|
+
),
|
|
123
|
+
h(
|
|
124
|
+
"td",
|
|
125
|
+
{ class: "nowrap" },
|
|
126
|
+
job.request
|
|
127
|
+
? h(
|
|
128
|
+
"span",
|
|
129
|
+
{},
|
|
130
|
+
h(
|
|
131
|
+
"a",
|
|
132
|
+
{ href: `#/requests?seq=${trace?.seq ?? ""}`, class: "mono" },
|
|
133
|
+
`${job.request.service} #${job.request.sequence}`,
|
|
134
|
+
),
|
|
135
|
+
" ",
|
|
136
|
+
trace ? requestStatus(trace) : h("span", { class: "dim" }, "no trace"),
|
|
137
|
+
trace?.durationMs === undefined
|
|
138
|
+
? ""
|
|
139
|
+
: h("span", { class: "mono dim" }, ` ${formatDuration(trace.durationMs)}`),
|
|
140
|
+
)
|
|
141
|
+
: h("span", { class: "dim" }, "—"),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const jobTable = (rows: HTMLElement[]) =>
|
|
147
|
+
h(
|
|
148
|
+
"table",
|
|
149
|
+
{ class: "grid jobs" },
|
|
150
|
+
h(
|
|
151
|
+
"thead",
|
|
152
|
+
{},
|
|
153
|
+
h(
|
|
154
|
+
"tr",
|
|
155
|
+
{},
|
|
156
|
+
...["Function", "Instance", "State", "Waiting on", "Elapsed", "Error", "Request"].map(
|
|
157
|
+
(label) => h("th", {}, label),
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
h("tbody", {}, ...rows),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const FILTERS: JobFilter[] = ["all", "busy", "failed"];
|
|
165
|
+
const filters: Record<JobFilter, { label: string; keep: (job: JobState) => boolean }> = {
|
|
166
|
+
all: { label: "All", keep: () => true },
|
|
167
|
+
busy: { label: "Busy", keep: (job) => job.busy === true },
|
|
168
|
+
failed: { label: "Failed", keep: failed },
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const jobsView = (store: Store, params: URLSearchParams): View => {
|
|
172
|
+
let filter = FILTERS.find((key) => key === params.get("filter")) ?? "all";
|
|
173
|
+
const highlighted = Number(params.get("id") ?? Number.NaN);
|
|
174
|
+
// Compiler-made jobs, such as each service call's own, are hidden unless asked for or linked to.
|
|
175
|
+
const generated = new Set(
|
|
176
|
+
store.manifest.jobs.filter((job) => job.generated).map((job) => job.id),
|
|
177
|
+
);
|
|
178
|
+
const generatedToggle = h("input", { type: "checkbox", checked: generated.has(highlighted) });
|
|
179
|
+
const shown = (job: { id: number }) => generatedToggle.checked || !generated.has(job.id);
|
|
180
|
+
const bar = h("div", { class: "tabs" });
|
|
181
|
+
const body = h("div");
|
|
182
|
+
const render = () => {
|
|
183
|
+
bar.replaceChildren(
|
|
184
|
+
...FILTERS.map((key) =>
|
|
185
|
+
h(
|
|
186
|
+
"button",
|
|
187
|
+
{
|
|
188
|
+
class: `tab${key === filter ? " on" : ""}`,
|
|
189
|
+
onclick: () => {
|
|
190
|
+
filter = key;
|
|
191
|
+
history.replaceState(null, "", `#/jobs?filter=${key}`);
|
|
192
|
+
render();
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
filters[key].label,
|
|
196
|
+
h(
|
|
197
|
+
"span",
|
|
198
|
+
{ class: "n" },
|
|
199
|
+
String(
|
|
200
|
+
[...store.jobs.values()].filter((job) => shown(job) && filters[key].keep(job)).length,
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
h("label", { class: "check" }, generatedToggle, "show generated"),
|
|
206
|
+
);
|
|
207
|
+
const jobs = [...store.jobs.values()]
|
|
208
|
+
.filter((job) => shown(job) && filters[filter].keep(job))
|
|
209
|
+
.sort((a, b) => Number(isActive(b)) - Number(isActive(a)) || a.id - b.id);
|
|
210
|
+
body.replaceChildren(
|
|
211
|
+
jobs.length
|
|
212
|
+
? jobTable(jobs.map((job) => jobRow(store, job, job.id === highlighted)))
|
|
213
|
+
: empty("No jobs match."),
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
const showJobs = () =>
|
|
217
|
+
store.show({
|
|
218
|
+
jobs: store.manifest.jobs.filter(shown).map(({ id }) => id),
|
|
219
|
+
});
|
|
220
|
+
generatedToggle.addEventListener("change", () => {
|
|
221
|
+
showJobs();
|
|
222
|
+
render();
|
|
223
|
+
});
|
|
224
|
+
render();
|
|
225
|
+
showJobs();
|
|
226
|
+
return {
|
|
227
|
+
element: h(
|
|
228
|
+
"div",
|
|
229
|
+
{ class: "page" },
|
|
230
|
+
h("h1", {}, "Jobs"),
|
|
231
|
+
h(
|
|
232
|
+
"p",
|
|
233
|
+
{ class: "hint" },
|
|
234
|
+
"Busy and idle come from the trace when it is on; otherwise each listed job's busy flag is read through the login, and the details of busy ones. Failures are the program's unhandled exceptions.",
|
|
235
|
+
),
|
|
236
|
+
bar,
|
|
237
|
+
body,
|
|
238
|
+
),
|
|
239
|
+
update(topics) {
|
|
240
|
+
if (
|
|
241
|
+
topics.has("jobs") ||
|
|
242
|
+
topics.has("stream") ||
|
|
243
|
+
topics.has("session") ||
|
|
244
|
+
topics.has("timeline")
|
|
245
|
+
)
|
|
246
|
+
render();
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
};
|
package/src/ui/main.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getJson } from "./client.ts";
|
|
2
|
+
import { type View, h } from "./dom.ts";
|
|
3
|
+
import { hardwareView } from "./hardware.ts";
|
|
4
|
+
import { ROUTES, type Route, header } from "./header.ts";
|
|
5
|
+
import { jobsView } from "./jobs.ts";
|
|
6
|
+
import { overviewView } from "./overview.ts";
|
|
7
|
+
import { recentView } from "./recent.ts";
|
|
8
|
+
import { requestsView } from "./requests.ts";
|
|
9
|
+
import { Store } from "./store.ts";
|
|
10
|
+
import { variablesView } from "./variables.ts";
|
|
11
|
+
|
|
12
|
+
interface RouteMatch {
|
|
13
|
+
route: Route;
|
|
14
|
+
rest: string[];
|
|
15
|
+
params: URLSearchParams;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parseHash = (): RouteMatch => {
|
|
19
|
+
const [path = "", query = ""] = location.hash.replace(/^#\/?/, "").split("?");
|
|
20
|
+
const [name, ...rest] = path.split("/");
|
|
21
|
+
return {
|
|
22
|
+
route: ROUTES.find((route) => route === name) ?? "overview",
|
|
23
|
+
rest: rest.map(decodeURIComponent),
|
|
24
|
+
params: new URLSearchParams(query),
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const views: Record<Route, (store: Store, at: RouteMatch) => View> = {
|
|
29
|
+
overview: (store) => overviewView(store),
|
|
30
|
+
variables: (store, at) => variablesView(store, at.params),
|
|
31
|
+
hardware: (store, at) => hardwareView(store, at.rest[0]),
|
|
32
|
+
jobs: (store, at) => jobsView(store, at.params),
|
|
33
|
+
recent: (store) => recentView(store),
|
|
34
|
+
requests: (store, at) => requestsView(store, at.params),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const start = async () => {
|
|
38
|
+
const [manifest, session] = await Promise.all([getJson("manifest"), getJson("session")]);
|
|
39
|
+
let view: View | undefined;
|
|
40
|
+
const store = new Store(manifest, session, (topics) => {
|
|
41
|
+
top.update(topics);
|
|
42
|
+
view?.update(topics);
|
|
43
|
+
});
|
|
44
|
+
const top = header(store);
|
|
45
|
+
const main = h("main");
|
|
46
|
+
document.body.replaceChildren(top.element, main);
|
|
47
|
+
|
|
48
|
+
const show = () => {
|
|
49
|
+
const at = parseHash();
|
|
50
|
+
view?.dispose?.();
|
|
51
|
+
view = views[at.route](store, at);
|
|
52
|
+
top.setRoute(at.route);
|
|
53
|
+
main.replaceChildren(view.element);
|
|
54
|
+
main.scrollTop = 0;
|
|
55
|
+
};
|
|
56
|
+
addEventListener("hashchange", show);
|
|
57
|
+
show();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
start().catch((error: unknown) =>
|
|
61
|
+
document.body.replaceChildren(
|
|
62
|
+
h(
|
|
63
|
+
"div",
|
|
64
|
+
{ class: "fatal" },
|
|
65
|
+
h("h1", {}, "The debugger could not start"),
|
|
66
|
+
h("p", {}, error instanceof Error ? error.message : String(error)),
|
|
67
|
+
h(
|
|
68
|
+
"p",
|
|
69
|
+
{ class: "dim" },
|
|
70
|
+
"Check that the debugger host is running and, under the agent, that the URL carries a valid ?token=.",
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type Health, type View, deviceHealth, empty, h, healthBadge } from "./dom.ts";
|
|
2
|
+
import { formatAge, formatClock, formatInterval } from "./format.ts";
|
|
3
|
+
import { isActive, jobRow, jobTable } from "./jobs.ts";
|
|
4
|
+
import { recentEntry } from "./recent.ts";
|
|
5
|
+
import type { Store } from "./store.ts";
|
|
6
|
+
|
|
7
|
+
const HEALTH_ORDER: Health[] = ["fault", "stale", "unknown", "ok"];
|
|
8
|
+
const RECENT_EVENTS = 12;
|
|
9
|
+
|
|
10
|
+
export const overviewView = (store: Store): View => {
|
|
11
|
+
const connection = h("section", { class: "panel" });
|
|
12
|
+
const devices = h("section", { class: "panel" });
|
|
13
|
+
const jobs = h("section", { class: "panel wide" });
|
|
14
|
+
const events = h("section", { class: "panel wide" });
|
|
15
|
+
|
|
16
|
+
const renderConnection = () => {
|
|
17
|
+
const { session } = store;
|
|
18
|
+
connection.replaceChildren(
|
|
19
|
+
h("h2", {}, "Connection"),
|
|
20
|
+
h(
|
|
21
|
+
"dl",
|
|
22
|
+
{ class: "facts" },
|
|
23
|
+
h("dt", {}, "Values"),
|
|
24
|
+
h(
|
|
25
|
+
"dd",
|
|
26
|
+
{},
|
|
27
|
+
h("span", { class: `st st-${session.connection.state}` }, session.connection.state),
|
|
28
|
+
` since ${formatClock(session.connection.since)} (${formatAge(store.ageOf(session.connection.since))})`,
|
|
29
|
+
),
|
|
30
|
+
h("dt", {}, "Endpoint"),
|
|
31
|
+
h("dd", { class: "mono" }, session.connection.endpoint),
|
|
32
|
+
session.connection.error && h("dt", {}, "Error"),
|
|
33
|
+
session.connection.error && h("dd", { class: "err" }, session.connection.error),
|
|
34
|
+
h("dt", {}, "Debugger host"),
|
|
35
|
+
h("dd", {}, store.stream === "open" ? "streaming" : `${store.stream}…`),
|
|
36
|
+
h("dt", {}, "Reading"),
|
|
37
|
+
h(
|
|
38
|
+
"dd",
|
|
39
|
+
{},
|
|
40
|
+
`${session.reading.method}, every ${formatInterval(session.reading.intervalMs)}; values older than ${formatInterval(3 * session.reading.intervalMs)} are marked stale`,
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const renderDevices = () => {
|
|
47
|
+
const healths = store.manifest.devices.map((device) => ({
|
|
48
|
+
device,
|
|
49
|
+
health: deviceHealth(store, device),
|
|
50
|
+
}));
|
|
51
|
+
const attention = healths.filter(({ health }) => health.health !== "ok");
|
|
52
|
+
devices.replaceChildren(
|
|
53
|
+
h("h2", {}, h("a", { href: "#/hardware" }, "Devices")),
|
|
54
|
+
h(
|
|
55
|
+
"div",
|
|
56
|
+
{ class: "counts" },
|
|
57
|
+
...HEALTH_ORDER.map((health) =>
|
|
58
|
+
h(
|
|
59
|
+
"div",
|
|
60
|
+
{ class: `count-box h-${health}` },
|
|
61
|
+
h("b", {}, String(healths.filter((entry) => entry.health.health === health).length)),
|
|
62
|
+
h("span", {}, health),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
...attention.map(({ device, health }) =>
|
|
67
|
+
h(
|
|
68
|
+
"div",
|
|
69
|
+
{ class: "device-line" },
|
|
70
|
+
healthBadge(health),
|
|
71
|
+
" ",
|
|
72
|
+
h("a", { href: `#/hardware/${encodeURIComponent(device.id)}`, class: "mono" }, device.id),
|
|
73
|
+
h("span", { class: "dim" }, ` ${health.detail}`),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// The jobs this page reads: the program's own, as the Jobs page shows them by default.
|
|
80
|
+
const shown = new Set(store.manifest.jobs.filter((job) => !job.generated).map(({ id }) => id));
|
|
81
|
+
const renderJobs = () => {
|
|
82
|
+
const active = [...store.jobs.values()].filter((job) => shown.has(job.id) && isActive(job));
|
|
83
|
+
jobs.replaceChildren(
|
|
84
|
+
h("h2", {}, h("a", { href: "#/jobs" }, "Active jobs")),
|
|
85
|
+
h("p", { class: "hint" }, "Busy, or failed since they last started."),
|
|
86
|
+
active.length
|
|
87
|
+
? jobTable(active.map((job) => jobRow(store, job)))
|
|
88
|
+
: empty("No job is busy or failed."),
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const renderEvents = () => {
|
|
93
|
+
const recent = store.timeline
|
|
94
|
+
.filter((entry) => entry.kind === "app" || entry.kind === "trace")
|
|
95
|
+
.slice(-RECENT_EVENTS)
|
|
96
|
+
.reverse();
|
|
97
|
+
events.replaceChildren(
|
|
98
|
+
h("h2", {}, h("a", { href: "#/recent" }, "Recent events")),
|
|
99
|
+
...(recent.length
|
|
100
|
+
? recent.map((entry) => recentEntry(store, entry))
|
|
101
|
+
: [empty("The program has reported nothing recently.")]),
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
renderConnection();
|
|
106
|
+
renderDevices();
|
|
107
|
+
renderJobs();
|
|
108
|
+
renderEvents();
|
|
109
|
+
store.show({
|
|
110
|
+
health: store.manifest.devices.filter((device) => device.health).map(({ id }) => id),
|
|
111
|
+
jobs: [...shown],
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
element: h("div", { class: "page overview" }, connection, devices, jobs, events),
|
|
115
|
+
update(topics) {
|
|
116
|
+
const live = topics.has("session") || topics.has("stream");
|
|
117
|
+
if (live || topics.has("clock")) renderConnection();
|
|
118
|
+
if (live || topics.has("devices")) renderDevices();
|
|
119
|
+
if (live || topics.has("jobs") || topics.has("timeline")) renderJobs();
|
|
120
|
+
if (topics.has("timeline")) renderEvents();
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
};
|
package/src/ui/page.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
const css = `
|
|
2
|
+
:root{color-scheme:dark;
|
|
3
|
+
--bg:#0c0e11;--grid:rgba(255,255,255,.025);--panel:#13161b;--panel-2:#191d23;--line:#262b33;--line-soft:#1d2128;
|
|
4
|
+
--fg:#d8dce2;--dim:#7c848f;--accent:#f2a33a;--link:#79c0ff;
|
|
5
|
+
--live:#5ee0a0;--uncertain:#f3c64a;--bad:#ff6b6b;--stale:#8a919b;--unavailable:#5a616b;--constant:#b8a2ff;
|
|
6
|
+
--hatch:rgba(138,145,155,.16);
|
|
7
|
+
--mono:"JetBrains Mono","Cascadia Mono","IBM Plex Mono","SF Mono",Menlo,Consolas,monospace;
|
|
8
|
+
--sans:"IBM Plex Sans","Segoe UI Variable Text","Segoe UI","Helvetica Neue",sans-serif}
|
|
9
|
+
@media (prefers-color-scheme:light){:root{color-scheme:light;
|
|
10
|
+
--bg:#f3f1ec;--grid:rgba(0,0,0,.035);--panel:#fffefb;--panel-2:#f6f4ef;--line:#d8d3c8;--line-soft:#e7e3da;
|
|
11
|
+
--fg:#1b1e22;--dim:#6a6e75;--accent:#b5650a;--link:#0a5bb0;
|
|
12
|
+
--live:#0d8048;--uncertain:#9a6400;--bad:#c42525;--stale:#767b83;--unavailable:#a1a6ad;--constant:#6447cc;
|
|
13
|
+
--hatch:rgba(118,123,131,.16)}}
|
|
14
|
+
*{box-sizing:border-box}
|
|
15
|
+
html,body{height:100%}
|
|
16
|
+
body{margin:0;font:13px/1.45 var(--sans);color:var(--fg);background:var(--bg);
|
|
17
|
+
background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);
|
|
18
|
+
background-size:24px 24px;display:grid;grid-template-rows:auto minmax(0,1fr);font-variant-numeric:tabular-nums}
|
|
19
|
+
a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
|
|
20
|
+
code,.mono,.path,.src{font-family:var(--mono);font-size:12px}
|
|
21
|
+
.src,.nowrap,.grid td.num{white-space:nowrap}
|
|
22
|
+
code{color:var(--fg);background:var(--panel-2);border:1px solid var(--line-soft);border-radius:3px;padding:0 4px}
|
|
23
|
+
.dim,.hint,.empty{color:var(--dim)}.hint{margin:4px 0 12px}.empty{margin:8px 0;font-style:italic}
|
|
24
|
+
.err{color:var(--bad)}.num{text-align:right}
|
|
25
|
+
button,input{font:inherit;color:inherit}
|
|
26
|
+
button{background:var(--panel-2);border:1px solid var(--line);border-radius:4px;padding:3px 10px;cursor:pointer}
|
|
27
|
+
button:hover{border-color:var(--dim)}button.primary{background:var(--accent);border-color:var(--accent);color:#16110a;font-weight:600}
|
|
28
|
+
button:disabled{opacity:.5;cursor:default}
|
|
29
|
+
input{background:var(--panel);border:1px solid var(--line);border-radius:4px;padding:4px 8px}
|
|
30
|
+
input:focus,button:focus-visible,.vlist:focus-visible{outline:1px solid var(--accent);outline-offset:1px}
|
|
31
|
+
h1{font:600 18px var(--sans);margin:0 0 12px;letter-spacing:-.01em}
|
|
32
|
+
h2{font:600 10.5px var(--sans);text-transform:uppercase;letter-spacing:.14em;color:var(--dim);margin:0 0 10px}
|
|
33
|
+
h2 a{color:inherit}h3{font:600 12px var(--sans);margin:14px 0 6px}
|
|
34
|
+
|
|
35
|
+
header{background:var(--panel);border-top:2px solid var(--accent);border-bottom:1px solid var(--line)}
|
|
36
|
+
.bar{display:flex;align-items:stretch;gap:0;overflow-x:auto}
|
|
37
|
+
.brand{display:flex;align-items:center;gap:6px;padding:8px 16px;border-right:1px solid var(--line);white-space:nowrap}
|
|
38
|
+
.brand b{font:700 13px var(--mono);letter-spacing:.04em}.brand span{font:500 13px var(--mono);color:var(--accent)}
|
|
39
|
+
.brand .ro{font:600 9.5px var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--dim);border:1px solid var(--line);border-radius:3px;padding:1px 5px;margin-left:6px}
|
|
40
|
+
.readouts{display:flex;flex:1}
|
|
41
|
+
.readout{display:flex;flex-direction:column;justify-content:center;padding:6px 14px;border-right:1px solid var(--line-soft);white-space:nowrap}
|
|
42
|
+
.readout .label{font:600 9px var(--sans);letter-spacing:.16em;text-transform:uppercase;color:var(--dim)}
|
|
43
|
+
.readout .reading{font:12px var(--mono);display:flex;align-items:center;gap:8px}
|
|
44
|
+
.build.match i{color:var(--live);font-style:normal}.build.mismatch{color:var(--bad)}.build.mismatch i{font-style:normal;font-weight:700}
|
|
45
|
+
.task b{color:var(--fg);font-weight:600}
|
|
46
|
+
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;background:var(--unavailable)}
|
|
47
|
+
.dot.c-connected{background:var(--live);box-shadow:0 0 6px var(--live)}.dot.c-lost,.dot.c-failed{background:var(--bad)}.dot.c-unavailable{background:var(--uncertain)}
|
|
48
|
+
.dot.c-connecting{background:var(--uncertain);animation:pulse 1s infinite alternate}
|
|
49
|
+
@keyframes pulse{to{opacity:.3}}
|
|
50
|
+
nav{display:flex;gap:2px;padding:0 10px;border-top:1px solid var(--line-soft)}
|
|
51
|
+
nav a{color:var(--dim);padding:7px 12px 6px;text-transform:capitalize;border-bottom:2px solid transparent;font-weight:500}
|
|
52
|
+
nav a:hover{color:var(--fg);text-decoration:none}nav a.on{color:var(--fg);border-bottom-color:var(--accent)}
|
|
53
|
+
.banner{padding:7px 16px;font-weight:600;display:flex;gap:12px;align-items:center}
|
|
54
|
+
.banner.danger{background:var(--bad);color:#fff}
|
|
55
|
+
.banner.warn{background:repeating-linear-gradient(135deg,var(--uncertain) 0 14px,color-mix(in srgb,var(--uncertain) 85%,#000) 14px 28px);color:#1b1404}
|
|
56
|
+
.banner.error{background:var(--panel-2);color:var(--bad);border-top:1px solid var(--bad)}
|
|
57
|
+
.banner button{margin-left:auto}
|
|
58
|
+
|
|
59
|
+
main{overflow:auto;min-height:0}
|
|
60
|
+
.page{padding:16px 18px 28px;animation:enter .18s ease-out}
|
|
61
|
+
@keyframes enter{from{opacity:0;transform:translateY(3px)}}
|
|
62
|
+
.panel{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:12px 14px}
|
|
63
|
+
.toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
|
|
64
|
+
.check{display:inline-flex;gap:5px;align-items:center;color:var(--dim);cursor:pointer}.check input{margin:0}
|
|
65
|
+
.sep{margin-left:8px}.count{color:var(--dim);font:12px var(--mono);margin-left:auto}
|
|
66
|
+
.tabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px;align-items:center}
|
|
67
|
+
.tab{background:transparent}.tab.on{border-color:var(--accent);color:var(--fg)}
|
|
68
|
+
.tab .n{color:var(--dim);margin-left:6px;font:11px var(--mono)}
|
|
69
|
+
.tag{font:600 9.5px var(--sans);letter-spacing:.08em;text-transform:uppercase;color:var(--dim);border:1px solid var(--line);border-radius:3px;padding:0 4px;margin:0 4px}
|
|
70
|
+
dl{display:grid;grid-template-columns:max-content 1fr;gap:3px 14px;margin:0}dt{color:var(--dim)}dd{margin:0;min-width:0;overflow-wrap:anywhere}
|
|
71
|
+
.facts{margin-bottom:14px}.readings dd{display:flex;gap:8px;align-items:baseline}
|
|
72
|
+
.crumbs{color:var(--dim);margin-bottom:4px}
|
|
73
|
+
|
|
74
|
+
table.grid{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:6px}
|
|
75
|
+
.grid th{font:600 9.5px var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--dim);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--panel)}
|
|
76
|
+
.grid td{padding:6px 10px;border-bottom:1px solid var(--line-soft);vertical-align:top}
|
|
77
|
+
.grid tr:last-child td{border-bottom:0}.link-row{cursor:pointer}.link-row:hover td{background:var(--panel-2)}
|
|
78
|
+
tr.highlight td{background:color-mix(in srgb,var(--accent) 12%,transparent)}
|
|
79
|
+
|
|
80
|
+
.value{font:12.5px var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
81
|
+
.meta{font:11px var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
82
|
+
.meta::before{display:inline-block;width:1.4ch}
|
|
83
|
+
.s-live{color:var(--live)}.meta.s-live::before{content:"●"}
|
|
84
|
+
.s-uncertain{color:var(--uncertain)}.value.s-uncertain{text-decoration:underline dotted}.meta.s-uncertain::before{content:"◐"}
|
|
85
|
+
.s-bad{color:var(--bad)}.value.s-bad{text-decoration:line-through}.meta.s-bad::before{content:"✕"}
|
|
86
|
+
.s-stale{color:var(--stale)}.value.s-stale{background:repeating-linear-gradient(135deg,transparent 0 4px,var(--hatch) 4px 8px)}.meta.s-stale::before{content:"○"}
|
|
87
|
+
.s-unavailable{color:var(--unavailable)}.meta.s-unavailable::before{content:"–"}
|
|
88
|
+
.s-constant{color:var(--constant)}.value.s-constant{font-style:italic}.meta.s-constant::before{content:"◆"}
|
|
89
|
+
.s-group{color:var(--dim)}
|
|
90
|
+
|
|
91
|
+
.variables{height:100%;display:flex;flex-direction:column;padding-bottom:12px}
|
|
92
|
+
.search{flex:1;min-width:280px;max-width:640px;font-family:var(--mono);font-size:12.5px;padding:6px 10px}
|
|
93
|
+
.split{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:12px}
|
|
94
|
+
.listwrap{display:flex;flex-direction:column;min-height:0;background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
|
95
|
+
.vlist{flex:1;overflow:auto;position:relative;outline:none}
|
|
96
|
+
.vspacer{position:relative}.vwindow{position:absolute;left:0;right:0;top:0}
|
|
97
|
+
.row{display:grid;grid-template-columns:minmax(220px,1fr) minmax(90px,15ch) 21ch 17ch 40px minmax(110px,19ch);gap:8px;align-items:center;height:26px;padding-right:10px;border-bottom:1px solid var(--line-soft);cursor:default}
|
|
98
|
+
.row:hover{background:var(--panel-2)}.row.selected{background:color-mix(in srgb,var(--accent) 13%,transparent);box-shadow:inset 2px 0 0 var(--accent)}
|
|
99
|
+
.row.head{font:600 9.5px var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--dim);border-bottom:1px solid var(--line);padding-left:10px}
|
|
100
|
+
.row.generated .name{opacity:.7}
|
|
101
|
+
.name{font:12.5px var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center}
|
|
102
|
+
.name mark{background:color-mix(in srgb,var(--accent) 35%,transparent);color:inherit;border-radius:2px}
|
|
103
|
+
.twisty{display:inline-grid;place-items:center;width:18px;height:18px;flex:none;padding:0;border:0;background:none;color:var(--dim);font-size:10px}
|
|
104
|
+
.type{font:11.5px var(--mono);color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
105
|
+
.row .src{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px}
|
|
106
|
+
.rec{padding:0;width:22px;height:18px;border:1px solid transparent;background:none;color:var(--line);font-size:11px}
|
|
107
|
+
.rec:hover{color:var(--dim);border-color:var(--line)}.rec.on{color:var(--bad)}.rec.elsewhere{color:var(--dim)}
|
|
108
|
+
.side{display:flex;flex-direction:column;gap:12px;overflow:auto;min-height:0}
|
|
109
|
+
.detail-path{font-size:12.5px;overflow-wrap:anywhere;margin-bottom:8px;color:var(--fg)}
|
|
110
|
+
.detail-value{display:flex;gap:12px;align-items:baseline;margin-bottom:10px;padding:8px 10px;background:var(--panel-2);border-radius:4px}
|
|
111
|
+
.detail-value .value{font-size:20px}.detail .site{margin:3px 0}.detail button{margin-top:10px}
|
|
112
|
+
.member{display:grid;grid-template-columns:minmax(0,1fr) auto auto 22px;gap:8px;align-items:center;padding:3px 0;border-bottom:1px solid var(--line-soft)}
|
|
113
|
+
.member .path{font-size:11.5px}.remove{padding:0 5px;border:0;background:none;color:var(--dim)}
|
|
114
|
+
.groups form input{width:110px;padding:2px 6px}
|
|
115
|
+
|
|
116
|
+
.badge{display:inline-block;font:600 10.5px var(--mono);letter-spacing:.04em;padding:1px 7px;border-radius:3px;border:1px solid;text-transform:uppercase}
|
|
117
|
+
.h-ok{color:var(--live);border-color:var(--live)}
|
|
118
|
+
.h-fault{color:#fff;background:var(--bad);border-color:var(--bad)}
|
|
119
|
+
.h-stale{color:var(--stale);border-color:var(--stale);background:repeating-linear-gradient(135deg,transparent 0 4px,var(--hatch) 4px 8px)}
|
|
120
|
+
.h-unknown{color:var(--dim);border-color:var(--dim);border-style:dashed}
|
|
121
|
+
.st{font:600 11px var(--mono);text-transform:lowercase}
|
|
122
|
+
.st-busy,.st-pending,.st-connecting,.st-cancelling{color:var(--uncertain)}
|
|
123
|
+
.st-failed,.st-error,.st-timeout,.st-lost{color:var(--bad)}.st-unavailable{color:var(--uncertain)}
|
|
124
|
+
.st-done,.st-ok,.st-connected{color:var(--live)}.st-idle,.st-unknown{color:var(--dim)}
|
|
125
|
+
.await code{white-space:pre-wrap}
|
|
126
|
+
|
|
127
|
+
.overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;align-items:start}
|
|
128
|
+
.overview .wide{grid-column:1/-1}
|
|
129
|
+
.overview>*{animation:enter .25s ease-out backwards}.overview>:nth-child(2){animation-delay:.04s}.overview>:nth-child(3){animation-delay:.08s}.overview>:nth-child(4){animation-delay:.12s}
|
|
130
|
+
.counts{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px}
|
|
131
|
+
.count-box{border:1px solid;border-radius:5px;padding:6px 10px;display:flex;flex-direction:column}
|
|
132
|
+
.count-box b{font:600 22px var(--mono)}.count-box span{font:600 9.5px var(--sans);letter-spacing:.14em;text-transform:uppercase}
|
|
133
|
+
.count-box.h-fault{background:color-mix(in srgb,var(--bad) 12%,transparent);color:var(--bad)}
|
|
134
|
+
.device-line{padding:3px 0}
|
|
135
|
+
|
|
136
|
+
.assemblies{display:flex;flex-direction:column;gap:12px;margin-bottom:18px}
|
|
137
|
+
.assembly h3{margin:0 0 10px}
|
|
138
|
+
.bitgrid{display:grid;grid-template-columns:4ch repeat(8,52px) 4ch minmax(0,1fr);gap:3px;align-items:stretch}
|
|
139
|
+
.bh{font:600 9.5px var(--mono);color:var(--dim);text-align:center;letter-spacing:.06em}.legend-head{text-align:left;padding-left:6px}
|
|
140
|
+
.byte,.hex{font-size:11.5px;color:var(--dim);align-self:center}
|
|
141
|
+
.bit{position:relative;height:34px;display:flex;flex-direction:column;align-items:center;justify-content:center;font:600 13px var(--mono);
|
|
142
|
+
border:1px dashed var(--line);border-radius:3px;color:var(--unavailable)}
|
|
143
|
+
.bit small{font:9px var(--mono);color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 2px}
|
|
144
|
+
.bit.mapped{border-style:solid;color:var(--dim);background:var(--panel-2)}
|
|
145
|
+
.bit.on{color:var(--fg)}
|
|
146
|
+
.bit.mapped.on{color:var(--live);background:color-mix(in srgb,var(--live) 16%,transparent);border-color:color-mix(in srgb,var(--live) 60%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--live) 25%,transparent)}
|
|
147
|
+
.bit.none{color:var(--unavailable)}
|
|
148
|
+
.q-stale .bit,.q-uncertain .bit,.q-bad .bit{background-image:repeating-linear-gradient(135deg,transparent 0 4px,var(--hatch) 4px 8px)}
|
|
149
|
+
.q-stale .bit.on,.q-bad .bit.on{color:var(--stale);box-shadow:none;border-color:var(--line)}
|
|
150
|
+
.legend{display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;font-size:11.5px;padding-left:6px}
|
|
151
|
+
.legend-item{display:inline-flex;gap:6px;align-items:baseline}.legend-item b{font:600 11px var(--mono)}
|
|
152
|
+
.mapped-path{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline}
|
|
153
|
+
|
|
154
|
+
.recent .entries{display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:6px}
|
|
155
|
+
.entry{display:grid;grid-template-columns:14ch 9ch minmax(0,1fr);gap:10px;padding:7px 12px;border-bottom:1px solid var(--line-soft)}
|
|
156
|
+
.overview .entry{padding:6px 0}
|
|
157
|
+
.when{color:var(--dim);font-size:11.5px}.approx{color:var(--uncertain);margin-left:4px;cursor:help}
|
|
158
|
+
.source{font:600 9.5px var(--sans);letter-spacing:.12em;text-transform:uppercase;padding-top:2px}
|
|
159
|
+
.src-plc{color:var(--accent)}.src-server{color:var(--link)}.src-agent{color:var(--constant)}.src-debugger{color:var(--dim)}
|
|
160
|
+
.body>div{margin:1px 0}.val{font:600 12.5px var(--mono)}
|
|
161
|
+
.chips{display:flex;flex-wrap:wrap;gap:5px;align-items:center;margin-top:3px}
|
|
162
|
+
.chip{display:inline-flex;align-items:baseline;gap:3px;font:11.5px var(--mono);border:1px solid var(--line);border-radius:10px;padding:0 8px;background:var(--panel-2)}
|
|
163
|
+
.chip.unstable{border-style:dashed}
|
|
164
|
+
.tag.lvl-info{color:var(--link);border-color:var(--link)}.tag.lvl-warn{color:var(--uncertain);border-color:var(--uncertain)}.tag.lvl-error{color:var(--bad);border-color:var(--bad)}
|
|
165
|
+
.recent>button{margin-top:10px}
|
|
166
|
+
|
|
167
|
+
.fatal{padding:40px;max-width:720px}.fatal h1{color:var(--bad)}
|
|
168
|
+
@media (max-width:1100px){.split{grid-template-columns:minmax(0,1fr)}.side{max-height:40vh}.overview{grid-template-columns:minmax(0,1fr)}}
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
/** The page shell; `./ui.js` builds everything from the API, so the same page works at `/` and under `/debug/`. */
|
|
172
|
+
export const html = `<!doctype html>
|
|
173
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
174
|
+
<title>tsPLC debug</title><link rel="icon" href="data:,">
|
|
175
|
+
<script>if(!location.pathname.endsWith("/"))location.replace(location.pathname+"/"+location.search+location.hash)</script>
|
|
176
|
+
<style>${css}</style></head>
|
|
177
|
+
<body><p class="empty" style="padding:24px">Loading the debugger…</p>
|
|
178
|
+
<script type="module" src="./ui.js"></script></body></html>`;
|