@mason-data/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CHARTS OF THE WEB PAGE (docs/WEB-UI.md "Presentation"): a sparkline in a KPI card and a small time-series chart
|
|
3
|
+
* (bars or a line), drawn by uPlot (vendored whole: src/web/vendor/uplot-1.6.32/, MIT) and themed from the CSS tokens
|
|
4
|
+
* (`--chart-*`, src/web/app.css). A chart is a PROGRESSIVE ADDITION: every number it draws is also said as text in a
|
|
5
|
+
* card or a table (the tests read the text), so a page reads the same where uPlot never loads.
|
|
6
|
+
*
|
|
7
|
+
* How a page uses them:
|
|
8
|
+
* 1. its renderer writes a holder where the chart goes — `spark(key, label)` or `seriesChart(key, label)` — an empty
|
|
9
|
+
* div that takes no room until it is drawn;
|
|
10
|
+
* 2. its `charts()` (the page module contract, src/web/app.js) returns `{ [key]: spec }`, a spec being
|
|
11
|
+
* `{ kind: "spark" | "bars" | "line", x: seconds[], y: (number | null)[], label, format?(v) → text, unit? }`
|
|
12
|
+
* (`format` says a hovered value exactly, as the tables do; `unit` — "bytes", "ms", else a count — the axis's
|
|
13
|
+
* compact labels and steps: `axisFormat`, `axisIncrs`; the time axis's labels follow the span, `timeTick`)
|
|
14
|
+
* (`seriesOf(samples, field)` makes x and y from /api/usage's samples);
|
|
15
|
+
* 3. the shell, after it writes the page, calls `mount(specs)` of ONE `createCharts`: a chart is made once per key,
|
|
16
|
+
* moved into the holder a rewrite made, given its new data when the data changed, and destroyed when its key or
|
|
17
|
+
* holder is gone — no instance outlives what it draws.
|
|
18
|
+
* DOM-free until `mount`, so the module loads under node; uPlot itself is imported only then.
|
|
19
|
+
*/
|
|
20
|
+
import { esc } from "./shared.js";
|
|
21
|
+
|
|
22
|
+
/** uPlot's module, imported relative to this one (a route of PAGE_ROUTES), only in a browser and only once. */
|
|
23
|
+
const UPLOT = "./vendor/uplot-1.6.32/uPlot.esm.js";
|
|
24
|
+
|
|
25
|
+
const holder = (cls, key, label) => `<div class="chart ${cls}" data-chart="${esc(key)}" role="img" aria-label="${esc(label)}"></div>`;
|
|
26
|
+
/** Where a KPI card's sparkline is drawn: `card(label, value, sub, spark("rows", "Rows, the last 24 hours"))`. */
|
|
27
|
+
export const spark = (key, label) => holder("spark", key, label);
|
|
28
|
+
/** Where a small time-series chart is drawn (above the table that says the same numbers). */
|
|
29
|
+
export const seriesChart = (key, label) => holder("series", key, label);
|
|
30
|
+
|
|
31
|
+
/** A series from /api/usage's samples: x each sample's time in seconds, y its `field` (null where it has none). */
|
|
32
|
+
export function seriesOf(samples, field) {
|
|
33
|
+
return { x: samples.map((s) => Date.parse(s.at) / 1000), y: samples.map((s) => typeof s[field] === "number" ? s[field] : null) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether a spec is worth drawing: two points with a value, at least (one point is no trend). */
|
|
37
|
+
export const drawable = (spec) => spec !== undefined && spec !== null && Array.isArray(spec.x) && spec.x.length >= 2 &&
|
|
38
|
+
spec.y.filter((v) => v !== null).length >= 2;
|
|
39
|
+
|
|
40
|
+
/** The colours and the font a chart is drawn in, from the page's tokens (light or dark: the stylesheet chooses). */
|
|
41
|
+
function themeOf(doc, win) {
|
|
42
|
+
const cs = win.getComputedStyle(doc.documentElement);
|
|
43
|
+
const v = (k, d) => cs.getPropertyValue(k).trim() || d;
|
|
44
|
+
return { trend: v("--chart-trend", "#a8a8a8"), mark: v("--chart-mark", "#0070f3"), grid: v("--chart-grid", "#ebebeb"),
|
|
45
|
+
text: v("--chart-text", "#8f8f8f"), font: v("--font-mono", "monospace") };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The index of a series' last value: where a sparkline's one marker goes (the latest period, in the accent). */
|
|
49
|
+
const lastIndex = (y) => { for (let i = y.length - 1; i >= 0; i--) if (y[i] !== null) return i; return -1; };
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------------ the axes' words (pure: the tests hold each)
|
|
52
|
+
|
|
53
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A TIME TICK'S LABEL for a chart spanning `span` seconds, in UTC as the tables say it: minutes and hours (`09:05`,
|
|
57
|
+
* `14:00`) up to two days; past that a day (`Oct 27`) at midnight and the hour between (`12:00`), so a week reads by
|
|
58
|
+
* its days. Short enough that ticks 72 px apart never touch.
|
|
59
|
+
*/
|
|
60
|
+
export function timeTick(sec, span) {
|
|
61
|
+
const d = new Date(sec * 1000);
|
|
62
|
+
const hm = d.toISOString().slice(11, 16);
|
|
63
|
+
if (span <= 2 * 86_400) return hm;
|
|
64
|
+
return hm === "00:00" ? `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}` : hm;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The hovered time, read out under the chart: the minute alone within three hours, else its date too (`2026-10-27 09:00 UTC`). */
|
|
68
|
+
export function readoutTime(sec, span) {
|
|
69
|
+
const iso = new Date(sec * 1000).toISOString();
|
|
70
|
+
return `${span <= 3 * 3600 ? iso.slice(11, 16) : `${iso.slice(0, 10)} ${iso.slice(11, 16)}`} UTC`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const trim1 = (x) => (x >= 100 ? String(Math.round(x)) : x.toFixed(1).replace(/\.0$/, ""));
|
|
74
|
+
|
|
75
|
+
/** A count on an axis: `0`, `950`, `1.2K`, `3.4M` (the read-out and the table have it whole). */
|
|
76
|
+
export const compactCount = (v) => new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(v);
|
|
77
|
+
|
|
78
|
+
/** Bytes on an axis, in the units the commands use (KiB, MiB, GiB) and on to TiB and PiB: `512 B`, `3.4 MiB`, `1.5 TiB`. */
|
|
79
|
+
export function compactBytes(v) {
|
|
80
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
|
|
81
|
+
let i = 0, x = Math.abs(v);
|
|
82
|
+
while (x >= 1024 && i < units.length - 1) { x /= 1024; i++; }
|
|
83
|
+
return `${v < 0 ? "-" : ""}${i === 0 ? Math.round(x) : trim1(x)} ${units[i]}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A duration in ms on an axis: `512 ms`, `1.2 s`, `2.5 min`, `1.1 h`. */
|
|
87
|
+
export function compactMs(v) {
|
|
88
|
+
if (v < 1000) return `${Math.round(v)} ms`;
|
|
89
|
+
if (v < 60_000) return `${trim1(v / 1000)} s`;
|
|
90
|
+
if (v < 3_600_000) return `${trim1(v / 60_000)} min`;
|
|
91
|
+
return `${trim1(v / 3_600_000)} h`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An axis's labeller by a spec's unit: "bytes", "ms", else a count. */
|
|
95
|
+
const axisFormat = (unit) => unit === "bytes" ? compactBytes : unit === "ms" ? compactMs : compactCount;
|
|
96
|
+
|
|
97
|
+
/** The steps an axis's ticks are chosen from, by unit: whole numbers for a count or ms (never 0.5 of a statement), and
|
|
98
|
+
* powers of 1024 for bytes, so a tick is 512 MiB or 1 GiB, never 477 MiB. */
|
|
99
|
+
export function axisIncrs(unit) {
|
|
100
|
+
const bases = unit === "bytes" ? [1, 1024, 1024 ** 2, 1024 ** 3, 1024 ** 4, 1024 ** 5] : [1, 10, 100, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12];
|
|
101
|
+
const mults = unit === "bytes" ? [1, 2, 4, 8, 16, 32, 64, 128, 256, 512] : [1, 2, 5];
|
|
102
|
+
return [...new Set(bases.flatMap((b) => mults.map((m) => m * b)))].sort((a, b) => a - b);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The steps a time axis's ticks are chosen from, in seconds: whole minutes at the least (a tick between two minutes would
|
|
106
|
+
* read as the minute twice), then hours, then days. */
|
|
107
|
+
const TIME_INCRS = [60, 120, 300, 600, 900, 1800, 3600, 7200, 10_800, 21_600, 43_200, 86_400, 2 * 86_400, 7 * 86_400];
|
|
108
|
+
|
|
109
|
+
/** The y range from a zero baseline, a little over the highest value (1 when all are 0). A spike keeps its height: the
|
|
110
|
+
* axis is never cut, so the bar that is ten times the others reads as ten times (the table has every value). */
|
|
111
|
+
export const zeroRange = (max) => [0, max > 0 ? max * 1.08 : 1];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* UPLOT'S OPTIONS FOR A SPEC (pure): a sparkline is the trend alone — no axis, legend or cursor, a 2px line in the
|
|
115
|
+
* de-emphasis gray from a zero baseline, its latest value an 8px dot in the accent; a series chart has recessive axes
|
|
116
|
+
* (UTC hours, the value's own format), one hue, bars with a 2px gap or a 2px line, and a cursor whose live legend is
|
|
117
|
+
* the hover read-out.
|
|
118
|
+
*/
|
|
119
|
+
export function chartOptions(spec, theme, width, height, uPlot) {
|
|
120
|
+
const fmt = spec.format ?? ((v) => String(v));
|
|
121
|
+
const zero = (_u, _min, max) => zeroRange(max);
|
|
122
|
+
if (spec.kind === "spark") {
|
|
123
|
+
return {
|
|
124
|
+
width, height, padding: [6, 6, 6, 6], legend: { show: false }, cursor: { show: false }, select: { show: false },
|
|
125
|
+
scales: { x: { time: false }, y: { range: zero } }, axes: [{ show: false }, { show: false }],
|
|
126
|
+
series: [{}, { label: spec.label, stroke: theme.trend, width: 2, spanGaps: false,
|
|
127
|
+
points: { show: true, size: 8, width: 0, fill: theme.mark, stroke: theme.mark, filter: (u, si) => { const i = lastIndex(u.data[si]); return i < 0 ? null : [i]; } } }],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const bars = spec.kind === "bars" && typeof uPlot?.paths?.bars === "function";
|
|
131
|
+
const axisFont = `11px ${theme.font}`;
|
|
132
|
+
const span = spec.x.length ? spec.x[spec.x.length - 1] - spec.x[0] : 0;
|
|
133
|
+
const axis = axisFormat(spec.unit);
|
|
134
|
+
return {
|
|
135
|
+
width, height, padding: [10, 24, 0, 0], legend: { show: true, live: true }, select: { show: false },
|
|
136
|
+
cursor: { drag: { x: false, y: false }, points: { size: 8, fill: theme.mark } },
|
|
137
|
+
...(typeof uPlot?.tzDate === "function" ? { tzDate: (ts) => uPlot.tzDate(new Date(ts * 1000), "Etc/UTC") } : {}),
|
|
138
|
+
scales: { x: { time: true }, y: { range: zero } },
|
|
139
|
+
axes: [
|
|
140
|
+
{ stroke: theme.text, font: axisFont, space: 72, incrs: TIME_INCRS, grid: { show: false }, ticks: { stroke: theme.grid, width: 1, size: 4 }, values: (_u, vals) => vals.map((v) => timeTick(v, span)) },
|
|
141
|
+
{ stroke: theme.text, font: axisFont, size: 64, space: 28, incrs: axisIncrs(spec.unit), grid: { stroke: theme.grid, width: 1 }, ticks: { show: false }, values: (_u, vals) => vals.map(axis) },
|
|
142
|
+
],
|
|
143
|
+
series: [
|
|
144
|
+
// the read-out under the chart: the hovered time and its EXACT value (the axis is compact; the read-out is the table's)
|
|
145
|
+
{ label: "At", value: (_u, v) => v === null ? "–" : readoutTime(v, span) },
|
|
146
|
+
{ label: spec.label, stroke: theme.mark, width: bars ? 0 : 2, ...(bars ? { fill: theme.mark, paths: uPlot.paths.bars({ size: [0.7, 48], gap: 2 }) } : {}),
|
|
147
|
+
points: { show: false }, value: (_u, v) => v === null ? "–" : fmt(v) },
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A series chart's plot, in px (its live read-out is under it). */
|
|
153
|
+
const SERIES_HEIGHT = 160;
|
|
154
|
+
|
|
155
|
+
/** What a spec draws, as one string: a chart is given new data only when this changed. */
|
|
156
|
+
const signature = (spec) => `${spec.kind}|${spec.x.join(",")}|${spec.y.join(",")}`;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* THE CHARTS OF A DOCUMENT, made, moved, updated and destroyed by key (see the top of this file). `load` gives uPlot's
|
|
160
|
+
* constructor (the vendored module by default; a test passes its own, or one that fails). When uPlot cannot load the
|
|
161
|
+
* page still reads whole — every number is text — so the failure is said once in the console and nothing is drawn.
|
|
162
|
+
*/
|
|
163
|
+
export function createCharts({ doc, win, load = () => import(UPLOT).then((m) => m.default) }) {
|
|
164
|
+
const live = new Map(); // key → { el, plot, sig }
|
|
165
|
+
let lib = null, loading = null, failed = false, last = {};
|
|
166
|
+
const holders = () => new Map([...doc.querySelectorAll("[data-chart]")].map((el) => [el.dataset.chart, el]));
|
|
167
|
+
// EACH CHART AS WIDE AS ITS HOLDER, whenever the holder's width changes — a window resized, a grid that reflowed, the
|
|
168
|
+
// sidebar become a rail — not only on the window's resize: a chart sized once at a wider holder stood out of its card
|
|
169
|
+
const watched = new Map(); // holder → key
|
|
170
|
+
const observer = typeof win.ResizeObserver === "function" ? new win.ResizeObserver((entries) => {
|
|
171
|
+
for (const e of entries) {
|
|
172
|
+
const key = watched.get(e.target), c = key === undefined ? undefined : live.get(key);
|
|
173
|
+
if (c !== undefined && c.width !== size(c.el, last[key]?.kind).width) fit(key, c);
|
|
174
|
+
}
|
|
175
|
+
}) : null;
|
|
176
|
+
const watch = (key, el) => { if (observer !== null && watched.get(el) !== key) { observer.observe(el); watched.set(el, key); } };
|
|
177
|
+
const unwatch = (el) => { if (observer !== null && watched.has(el)) { observer.unobserve(el); watched.delete(el); } };
|
|
178
|
+
const fit = (key, c) => { const s = size(c.el, last[key]?.kind); c.plot.setSize(s); c.width = s.width; };
|
|
179
|
+
const destroy = (key) => { const c = live.get(key); if (c !== undefined) { unwatch(c.el); c.plot.destroy(); } live.delete(key); };
|
|
180
|
+
// a holder is shown (`drawn`: app.css hides one not drawn, so it takes no room without uPlot) before it is measured;
|
|
181
|
+
// a sparkline fills its holder, a series chart is its plot's height and its read-out under it
|
|
182
|
+
const show = (el) => { el.classList?.add("drawn"); return el; };
|
|
183
|
+
const size = (el, kind) => ({ width: Math.max(40, Math.floor(el.clientWidth || 200)), height: kind === "spark" ? Math.max(24, Math.floor(el.clientHeight || 48)) : SERIES_HEIGHT });
|
|
184
|
+
function draw(specs) {
|
|
185
|
+
const at = holders();
|
|
186
|
+
// a chart whose key, data or holder is gone is destroyed: a rewrite of the part replaced its holder, and nothing else holds it
|
|
187
|
+
for (const key of [...live.keys()]) if (!drawable(specs[key]) || !at.has(key)) destroy(key);
|
|
188
|
+
const theme = themeOf(doc, win);
|
|
189
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
190
|
+
const el = at.get(key);
|
|
191
|
+
if (!drawable(spec) || el === undefined) continue;
|
|
192
|
+
const c = live.get(key);
|
|
193
|
+
if (c === undefined) {
|
|
194
|
+
const { width, height } = size(show(el), spec.kind);
|
|
195
|
+
live.set(key, { el, width, plot: new lib(chartOptions(spec, theme, width, height, lib), [spec.x, spec.y], el), sig: signature(spec) });
|
|
196
|
+
watch(key, el);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// the part was written again: the same chart, moved into the new holder
|
|
200
|
+
if (c.el !== el) { unwatch(c.el); show(el).appendChild(c.plot.root); c.el = el; fit(key, c); watch(key, el); }
|
|
201
|
+
const sig = signature(spec);
|
|
202
|
+
if (sig !== c.sig) { c.plot.setData([spec.x, spec.y]); c.sig = sig; }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
/** Draw these specs where their holders are (the shell calls it after each write); true once uPlot is there. */
|
|
207
|
+
async mount(specs) {
|
|
208
|
+
last = specs;
|
|
209
|
+
if (failed) return false;
|
|
210
|
+
if (lib === null) {
|
|
211
|
+
loading ??= load().then((l) => { lib = l; }, (e) => {
|
|
212
|
+
// tolerated: the charts are an addition over text the page already says; said once, then never tried again
|
|
213
|
+
failed = true;
|
|
214
|
+
win.console?.warn?.(`the page's charts are not drawn (its numbers are in its tables): ${e?.message ?? e}`);
|
|
215
|
+
});
|
|
216
|
+
await loading;
|
|
217
|
+
if (lib === null) return false;
|
|
218
|
+
}
|
|
219
|
+
draw(last);
|
|
220
|
+
return true;
|
|
221
|
+
},
|
|
222
|
+
/** The theme changed (light ↔ dark): every chart made again in the new colours. */
|
|
223
|
+
retheme() { for (const key of [...live.keys()]) destroy(key); if (lib !== null) draw(last); },
|
|
224
|
+
/** The window was resized: each chart to its holder's size. */
|
|
225
|
+
resize() { for (const [key, c] of live) fit(key, c); },
|
|
226
|
+
/** How many charts are alive (the tests: none leaks). */
|
|
227
|
+
count: () => live.size,
|
|
228
|
+
};
|
|
229
|
+
}
|
package/dist/src/web/index.html
CHANGED
|
@@ -6,39 +6,103 @@
|
|
|
6
6
|
<title>Mason</title>
|
|
7
7
|
<!-- no favicon request: an empty one, inline (img-src allows data:) -->
|
|
8
8
|
<link rel="icon" href="data:,">
|
|
9
|
+
<!-- every file Mason's own, from its allowlist (src/cli/run-server.ts PAGE_ROUTES): the fonts are app.css's, vendored -->
|
|
9
10
|
<link rel="stylesheet" href="/app.css">
|
|
11
|
+
<link rel="stylesheet" href="/vendor/uplot-1.6.32/uPlot.min.css">
|
|
10
12
|
<script type="module" src="/app.js"></script>
|
|
11
13
|
</head>
|
|
12
14
|
<body>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<
|
|
17
|
-
</
|
|
15
|
+
<!-- the shell (src/web/app.js): the sidebar — the project, the nav, when it was updated and the version — and the page
|
|
16
|
+
beside it. Below 1000 px the sidebar is an icon rail; below 640 px a top bar whose menu button opens the nav as a sheet -->
|
|
17
|
+
<aside id="sidebar" class="sidebar">
|
|
18
|
+
<div class="side-head">
|
|
19
|
+
<button type="button" id="menu-button" class="menu-button" data-menu aria-controls="side-body" aria-expanded="false"><span id="menu-icon"></span><span class="sr">Menu</span></button>
|
|
20
|
+
<h1 class="brand"><span class="logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor" focusable="false"><path d="M3 3h8.25v5H3zM12.75 3H21v5h-8.25zM3 9.5h3.5L8 14.5H3zM8 9.5h8l-1.5 5h-5zM17.5 9.5H21v5h-5zM3 16h6v5H3zM15 16h6v5h-6z"/></svg></span><span class="brand-name">Mason</span></h1>
|
|
21
|
+
<div class="project"><p id="where" class="where"></p><span id="badge"></span></div>
|
|
22
|
+
</div>
|
|
23
|
+
<div id="side-body" class="side-body">
|
|
24
|
+
<!-- the pages: one section each, the one shown chosen by the URL's hash; the links work before the script runs, which adds their icons -->
|
|
25
|
+
<nav id="nav" class="nav" aria-label="Pages"><ul><li><a href="#overview"><span class="label">Overview</span></a></li><li><a href="#tables"><span class="label">Tables</span></a></li><li><a href="#users"><span class="label">Users</span></a></li><li class="group"><p class="group-title" id="nav-observability">Observability</p><ul aria-labelledby="nav-observability"><li><a href="#observability/queries"><span class="label">Queries</span></a></li><li><a href="#observability/usage"><span class="label">Usage</span></a></li><li><a href="#observability/syncs"><span class="label">Sync events</span></a></li></ul></li></ul></nav>
|
|
26
|
+
<div class="side-foot"><p id="updated" class="updated" aria-live="polite"></p><p id="version" class="version"></p></div>
|
|
27
|
+
</div>
|
|
28
|
+
</aside>
|
|
29
|
+
<div class="scrim" aria-hidden="true"></div>
|
|
30
|
+
<div class="content">
|
|
18
31
|
<div id="poll"></div>
|
|
19
32
|
<main>
|
|
20
|
-
<section aria-labelledby="status-h">
|
|
21
|
-
<h2 id="status-h">
|
|
22
|
-
<div id="status"><p class="dim">Asking Mason…</p></div>
|
|
33
|
+
<section id="page-status" aria-labelledby="status-h">
|
|
34
|
+
<div class="page-head"><h2 id="status-h">Overview</h2><p class="desc">Mason syncs the tables you add from your Postgres, keeps them current, and answers analytics SQL at a Postgres URL of its own. Here: whether the synced data is ready, how to connect, and what it answered — what <code>mason status</code> says.</p></div>
|
|
35
|
+
<div id="status"><div class="loading" aria-busy="true"><p class="dim">Asking Mason…</p><span class="skeleton block"></span></div></div>
|
|
23
36
|
</section>
|
|
24
|
-
<section aria-labelledby="tables-h">
|
|
25
|
-
<h2 id="tables-h">Tables</h2>
|
|
26
|
-
<
|
|
37
|
+
<section id="page-tables" aria-labelledby="tables-h" hidden>
|
|
38
|
+
<div class="page-head"><h2 id="tables-h">Tables</h2><p class="desc">The database's tables, as <code>mason tables</code> lists them. Add one and Mason syncs it: it is added to the sources file, as <code>mason add</code> does; Remove takes it out and drops its synced data, as <code>mason remove</code> does.</p></div>
|
|
39
|
+
<div id="tables-cards"></div>
|
|
40
|
+
<!-- each label in a field with its control, as the request filters are: on a phone a field is a row, never a label left at a line's end -->
|
|
41
|
+
<div class="toolbar">
|
|
42
|
+
<div class="field grow"><label for="tables-filter">Search the tables</label>
|
|
43
|
+
<input type="search" id="tables-filter" autocomplete="off" spellcheck="false" placeholder="orders"></div>
|
|
44
|
+
<div class="field"><label for="tables-show">Show</label>
|
|
45
|
+
<select id="tables-show"><option value="all">All</option><option value="added">Added</option><option value="failed">Failed</option><option value="not-added">Not added</option></select></div>
|
|
46
|
+
</div>
|
|
27
47
|
<div id="tables"></div>
|
|
28
48
|
</section>
|
|
29
|
-
<section aria-labelledby="
|
|
30
|
-
|
|
31
|
-
<p class="
|
|
32
|
-
<
|
|
33
|
-
<form id="
|
|
49
|
+
<section id="page-queries" aria-labelledby="queries-h" hidden>
|
|
50
|
+
<!-- an Observability view's head is ONE row: its title left, the time range the three share right (src/web/app.js renderSubnav) -->
|
|
51
|
+
<div class="page-head"><div class="head-row"><h2 id="queries-h">Queries</h2><div id="range-queries" class="head-range"></div></div><p class="desc">Every statement the Postgres API answered, refused or cancelled, as <code>mason logs --requests</code> lists them, newest first, over the time range. Who is connected, what runs now and the range's numbers are on <a href="#observability/usage">Usage</a>.</p></div>
|
|
52
|
+
<p class="lead">Since, when given, is used instead: an age (30m, 2h, 7d) or an ISO 8601 date or time with its zone. A statement that reads no synced table (select 1, SET, SHOW, a BI tool's catalog queries) is answered but not listed.</p>
|
|
53
|
+
<form id="request-filters" class="toolbar" autocomplete="off">
|
|
54
|
+
<div class="field"><label for="rq-status">Status</label>
|
|
55
|
+
<select id="rq-status" name="status"><option value="">All</option><option value="completed">Completed</option><option value="refused">Refused</option><option value="failed">Failed</option><option value="refused-or-failed">Refused or failed</option><option value="cancelled">Cancelled</option></select></div>
|
|
56
|
+
<div class="field"><label for="rq-since">Since</label>
|
|
57
|
+
<input id="rq-since" name="since" placeholder="2h" spellcheck="false"></div>
|
|
58
|
+
<div class="field"><label for="rq-user">User</label>
|
|
59
|
+
<input id="rq-user" name="user" spellcheck="false"></div>
|
|
60
|
+
<div class="field"><label for="rq-client">Client</label>
|
|
61
|
+
<input id="rq-client" name="client" spellcheck="false"></div>
|
|
62
|
+
<div class="field grow"><label for="rq-search">Statement has</label>
|
|
63
|
+
<input id="rq-search" name="search" type="search" maxlength="200" spellcheck="false" placeholder="orders"></div>
|
|
64
|
+
<label class="check" for="rq-slow"><input type="checkbox" id="rq-slow" name="slow"> Slow (1 s or more)</label>
|
|
65
|
+
<div class="actions"><button type="submit" class="primary">Filter</button>
|
|
66
|
+
<button type="button" data-clear-filters>Clear</button></div>
|
|
67
|
+
</form>
|
|
68
|
+
<div id="request-log"></div>
|
|
69
|
+
</section>
|
|
70
|
+
<section id="page-users" aria-labelledby="users-h" hidden>
|
|
71
|
+
<div class="page-head"><h2 id="users-h">Users</h2><p class="desc">A user is the name and password psql, a BI tool or your app connects to Mason with; it does not expire. The same users as <code>mason user</code>.</p></div>
|
|
72
|
+
<form id="add-user" class="formcard" autocomplete="off">
|
|
34
73
|
<label for="user-name">Name</label>
|
|
35
74
|
<input id="user-name" name="name" required placeholder="metabase" spellcheck="false" autocapitalize="off">
|
|
36
|
-
<button type="submit">Add a user</button>
|
|
75
|
+
<button type="submit" class="primary">Add a user</button>
|
|
37
76
|
</form>
|
|
38
77
|
<div id="action"></div>
|
|
78
|
+
<div id="added"></div>
|
|
79
|
+
<div id="users-connect"></div>
|
|
39
80
|
<div id="users"></div>
|
|
40
81
|
</section>
|
|
82
|
+
<section id="page-usage" aria-labelledby="usage-h" hidden>
|
|
83
|
+
<div class="page-head"><div class="head-row"><h2 id="usage-h">Usage</h2><div id="range-usage" class="head-range"></div></div><p class="desc">What the Postgres API answered over the time range — who connected, what ran, the SQL it refused as unsupported — and each synced table's size, as <code>mason status</code>, <code>mason logs</code> and <code>mason usage</code> say them. A client or a card leads to its statements on <a href="#observability/queries">Queries</a>.</p></div>
|
|
84
|
+
<div id="usage-kpis"></div>
|
|
85
|
+
<div id="usage-charts"></div>
|
|
86
|
+
<h3 id="usage-connected-h">Connected now</h3>
|
|
87
|
+
<div id="usage-connected"></div>
|
|
88
|
+
<h3 id="usage-running-h">Running now</h3>
|
|
89
|
+
<p class="lead" id="usage-running-lead"></p>
|
|
90
|
+
<div id="usage-running"></div>
|
|
91
|
+
<h3>Unsupported SQL</h3>
|
|
92
|
+
<div id="unsupported"></div>
|
|
93
|
+
<h3>Over time</h3>
|
|
94
|
+
<div id="usage-hourly"></div>
|
|
95
|
+
<h3>Each synced table's own size</h3>
|
|
96
|
+
<div id="usage-sizes"></div>
|
|
97
|
+
</section>
|
|
98
|
+
<section id="page-syncs" aria-labelledby="syncs-h" hidden>
|
|
99
|
+
<div class="page-head"><div class="head-row"><h2 id="syncs-h">Sync events</h2><div id="range-syncs" class="head-range"></div></div><p class="desc">The sync's events — each first sync, each failure and recovery, each sync asked by hand — as <code>mason logs --events</code> lists them, and the run's own log behind a details. A scheduled poll that succeeds is not listed: the last one's result is each table's last sync (mason status, the Tables page's Last sync).</p></div>
|
|
100
|
+
<div class="toolbar"><div id="syncs-filters" class="fields"></div><label class="check" for="syncs-failures-only"><input type="checkbox" id="syncs-failures-only"> Failures only</label></div>
|
|
101
|
+
<div id="syncs-events"></div>
|
|
102
|
+
<div id="syncs-run"></div>
|
|
103
|
+
</section>
|
|
41
104
|
</main>
|
|
42
|
-
|
|
105
|
+
</div>
|
|
106
|
+
<noscript><p>This page needs JavaScript. <code>mason status</code>, <code>mason tables</code>, <code>mason user list</code>, <code>mason logs</code> and <code>mason usage</code> say the same in a terminal.</p></noscript>
|
|
43
107
|
</body>
|
|
44
108
|
</html>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE QUERIES VIEW (#observability/queries, a view of the Observability group; `#queries`, its old address, lands here):
|
|
3
|
+
* the request log (`mason logs --requests`, GET /api/requests) and nothing else — who is connected, what runs and the
|
|
4
|
+
* range's numbers are Usage's (src/web/pages/usage.js). A table (a status dot and word, each row expanding in place and
|
|
5
|
+
* staying open across polls), newest first, filtered by status, slow (SLOW_MS or more), a piece of the statement's text,
|
|
6
|
+
* since, user and client — since is the shared time range unless the filter names its own — and paged back by Older;
|
|
7
|
+
* Started, Duration and Rows sort the page shown (the pages follow the log's order by start: a sort of the whole log
|
|
8
|
+
* would need a cursor per order). Read every 5 s while the range is Now, once when another is chosen. A statement is one
|
|
9
|
+
* or two lines in its cell, its title at most TITLE_MAX characters, and its expanded row the first STATEMENT_MAX of it.
|
|
10
|
+
* The words are the commands': a request's duration in ms, its error, the empty log's sentence — and, while the log is
|
|
11
|
+
* empty and unfiltered, how to connect (`renderConnect`), so the first statement is a paste away. Usage's rows lead here
|
|
12
|
+
* filtered (`route`: `?user=`, `?client=`, `?slow=1`, `?status=`).
|
|
13
|
+
*/
|
|
14
|
+
import { msText, agoText, bytesText, esc, firstLine, n, empty, renderFailure, rowsHtml, utcText, utcMsText, details, createOpenSet, status,
|
|
15
|
+
clip, capped, TITLE_MAX, sortTh, sortedBy, nextSort, RANGES, DEFAULT_RANGE, clientText, renderConnect, copyClick, UNLISTED_STATEMENTS, statementCell, rangeEvery } from "../shared.js";
|
|
16
|
+
|
|
17
|
+
const STATE = "/api/state";
|
|
18
|
+
const NEWEST_EVERY_MS = 5000;
|
|
19
|
+
/** The newest page while a search is set: a search reads every statement's text of the range (7 days of a busy log,
|
|
20
|
+
* ~0.6 s a read), so it is read every 30 s, not 5 — said under the log (SEARCH_NOTE). */
|
|
21
|
+
const SEARCH_EVERY_MS = 30_000;
|
|
22
|
+
export const SEARCH_NOTE = `Refreshed every ${SEARCH_EVERY_MS / 1000} s while searching: a search reads the text of every statement in the range.`;
|
|
23
|
+
/** The most of a statement (or an error) an expanded row writes, in characters. */
|
|
24
|
+
const STATEMENT_MAX = 16 * 1024;
|
|
25
|
+
|
|
26
|
+
/** The status column's dot and word, by the ledger's `outcome` (src/api/request-log.ts `outcomeOf`): the word
|
|
27
|
+
* `mason logs --requests` prints, coloured as its line is (src/cli/commands.ts `requestLine`). A refusal (a statement
|
|
28
|
+
* Mason answers as designed, as a Postgres would refuse it) is Refused, never Failed (Mason's or the machine's fault). */
|
|
29
|
+
const OUTCOME = { ok: ["ok", "Completed"], refused: ["warn", "Refused"], failed: ["bad", "Failed"], cancelled: ["warn", "Cancelled"] };
|
|
30
|
+
|
|
31
|
+
/** The columns of the request log's table: its details row spans them all; the sortable ones by their sort key. */
|
|
32
|
+
const REQUEST_COLUMNS = [["Status"], ["Query id"], ["Started", "started"], ["Duration", "duration", "num"], ["User"], ["Client"], ["Rows", "rows", "num"], ["Statement"]];
|
|
33
|
+
/** What each sortable column sorts by. */
|
|
34
|
+
const REQUEST_SORTS = { started: (r) => Date.parse(r.startedAt), duration: (r) => r.durationMs, rows: (r) => r.rows };
|
|
35
|
+
/** The request log's order unless a header is clicked: newest first. */
|
|
36
|
+
const NEWEST_FIRST = { key: "started", dir: "desc" };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* THE REQUEST LOG (`mason logs --requests --json`'s shape: { requests, keptDays, before }), newest first (the command
|
|
40
|
+
* prints the oldest of the page first; a table reads the other way): a dot and the status word, the id's first 8
|
|
41
|
+
* characters, when it started (how long ago; the UTC time is its title and in its details), the duration, user, client,
|
|
42
|
+
* rows, and the statement (cut to a line, at most two in the cell, at most TITLE_MAX characters its title) over its
|
|
43
|
+
* error's first line. Each row EXPANDS (its id is the toggle: a `details` keyed by the request's id, `isOpen` the page's
|
|
44
|
+
* open set, so a poll that rewrites the log keeps it open) into the whole id, when it started and ended, the bytes sent,
|
|
45
|
+
* the statement and the error — each the first STATEMENT_MAX characters, and said so when cut. `sort` ({ key, dir }:
|
|
46
|
+
* started, duration or rows) orders the page shown, said under it when it is not the newest first. **Older** when the
|
|
47
|
+
* page has an older one (`before`), **Newest** when the page shown is not the newest. `filtered`: the empty sentence
|
|
48
|
+
* names the filters, as the command's does; `span` the range's words (the empty log's). `now` is the page's clock.
|
|
49
|
+
*/
|
|
50
|
+
export function renderRequests(page, { paged = false, filtered = false, isOpen = () => false, now = Date.now(), sort = NEWEST_FIRST, span = "", connect = "" } = {}) {
|
|
51
|
+
const older = page.before !== null && page.before !== undefined;
|
|
52
|
+
const nav = paged || older ? `<div class="paging">${paged ? `<button type="button" data-newest>Newest</button>` : ""}${older ? `<button type="button" data-older>Older</button>` : ""}</div>` : "";
|
|
53
|
+
if (!page.requests.length) {
|
|
54
|
+
const days = `${page.keptDays} day${page.keptDays === 1 ? "" : "s"}`;
|
|
55
|
+
// nothing asked yet (no filter, the newest page): how to connect, so the first statement is a paste away
|
|
56
|
+
return empty(`No ${filtered ? "request that passes the filters" : "request"} ${span ? `in ${span} (the request log keeps ${days}: MASON_HISTORY_DAYS)` : `in the last ${days} (MASON_HISTORY_DAYS)`}.`) +
|
|
57
|
+
// a first statement looked for and not there: what the log does not list (a select 1 typed to test the connection)
|
|
58
|
+
(filtered || paged ? "" : `<p class="dim unlisted">${esc(UNLISTED_STATEMENTS)}.</p>${connect}`) + nav;
|
|
59
|
+
}
|
|
60
|
+
const newest = [...page.requests].reverse();
|
|
61
|
+
const list = sort.key === "started" && sort.dir === "desc" ? newest : sortedBy(newest, REQUEST_SORTS[sort.key] ?? REQUEST_SORTS.started, sort.dir);
|
|
62
|
+
const rows = list.map((r) => {
|
|
63
|
+
const key = `request:${r.requestId}`;
|
|
64
|
+
const open = isOpen(key);
|
|
65
|
+
const error = r.sqlstate === null && !r.message ? "" : `<div class="err"${r.sqlstate ? ` title="SQLSTATE ${esc(r.sqlstate)}"` : ""}><span title="${esc(clip(r.message ?? "", TITLE_MAX))}">${esc(clip(firstLine(r.message ?? ""), 300))}</span></div>`;
|
|
66
|
+
const [kind, word] = OUTCOME[r.outcome] ?? [undefined, String(r.outcome)];
|
|
67
|
+
const row = `<tr class="s-${esc(word.toLowerCase())}"><td>${status(kind, word)}</td>` +
|
|
68
|
+
`<td class="id">${details(key, r.requestId.slice(0, 8), "", open)}</td>` +
|
|
69
|
+
`<td class="when"><time data-tick datetime="${esc(r.startedAt)}" title="${esc(utcText(r.startedAt))} UTC">${esc(agoText(r.startedAt, now))}</time></td>` +
|
|
70
|
+
`<td class="num">${esc(msText(r.durationMs))}</td><td><span class="clip" title="${esc(r.user || r.tokenName)}">${esc(r.user || (r.tokenName ? `token ${r.tokenName}` : ""))}</span></td>` +
|
|
71
|
+
`<td><span class="clip" title="${esc(clientText(r.applicationName))}">${esc(clientText(r.applicationName))}</span></td>` +
|
|
72
|
+
`<td class="num">${r.rows === null ? "" : esc(n(r.rows))}</td><td class="stmt-cell">${statementCell(r.query)}${error}</td></tr>`;
|
|
73
|
+
// the rest of a request is its expanded row: the whole id, when it started and ended, the bytes sent, the statement and error (each capped)
|
|
74
|
+
const stmt = capped(r.query || "(no statement)", STATEMENT_MAX, "mason logs --requests --json");
|
|
75
|
+
const err = capped(r.message ?? "", STATEMENT_MAX, "mason logs --requests --json");
|
|
76
|
+
// its body is as wide as the row and no wider (`.detail-body`): a long statement scrolls in its box, never widens a column
|
|
77
|
+
const more = open ? `<tr class="detail"><td colspan="${REQUEST_COLUMNS.length}"><div class="detail-body">${rowsHtml([["Query id", r.requestId], ["Started (UTC)", utcMsText(r.startedAt)], ["Ended (UTC)", utcMsText(r.endedAt)], ["Bytes sent", r.bytesSent === null ? "" : bytesText(r.bytesSent)]])}` +
|
|
78
|
+
`<pre class="stmt">${esc(stmt.text)}</pre>${stmt.cut ? `<p class="dim cut">${esc(stmt.cut)}${r.queryTruncated ? " (as the log kept it)" : ""}</p>` : r.queryTruncated ? `<p class="dim cut">the request log kept the statement cut</p>` : ""}` +
|
|
79
|
+
`${r.message ? `<pre class="error">${esc(r.sqlstate ? `${r.sqlstate} ` : "")}${esc(err.text)}</pre>${err.cut ? `<p class="dim cut">${esc(err.cut)}</p>` : ""}` : ""}</div></td></tr>` : "";
|
|
80
|
+
return row + more;
|
|
81
|
+
}).join("");
|
|
82
|
+
const sortNote = sort.key === "started" && sort.dir === "desc" ? ""
|
|
83
|
+
: `<p class="dim sort-note">Sorted by ${esc(sort.key === "started" ? "start" : sort.key)}, ${sort.dir === "asc" ? "lowest" : "highest"} first, within the ${n(page.requests.length)} rows of this page: Older and Newest page by start time.</p>`;
|
|
84
|
+
return `<div class="scroll"><table class="requests"><thead><tr>${REQUEST_COLUMNS.map(([c, key, cls]) => key ? sortTh("requests", key, c, sort, cls) : `<th scope="col">${c}</th>`).join("")}</tr></thead>` +
|
|
85
|
+
`<tbody>${rows}</tbody></table></div>${sortNote}${nav}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The status filter's choices as the server's `outcome` list (src/api/request-log.ts): Completed is ok, Refused the
|
|
89
|
+
* statement's own fault, Failed Mason's or the machine's, Cancelled a cancel or a timeout (57014); Refused or failed is
|
|
90
|
+
* both, the count the Overview's query errors and Usage's Failed card lead here with. */
|
|
91
|
+
const STATUS_OUTCOMES = { completed: "ok", refused: "refused", failed: "failed", "refused-or-failed": "refused,failed", cancelled: "cancelled" };
|
|
92
|
+
|
|
93
|
+
/** The filters as they are before any is set. */
|
|
94
|
+
const NO_FILTERS = { status: "", since: "", user: "", client: "", slow: false, search: "" };
|
|
95
|
+
|
|
96
|
+
/** The path of one view of the request log: its filters as the server reads them (it judges since and search), since the
|
|
97
|
+
* range's (`range`, a key of RANGES) unless the filter names its own, and the cursor of an older page. */
|
|
98
|
+
export function requestsPath(filters, before, range = DEFAULT_RANGE) {
|
|
99
|
+
const p = new URLSearchParams();
|
|
100
|
+
if (STATUS_OUTCOMES[filters.status]) p.set("outcome", STATUS_OUTCOMES[filters.status]);
|
|
101
|
+
p.set("since", filters.since || (RANGES[range] ?? RANGES[DEFAULT_RANGE]).since);
|
|
102
|
+
for (const k of ["user", "client"]) if (filters[k]) p.set(k, filters[k]);
|
|
103
|
+
if (filters.slow) p.set("slow", "1");
|
|
104
|
+
if (filters.search) p.set("search", filters.search);
|
|
105
|
+
if (before) p.set("before", before);
|
|
106
|
+
return `/api/requests?${p.toString()}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const page = {
|
|
110
|
+
id: "queries",
|
|
111
|
+
hash: "observability/queries",
|
|
112
|
+
parent: "observability",
|
|
113
|
+
title: "Queries",
|
|
114
|
+
create(ctx) {
|
|
115
|
+
// the request rows' details open, by request id (createOpenSet): they stay open across a poll
|
|
116
|
+
const opened = createOpenSet();
|
|
117
|
+
const m = { filters: { ...NO_FILTERS }, before: null, open: opened.open, sort: NEWEST_FIRST };
|
|
118
|
+
const path = () => requestsPath(m.filters, m.before, ctx.range());
|
|
119
|
+
const filtered = () => Object.values(m.filters).some((v) => v !== "" && v !== false);
|
|
120
|
+
/** The form's controls showing the filters (a route sets them, not a person). */
|
|
121
|
+
const showFilters = (doc) => {
|
|
122
|
+
const form = doc?.getElementById?.("request-filters");
|
|
123
|
+
if (!form?.elements) return;
|
|
124
|
+
for (const k of ["status", "since", "user", "client", "search"]) if (form.elements[k]) form.elements[k].value = m.filters[k];
|
|
125
|
+
if (form.elements.slow) form.elements.slow.checked = m.filters.slow;
|
|
126
|
+
};
|
|
127
|
+
const actions = {
|
|
128
|
+
/** Show the requests that pass these filters, from the newest. */
|
|
129
|
+
async filterRequests(filters) {
|
|
130
|
+
m.filters = { ...NO_FILTERS, ...filters };
|
|
131
|
+
m.before = null;
|
|
132
|
+
await ctx.refresh([path()]);
|
|
133
|
+
},
|
|
134
|
+
/** The page before this one: the cursor the page shown answered with. Not read again while shown. */
|
|
135
|
+
async olderRequests() {
|
|
136
|
+
const before = ctx.read(path())?.body?.before;
|
|
137
|
+
if (typeof before !== "string") return;
|
|
138
|
+
m.before = before;
|
|
139
|
+
await ctx.refresh([path()]);
|
|
140
|
+
},
|
|
141
|
+
async newestRequests() {
|
|
142
|
+
m.before = null;
|
|
143
|
+
await ctx.refresh([path()]);
|
|
144
|
+
},
|
|
145
|
+
/** A sortable header clicked: the page shown in that order (the same column again turns it round). */
|
|
146
|
+
sortRequests(key) { m.sort = nextSort(m.sort, key, "desc"); },
|
|
147
|
+
};
|
|
148
|
+
return {
|
|
149
|
+
model: m,
|
|
150
|
+
// the newest page of the log every 5 s while the range is Now (30 s while a search is set), once when another
|
|
151
|
+
// range is chosen; an older page only when first asked. The state (how to connect, when the log is empty) every 30 s
|
|
152
|
+
reads: () => [{ path: path(), everyMs: m.before !== null ? Infinity : rangeEvery(ctx.range(), m.filters.search ? SEARCH_EVERY_MS : NEWEST_EVERY_MS) }, { path: STATE, everyMs: 30_000 }],
|
|
153
|
+
actions,
|
|
154
|
+
view() {
|
|
155
|
+
const log = ctx.read(path());
|
|
156
|
+
const s = ctx.read(STATE)?.body;
|
|
157
|
+
return {
|
|
158
|
+
"request-log": (log?.failure ? renderFailure(log.failure) : "") + (log?.body ? renderRequests(log.body, { paged: m.before !== null, filtered: filtered(), isOpen: opened.has, now: ctx.now().getTime(), sort: m.sort,
|
|
159
|
+
span: m.filters.since ? "" : (RANGES[ctx.range()] ?? RANGES[DEFAULT_RANGE]).span, connect: renderConnect(s?.postgres, { first: true, table: s?.tables?.find((t) => t.added && t.copy?.phase === "done")?.table ?? null }) }) : "") +
|
|
160
|
+
(log?.body && m.filters.search && m.before === null ? `<p class="dim refresh-note">${esc(SEARCH_NOTE)}</p>` : ""),
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
toggle: (target) => opened.toggle(target),
|
|
164
|
+
submit(form) {
|
|
165
|
+
if (form.id !== "request-filters") return null;
|
|
166
|
+
const value = (k) => form.elements[k]?.value?.trim() ?? "";
|
|
167
|
+
return actions.filterRequests({ status: value("status"), since: value("since"), user: value("user"), client: value("client"), slow: form.elements.slow?.checked === true, search: value("search") });
|
|
168
|
+
},
|
|
169
|
+
/** `#observability/queries?slow=1`, `?status=refused-or-failed`,`?user=metabase&client=Metabase` (where the Overview's cards
|
|
170
|
+
* and Usage's rows lead): those filters, from the newest, and the form's controls showing them. Nothing to do for a
|
|
171
|
+
* hash without them (a change of range keeps the filters). */
|
|
172
|
+
route(params, { doc } = {}) {
|
|
173
|
+
if (!["slow", "status", "user", "client"].some((k) => params[k] !== undefined)) return null;
|
|
174
|
+
m.filters = { ...NO_FILTERS, slow: params.slow === "1", status: Object.hasOwn(STATUS_OUTCOMES, params.status ?? "") ? params.status : "",
|
|
175
|
+
user: params.user ?? "", client: params.client ?? "" };
|
|
176
|
+
m.before = null;
|
|
177
|
+
showFilters(doc);
|
|
178
|
+
return true;
|
|
179
|
+
},
|
|
180
|
+
click(d, button, env) {
|
|
181
|
+
const { doc } = env;
|
|
182
|
+
// Connect's Copy buttons, in the request log's empty state
|
|
183
|
+
if (d.copy !== undefined) return copyClick(d, button, env);
|
|
184
|
+
if (d.sortTable === "requests") return actions.sortRequests(d.sortKey) ?? true;
|
|
185
|
+
if (d.older !== undefined) return actions.olderRequests();
|
|
186
|
+
if (d.newest !== undefined) return actions.newestRequests();
|
|
187
|
+
if (d.clearFilters !== undefined) { doc.getElementById("request-filters")?.reset?.(); return actions.filterRequests({}); }
|
|
188
|
+
return null;
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
};
|