watch-tail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/build/client/_app/immutable/assets/0.B1NsRRrX.css +2 -0
- package/build/client/_app/immutable/assets/0.B1NsRRrX.css.br +0 -0
- package/build/client/_app/immutable/assets/0.B1NsRRrX.css.gz +0 -0
- package/build/client/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/build/client/_app/immutable/chunks/Bjy-W4x2.js.br +0 -0
- package/build/client/_app/immutable/chunks/Bjy-W4x2.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DCKWcE8r.js +1 -0
- package/build/client/_app/immutable/chunks/DCKWcE8r.js.br +0 -0
- package/build/client/_app/immutable/chunks/DCKWcE8r.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DDRhiz1-.js +1 -0
- package/build/client/_app/immutable/chunks/DDRhiz1-.js.br +0 -0
- package/build/client/_app/immutable/chunks/DDRhiz1-.js.gz +0 -0
- package/build/client/_app/immutable/chunks/ZAD2y5xJ.js +3 -0
- package/build/client/_app/immutable/chunks/ZAD2y5xJ.js.br +0 -0
- package/build/client/_app/immutable/chunks/ZAD2y5xJ.js.gz +0 -0
- package/build/client/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/build/client/_app/immutable/chunks/xihTtKlq.js.br +1 -0
- package/build/client/_app/immutable/chunks/xihTtKlq.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.DIoggGwf.js +2 -0
- package/build/client/_app/immutable/entry/app.DIoggGwf.js.br +0 -0
- package/build/client/_app/immutable/entry/app.DIoggGwf.js.gz +0 -0
- package/build/client/_app/immutable/entry/start._YXgjuSB.js +1 -0
- package/build/client/_app/immutable/entry/start._YXgjuSB.js.br +0 -0
- package/build/client/_app/immutable/entry/start._YXgjuSB.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.D5bYt7iX.js +1 -0
- package/build/client/_app/immutable/nodes/0.D5bYt7iX.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.D5bYt7iX.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.DBX83mYX.js +1 -0
- package/build/client/_app/immutable/nodes/1.DBX83mYX.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.DBX83mYX.js.gz +0 -0
- package/build/client/_app/immutable/nodes/2.DiH-2moC.js +5 -0
- package/build/client/_app/immutable/nodes/2.DiH-2moC.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.DiH-2moC.js.gz +0 -0
- package/build/client/_app/version.json +1 -0
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/client/robots.txt +3 -0
- package/build/client/robots.txt.br +0 -0
- package/build/client/robots.txt.gz +0 -0
- package/build/env.js +70 -0
- package/build/env.js.map +1 -0
- package/build/handler.js +24 -0
- package/build/handler.js.map +1 -0
- package/build/index.js +147 -0
- package/build/index.js.map +1 -0
- package/build/server/chunks/chunks/env.js-l0sNRNKZ.js +2 -0
- package/build/server/chunks/chunks/env.js-l0sNRNKZ.js.map +1 -0
- package/build/server/chunks/chunks/env2.js-D7xrfc37.js +316 -0
- package/build/server/chunks/chunks/env2.js-D7xrfc37.js.map +1 -0
- package/build/server/chunks/chunks/exports.js-CvFPPbaH.js +263 -0
- package/build/server/chunks/chunks/exports.js-CvFPPbaH.js.map +1 -0
- package/build/server/chunks/chunks/internal.js-DdmUz1r3.js +1191 -0
- package/build/server/chunks/chunks/internal.js-DdmUz1r3.js.map +1 -0
- package/build/server/chunks/chunks/internal2.js-DZ8ZzzjN.js +22 -0
- package/build/server/chunks/chunks/internal2.js-DZ8ZzzjN.js.map +1 -0
- package/build/server/chunks/chunks/regions.js-IQPMyP5C.js +52 -0
- package/build/server/chunks/chunks/regions.js-IQPMyP5C.js.map +1 -0
- package/build/server/chunks/chunks/server.js-ByNB8uti.js +3659 -0
- package/build/server/chunks/chunks/server.js-ByNB8uti.js.map +1 -0
- package/build/server/chunks/chunks/shared-server.js-Dn5XheXH.js +22 -0
- package/build/server/chunks/chunks/shared-server.js-Dn5XheXH.js.map +1 -0
- package/build/server/chunks/chunks/shared.js-CgjUIvRK.js +776 -0
- package/build/server/chunks/chunks/shared.js-CgjUIvRK.js.map +1 -0
- package/build/server/chunks/chunks/state.js-ClLTNPGs.js +86 -0
- package/build/server/chunks/chunks/state.js-ClLTNPGs.js.map +1 -0
- package/build/server/chunks/chunks/time-range.js-BLMT8aU3.js +102 -0
- package/build/server/chunks/chunks/time-range.js-BLMT8aU3.js.map +1 -0
- package/build/server/chunks/chunks/uneval.js-DaakSYFQ.js +488 -0
- package/build/server/chunks/chunks/uneval.js-DaakSYFQ.js.map +1 -0
- package/build/server/chunks/chunks/utils.js-BIlHjgGW.js +933 -0
- package/build/server/chunks/chunks/utils.js-BIlHjgGW.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/health/_server.ts.js-aiNZfkco.js +41 -0
- package/build/server/chunks/entries/endpoints/api/health/_server.ts.js-aiNZfkco.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/log-groups/_server.ts.js-D143-vRl.js +120 -0
- package/build/server/chunks/entries/endpoints/api/log-groups/_server.ts.js-D143-vRl.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/regions/_server.ts.js-CDNL7msU.js +88 -0
- package/build/server/chunks/entries/endpoints/api/regions/_server.ts.js-CDNL7msU.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/stream/_server.ts.js-D_i-xW6_.js +610 -0
- package/build/server/chunks/entries/endpoints/api/stream/_server.ts.js-D_i-xW6_.js.map +1 -0
- package/build/server/chunks/entries/fallbacks/error.svelte.js-qFLa8EVy.js +17 -0
- package/build/server/chunks/entries/fallbacks/error.svelte.js-qFLa8EVy.js.map +1 -0
- package/build/server/chunks/entries/pages/_layout.svelte.js-Bx7lXkAi.js +22 -0
- package/build/server/chunks/entries/pages/_layout.svelte.js-Bx7lXkAi.js.map +1 -0
- package/build/server/chunks/entries/pages/_page.svelte.js-BsvaKqbk.js +1264 -0
- package/build/server/chunks/entries/pages/_page.svelte.js-BsvaKqbk.js.map +1 -0
- package/build/server/chunks/handler-nVqa-QPz.js +1735 -0
- package/build/server/chunks/handler-nVqa-QPz.js.map +1 -0
- package/build/server/chunks/index.js-CtbhHm7L.js +3803 -0
- package/build/server/chunks/index.js-CtbhHm7L.js.map +1 -0
- package/build/server/chunks/manifest.js-BwUys8gK.js +70 -0
- package/build/server/chunks/manifest.js-BwUys8gK.js.map +1 -0
- package/build/server/chunks/nodes/0.js-Dl0J9d3p.js +9 -0
- package/build/server/chunks/nodes/0.js-Dl0J9d3p.js.map +1 -0
- package/build/server/chunks/nodes/1.js-dXZVW92o.js +9 -0
- package/build/server/chunks/nodes/1.js-dXZVW92o.js.map +1 -0
- package/build/server/chunks/nodes/2.js-aMMUenQc.js +9 -0
- package/build/server/chunks/nodes/2.js-aMMUenQc.js.map +1 -0
- package/build/shims.js +33 -0
- package/build/shims.js.map +1 -0
- package/dist/cli/bin.js +9 -0
- package/dist/cli/completions.js +99 -0
- package/dist/cli/index.js +183 -0
- package/dist/cli/options.js +171 -0
- package/dist/cli/server.js +126 -0
- package/dist/cli/ui.js +75 -0
- package/dist/lib/cli/aws.js +264 -0
- package/dist/lib/time-range.js +106 -0
- package/package.json +106 -0
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
import '../../chunks/internal.js-DdmUz1r3.js';
|
|
2
|
+
import { ah as escape_html, ai as attr_style, P as derived, ag as attr, aj as attr_class, ak as ensure_array_like, al as stringify, am as clsx } from '../../chunks/server.js-ByNB8uti.js';
|
|
3
|
+
import { R as REGION_CODES } from '../../chunks/regions.js-IQPMyP5C.js';
|
|
4
|
+
import { P as PRESET_LABELS, a as presetLabel, H as HISTORIC_PRESETS, t as toDateTimeLocal, d as describeWindow } from '../../chunks/time-range.js-BLMT8aU3.js';
|
|
5
|
+
import { p as page, r as replaceState } from '../../chunks/state.js-ClLTNPGs.js';
|
|
6
|
+
import '../../chunks/uneval.js-DaakSYFQ.js';
|
|
7
|
+
import '../../chunks/shared.js-CgjUIvRK.js';
|
|
8
|
+
import '../../chunks/exports.js-CvFPPbaH.js';
|
|
9
|
+
import '../../chunks/utils.js-BIlHjgGW.js';
|
|
10
|
+
|
|
11
|
+
//#region src/lib/format.ts
|
|
12
|
+
/**
|
|
13
|
+
* Formatting helpers for the watch-stream UI. Every function here is pure so it can be unit
|
|
14
|
+
* tested without a DOM.
|
|
15
|
+
*/
|
|
16
|
+
/** Placeholder shown when a timestamp is missing or not a number. */
|
|
17
|
+
var EMPTY_TIME = "--:--:--.---";
|
|
18
|
+
/** Left-pads a non-negative integer with zeros. */
|
|
19
|
+
function pad(value, size = 2) {
|
|
20
|
+
return String(Math.trunc(Math.abs(value))).padStart(size, "0");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Formats an epoch-millisecond timestamp as `HH:MM:SS.mmm` in UTC.
|
|
24
|
+
* UTC keeps rendering stable no matter where the browser runs.
|
|
25
|
+
*/
|
|
26
|
+
function formatTime(timestamp) {
|
|
27
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return EMPTY_TIME;
|
|
28
|
+
const date = new Date(timestamp);
|
|
29
|
+
if (Number.isNaN(date.getTime())) return EMPTY_TIME;
|
|
30
|
+
return `${`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`}.${pad(date.getUTCMilliseconds(), 3)}`;
|
|
31
|
+
}
|
|
32
|
+
/** Formats an epoch-millisecond timestamp as an ISO 8601 string, for tooltips. */
|
|
33
|
+
function formatTimestamp(timestamp) {
|
|
34
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return "—";
|
|
35
|
+
const date = new Date(timestamp);
|
|
36
|
+
if (Number.isNaN(date.getTime())) return "—";
|
|
37
|
+
return date.toISOString();
|
|
38
|
+
}
|
|
39
|
+
/** Formats a number with at most one decimal, dropping a trailing `.0`. */
|
|
40
|
+
function trimZero(value) {
|
|
41
|
+
const fixed = value >= 100 ? value.toFixed(0) : value.toFixed(1);
|
|
42
|
+
return fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Formats a byte count as `B`, `KiB`, `MiB` or `GiB`, with at most one decimal for scaled units.
|
|
46
|
+
* Returns {@link EMPTY_VALUE} for a missing or negative value.
|
|
47
|
+
*/
|
|
48
|
+
function formatBytes(bytes) {
|
|
49
|
+
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return "—";
|
|
50
|
+
if (bytes < 1024) return `${Math.trunc(bytes)} B`;
|
|
51
|
+
const units = [
|
|
52
|
+
"KiB",
|
|
53
|
+
"MiB",
|
|
54
|
+
"GiB",
|
|
55
|
+
"TiB"
|
|
56
|
+
];
|
|
57
|
+
let value = bytes / 1024;
|
|
58
|
+
let unit = units[0];
|
|
59
|
+
for (let index = 1; index < units.length && value >= 1024; index += 1) {
|
|
60
|
+
value /= 1024;
|
|
61
|
+
unit = units[index];
|
|
62
|
+
}
|
|
63
|
+
return `${trimZero(value)} ${unit}`;
|
|
64
|
+
}
|
|
65
|
+
/** Formats an integer count with thousands separators, for example `1,234`. */
|
|
66
|
+
function formatCount(value) {
|
|
67
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "0";
|
|
68
|
+
const rounded = Math.trunc(value);
|
|
69
|
+
return (rounded < 0 ? "-" : "") + String(Math.abs(rounded)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Names the emulator behind a CloudWatch Logs endpoint override.
|
|
73
|
+
* Returns `null` when the endpoint is empty, which means real AWS.
|
|
74
|
+
*/
|
|
75
|
+
function emulatorLabel(endpoint) {
|
|
76
|
+
const trimmed = typeof endpoint === "string" ? endpoint.trim() : "";
|
|
77
|
+
if (trimmed === "") return null;
|
|
78
|
+
const lower = trimmed.toLowerCase();
|
|
79
|
+
if (lower.includes("floci")) return "floci";
|
|
80
|
+
if (lower.includes("localstack")) return "localstack";
|
|
81
|
+
if (endpointPort(trimmed) === "4566") return "floci";
|
|
82
|
+
return "local emulator";
|
|
83
|
+
}
|
|
84
|
+
/** Reads the port of an endpoint URL, or `null` when it cannot be parsed. */
|
|
85
|
+
function endpointPort(endpoint) {
|
|
86
|
+
return parseEndpoint(endpoint)?.port || null;
|
|
87
|
+
}
|
|
88
|
+
/** Parses an endpoint URL, returning `null` when it is not a URL. */
|
|
89
|
+
function parseEndpoint(endpoint) {
|
|
90
|
+
try {
|
|
91
|
+
return new URL(endpoint);
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Builds the header badge text for an endpoint, for example `floci http://localhost:4566`.
|
|
98
|
+
* Returns `null` when the app talks to real AWS.
|
|
99
|
+
*/
|
|
100
|
+
function describeEndpoint(endpoint) {
|
|
101
|
+
const trimmed = typeof endpoint === "string" ? endpoint.trim() : "";
|
|
102
|
+
if (trimmed === "") return null;
|
|
103
|
+
const label = emulatorLabel(trimmed);
|
|
104
|
+
if (label === null) return null;
|
|
105
|
+
return (parseEndpoint(trimmed)?.hostname.toLowerCase() ?? trimmed.toLowerCase()).startsWith(label) ? trimmed : `${label} ${trimmed}`;
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/lib/components/EndpointBadge.svelte
|
|
109
|
+
function EndpointBadge($$renderer, $$props) {
|
|
110
|
+
$$renderer.component(($$renderer) => {
|
|
111
|
+
/** Resolved CloudWatch Logs endpoint, or `null` when talking to real AWS. */
|
|
112
|
+
/** Credential mode from `GET /api/health`, or `null` when it is unknown. */
|
|
113
|
+
let { endpoint = null, credentials = null } = $$props;
|
|
114
|
+
/** Badge text, for example `floci http://localhost:4566`; `null` hides the badge. */
|
|
115
|
+
let label = derived(() => describeEndpoint(endpoint));
|
|
116
|
+
/** True when the server fell back to the emulator's throwaway credentials. */
|
|
117
|
+
let emulatorDefault = derived(() => credentials === "emulator-default");
|
|
118
|
+
let hint = derived(() => emulatorDefault() ? "Local emulator endpoint - using floci's throwaway credentials (no ambient AWS credentials found)" : "Local emulator endpoint");
|
|
119
|
+
if (label() !== null) {
|
|
120
|
+
$$renderer.push(`<!--[0--><span class="inline-flex items-center gap-1.5"${attr("title", hint())}><span data-testid="endpoint-badge"${attr("title", hint())} class="rounded-full border border-violet-900 bg-violet-950/60 px-2.5 py-1 text-xs font-medium text-violet-300">${escape_html(label())}</span> `);
|
|
121
|
+
if (emulatorDefault()) $$renderer.push(`<!--[0--><span data-testid="credentials-hint" class="text-[0.6875rem] text-neutral-500">dev creds</span>`);
|
|
122
|
+
else $$renderer.push("<!--[-1-->");
|
|
123
|
+
$$renderer.push(`<!--]--></span>`);
|
|
124
|
+
} else $$renderer.push("<!--[-1-->");
|
|
125
|
+
$$renderer.push(`<!--]-->`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/lib/resize.ts
|
|
130
|
+
/**
|
|
131
|
+
* Pure helpers for the drag-resizable columns in the UI.
|
|
132
|
+
*
|
|
133
|
+
* The components own the pointer events and the persistence; everything that can
|
|
134
|
+
* be reasoned about without a DOM lives here so it can be unit tested.
|
|
135
|
+
*/
|
|
136
|
+
/** `localStorage` keys, prefixed so they are easy to spot in a browser. */
|
|
137
|
+
var STORAGE_KEYS = {
|
|
138
|
+
sidebarWidth: "watch-stream:sidebar-width",
|
|
139
|
+
prefixWidth: "watch-stream:prefix-width",
|
|
140
|
+
wrap: "watch-stream:wrap",
|
|
141
|
+
jsonView: "watch-stream:json-view"
|
|
142
|
+
};
|
|
143
|
+
/** Default width of the group-list pane, in rem (matches the original layout). */
|
|
144
|
+
var SIDEBAR_WIDTH = {
|
|
145
|
+
defaultRem: 22,
|
|
146
|
+
minRem: 16,
|
|
147
|
+
maxVw: 44
|
|
148
|
+
};
|
|
149
|
+
/** Width constraints for the log line prefix (timestamp + stream name). */
|
|
150
|
+
var PREFIX_WIDTH = {
|
|
151
|
+
defaultRem: 14,
|
|
152
|
+
minRem: 4,
|
|
153
|
+
maxRem: 40
|
|
154
|
+
};
|
|
155
|
+
/** Converts a CSS pixel width to `rem` for use in a Tailwind `style` attribute. */
|
|
156
|
+
function pxToRem(px, rootFontSize = 16) {
|
|
157
|
+
const rem = px / rootFontSize;
|
|
158
|
+
return `${Number.isInteger(rem) ? rem : Number(rem.toFixed(3))}rem`;
|
|
159
|
+
}
|
|
160
|
+
/** Parses a `rem` value into CSS pixels. */
|
|
161
|
+
function remToPx(rem, rootFontSize = 16) {
|
|
162
|
+
return rem * rootFontSize;
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/lib/components/ColumnResizer.svelte
|
|
166
|
+
function ColumnResizer($$renderer, $$props) {
|
|
167
|
+
$$renderer.component(($$renderer) => {
|
|
168
|
+
/** Accessible name, for example "Resize group list". */
|
|
169
|
+
/** Current width in CSS pixels. */
|
|
170
|
+
/** Lower bound in CSS pixels. */
|
|
171
|
+
/** Upper bound in CSS pixels. */
|
|
172
|
+
/** `data-testid` value. */
|
|
173
|
+
/** Called on every pointer move and arrow key with the clamped width. */
|
|
174
|
+
/** Called when a drag ends or an arrow key is handled, for persistence. */
|
|
175
|
+
/** Extra classes, used for positioning. */
|
|
176
|
+
/** Extra inline style, for example `left: 120px`. */
|
|
177
|
+
let { label, width, min, max, testId, onChange, onCommit, class: className = "", style = "" } = $$props;
|
|
178
|
+
$$renderer.push(`<div role="separator" aria-orientation="vertical"${attr("aria-label", label)}${attr("aria-valuemin", min)}${attr("aria-valuemax", max)}${attr("aria-valuenow", Math.round(width))} tabindex="0"${attr("title", `${stringify(label)} (drag or use the arrow keys)`)}${attr("data-testid", testId)}${attr_class(`cursor-col-resize touch-none bg-transparent hover:bg-sky-500/30 focus:bg-sky-500/50 focus:outline-none ${stringify(className)}`)}${attr_style(style)}></div>`);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/lib/components/RangeControls.svelte
|
|
183
|
+
function RangeControls($$renderer, $$props) {
|
|
184
|
+
$$renderer.component(($$renderer) => {
|
|
185
|
+
/** Preset name when a preset is active, otherwise an empty string. */
|
|
186
|
+
/** Custom bounds as epoch ms, or `null` when a preset/live window is active. */
|
|
187
|
+
/** Active mode, owned by the page. */
|
|
188
|
+
/** Active preset, owned by the page. */
|
|
189
|
+
/** Active custom bounds as epoch ms, owned by the page. */
|
|
190
|
+
/** True when no group is selected, so there is nothing to scope. */
|
|
191
|
+
/** True while a historic window is being fetched. */
|
|
192
|
+
/** Called when the user switches mode or applies a range. */
|
|
193
|
+
let { mode = "live", range = "15m", from = null, to = null, disabled = false, loading = false} = $$props;
|
|
194
|
+
/** Label shown on the custom chip. */
|
|
195
|
+
let customLabel = derived(() => mode === "historic" && range === "" && from !== null && to !== null ? `${toDateTimeLocal(from).replace("T", " ")} → ${toDateTimeLocal(to).replace("T", " ")}` : "Custom…");
|
|
196
|
+
$$renderer.push(`<div class="flex flex-wrap items-center gap-2" data-testid="range-controls"><div class="flex items-center gap-1 rounded-lg border border-neutral-800 bg-neutral-900 p-0.5" role="group" aria-label="Tail mode"><button type="button"${attr("aria-pressed", mode === "live")} data-testid="mode-live"${attr_class(`rounded-md border px-2 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${mode === "live" ? "border-sky-700 bg-sky-950/60 text-sky-300" : "border-transparent text-neutral-400 hover:text-neutral-200"}`)}>Live</button> <button type="button"${attr("aria-pressed", mode === "historic")} data-testid="mode-historic"${attr_class(`rounded-md border px-2 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${mode === "historic" ? "border-amber-700 bg-amber-950/50 text-amber-300" : "border-transparent text-neutral-400 hover:text-neutral-200"}`)}>Historic</button></div> `);
|
|
197
|
+
if (mode === "historic") {
|
|
198
|
+
$$renderer.push(`<!--[0--><div class="flex flex-wrap items-center gap-1" role="group" aria-label="Historic range"><!--[-->`);
|
|
199
|
+
const each_array = ensure_array_like(HISTORIC_PRESETS);
|
|
200
|
+
for (let $$index = 0, $$length = each_array.length; $$index < $$length; $$index++) {
|
|
201
|
+
let preset = each_array[$$index];
|
|
202
|
+
$$renderer.push(`<button type="button"${attr("aria-pressed", range === preset)}${attr("data-testid", `preset-${stringify(preset)}`)}${attr("disabled", disabled, true)}${attr_class(`rounded-md border px-2 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${range === preset ? "border-amber-700 bg-amber-950/50 text-amber-200" : "border-neutral-800 bg-neutral-900 text-neutral-300 hover:border-neutral-700"} disabled:opacity-40`)}>${escape_html(PRESET_LABELS[preset])}</button>`);
|
|
203
|
+
}
|
|
204
|
+
$$renderer.push(`<!--]--> <button type="button"${attr("aria-pressed", range === "")} data-testid="preset-custom"${attr("disabled", disabled, true)}${attr_class(`rounded-md border px-2 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${range === "" ? "border-amber-700 bg-amber-950/50 text-amber-200" : "border-neutral-800 bg-neutral-900 text-neutral-300 hover:border-neutral-700"}`)}>${escape_html(customLabel())}</button></div>`);
|
|
205
|
+
} else $$renderer.push("<!--[-1-->");
|
|
206
|
+
$$renderer.push(`<!--]--> `);
|
|
207
|
+
if (loading) $$renderer.push(`<!--[0--><span class="text-xs text-amber-300" data-testid="range-loading">loading window…</span>`);
|
|
208
|
+
else if (mode === "historic" && range !== "") $$renderer.push(`<!--[1--><span class="text-xs text-neutral-500">last ${escape_html(presetLabel(range))}</span>`);
|
|
209
|
+
else $$renderer.push("<!--[-1-->");
|
|
210
|
+
$$renderer.push(`<!--]--> `);
|
|
211
|
+
$$renderer.push("<!--[-1-->");
|
|
212
|
+
$$renderer.push(`<!--]--></div>`);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/lib/groups-client.ts
|
|
217
|
+
/**
|
|
218
|
+
* Browser-side fetch helpers for the watch-stream JSON API (`/api/health`, `/api/regions`,
|
|
219
|
+
* `/api/log-groups`). Every helper throws {@link ApiError} on a non-2xx response so the UI can show
|
|
220
|
+
* the API `error` message instead of an opaque failure.
|
|
221
|
+
*/
|
|
222
|
+
/** Region list used by the picker before (or instead of) `/api/regions`. */
|
|
223
|
+
var DEFAULT_REGIONS = REGION_CODES;
|
|
224
|
+
/** Error carrying the parsed `ApiErrorBody` returned by the API. */
|
|
225
|
+
var ApiError = class extends Error {
|
|
226
|
+
/** HTTP status, or `0` when the request never reached the server. */
|
|
227
|
+
status;
|
|
228
|
+
/** Optional machine-readable code from the API. */
|
|
229
|
+
code;
|
|
230
|
+
/** Optional extra detail from the API. */
|
|
231
|
+
details;
|
|
232
|
+
constructor(message, options = {}) {
|
|
233
|
+
super(message, { cause: options.cause });
|
|
234
|
+
this.name = "ApiError";
|
|
235
|
+
this.status = options.status ?? 0;
|
|
236
|
+
this.code = options.code;
|
|
237
|
+
this.details = options.details;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
/** Reads a string field from an unknown JSON value. */
|
|
241
|
+
function readField(value, key) {
|
|
242
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
243
|
+
const field = value[key];
|
|
244
|
+
return typeof field === "string" ? field : void 0;
|
|
245
|
+
}
|
|
246
|
+
/** Extracts the API `error` message, falling back when the body has none. */
|
|
247
|
+
function apiErrorMessage(value, fallback = "Unexpected API error") {
|
|
248
|
+
return readField(value, "error") ?? fallback;
|
|
249
|
+
}
|
|
250
|
+
/** Builds the `/api/log-groups` URL, including only the parameters that are set. */
|
|
251
|
+
function buildLogGroupsUrl(query) {
|
|
252
|
+
const search = new URLSearchParams();
|
|
253
|
+
if (query.region) search.set("region", query.region);
|
|
254
|
+
if (query.prefix) search.set("prefix", query.prefix);
|
|
255
|
+
if (typeof query.limit === "number" && Number.isFinite(query.limit)) search.set("limit", String(query.limit));
|
|
256
|
+
const suffix = search.toString();
|
|
257
|
+
return suffix === "" ? "/api/log-groups" : `/api/log-groups?${suffix}`;
|
|
258
|
+
}
|
|
259
|
+
/** True for an abort, which is a normal way to cancel a request. */
|
|
260
|
+
function isAbort(error) {
|
|
261
|
+
return error instanceof Error && error.name === "AbortError";
|
|
262
|
+
}
|
|
263
|
+
/** Performs a JSON request and maps failures onto {@link ApiError}. */
|
|
264
|
+
async function getJson(url, options) {
|
|
265
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
266
|
+
let response;
|
|
267
|
+
try {
|
|
268
|
+
response = await fetchImpl(url, {
|
|
269
|
+
headers: { accept: "application/json" },
|
|
270
|
+
signal: options.signal
|
|
271
|
+
});
|
|
272
|
+
} catch (cause) {
|
|
273
|
+
if (isAbort(cause)) throw cause;
|
|
274
|
+
throw new ApiError(`Could not reach ${url}.`, { cause });
|
|
275
|
+
}
|
|
276
|
+
const text = await response.text();
|
|
277
|
+
let body = null;
|
|
278
|
+
if (text.trim() !== "") try {
|
|
279
|
+
body = JSON.parse(text);
|
|
280
|
+
} catch {
|
|
281
|
+
body = null;
|
|
282
|
+
}
|
|
283
|
+
if (!response.ok) throw new ApiError(apiErrorMessage(body, `Request to ${url} failed with ${response.status}.`), {
|
|
284
|
+
status: response.status,
|
|
285
|
+
code: readField(body, "code"),
|
|
286
|
+
details: readField(body, "details")
|
|
287
|
+
});
|
|
288
|
+
return body;
|
|
289
|
+
}
|
|
290
|
+
/** `GET /api/log-groups` - log groups for a region. */
|
|
291
|
+
function fetchLogGroups(query) {
|
|
292
|
+
return getJson(buildLogGroupsUrl(query), query);
|
|
293
|
+
}
|
|
294
|
+
/** Message shown when a region has no log groups at all. */
|
|
295
|
+
function noGroupsMessage(region) {
|
|
296
|
+
return `No log groups found in ${region}. Start floci and run \`pnpm seed\` to create sample groups.`;
|
|
297
|
+
}
|
|
298
|
+
/** Drops a trailing period so an appended sentence does not double up. */
|
|
299
|
+
function trimSentence(text) {
|
|
300
|
+
return text.trim().replace(/\.+$/, "");
|
|
301
|
+
}
|
|
302
|
+
/** Adds region context to a failed group list request. */
|
|
303
|
+
function describeGroupsError(region, error) {
|
|
304
|
+
if (error instanceof ApiError) {
|
|
305
|
+
const details = error.details ? ` ${error.details}` : "";
|
|
306
|
+
return `Could not list log groups in ${region}: ${trimSentence(error.message)}.${details}`;
|
|
307
|
+
}
|
|
308
|
+
return `Could not reach the watch-stream API for ${region}. ${error instanceof Error && error.message !== "" ? error.message : String(error)}`;
|
|
309
|
+
}
|
|
310
|
+
/** Adds group and region context to a stream error payload. */
|
|
311
|
+
function describeStreamError(region, group, message) {
|
|
312
|
+
return `${message} (${group === null || group === "" ? region : `${group} in ${region}`})`;
|
|
313
|
+
}
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/lib/components/LogGroupList.svelte
|
|
316
|
+
function LogGroupList($$renderer, $$props) {
|
|
317
|
+
$$renderer.component(($$renderer) => {
|
|
318
|
+
/** Groups returned for the active region. */
|
|
319
|
+
/** Active region, used for context in the empty state. */
|
|
320
|
+
/** Group name currently being tailed. */
|
|
321
|
+
/** API error message, shown instead of the list. */
|
|
322
|
+
/** Row cap; defaults to {@link MAX_GROUP_ROWS}. */
|
|
323
|
+
/** Called with the group name when a row is clicked. */
|
|
324
|
+
/** Called when the refresh button is clicked. */
|
|
325
|
+
let { groups = [], region = "", selected = null, loading = false, error = null, maxRows = 500} = $$props;
|
|
326
|
+
let search = "";
|
|
327
|
+
/** Groups matching the search box, case-insensitively. */
|
|
328
|
+
let filtered = derived(() => groups.filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase())));
|
|
329
|
+
/** Rows actually rendered, capped at `maxRows`. */
|
|
330
|
+
let visible = derived(() => filtered().slice(0, maxRows));
|
|
331
|
+
let truncated = derived(() => filtered().length > visible().length);
|
|
332
|
+
let query = derived(() => search.trim());
|
|
333
|
+
/** Row styling, highlighting the group that is currently being tailed. */
|
|
334
|
+
function rowTone(name) {
|
|
335
|
+
return name === selected ? "border-sky-800 bg-sky-950/50 text-sky-200" : "border-transparent bg-neutral-900/60 text-neutral-300 hover:border-neutral-800 hover:text-neutral-100";
|
|
336
|
+
}
|
|
337
|
+
$$renderer.push(`<section class="flex min-h-0 flex-1 flex-col gap-3"><div class="flex items-center justify-between gap-2"><div class="flex items-baseline gap-2"><h2 class="text-sm font-semibold text-neutral-200">Log groups</h2> <span class="text-xs text-neutral-500" data-testid="group-count">${escape_html(formatCount(filtered().length))} of ${escape_html(formatCount(groups.length))}</span></div> <button type="button"${attr("disabled", loading, true)} class="rounded-md border border-neutral-800 bg-neutral-900 px-2 py-1 text-xs font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:text-neutral-100 disabled:cursor-not-allowed disabled:opacity-50">${escape_html(loading ? "Refreshing…" : "Refresh")}</button></div> <div class="flex flex-col gap-1.5"><label class="sr-only" for="group-search">Search log groups</label> <input id="group-search" type="search" placeholder="Search log groups"${attr("value", search)} class="w-full rounded-md border border-neutral-800 bg-neutral-900 px-2.5 py-1.5 text-sm text-neutral-100 outline-none transition-colors placeholder:text-neutral-600 hover:border-neutral-700 focus:border-sky-600"/></div> `);
|
|
338
|
+
if (error !== null && error !== "") $$renderer.push(`<!--[0--><p role="alert" data-testid="group-error" class="rounded-md border border-red-900 bg-red-950/50 px-3 py-2 text-xs leading-5 text-red-300">${escape_html(error)}</p>`);
|
|
339
|
+
else if (loading && groups.length === 0) {
|
|
340
|
+
$$renderer.push(`<!--[1--><div class="flex flex-col gap-2" data-testid="group-loading" aria-busy="true"><!--[-->`);
|
|
341
|
+
const each_array = ensure_array_like(Array.from({ length: 5 }));
|
|
342
|
+
for (let index = 0, $$length = each_array.length; index < $$length; index++) {
|
|
343
|
+
each_array[index];
|
|
344
|
+
$$renderer.push(`<div class="h-5 animate-pulse rounded bg-neutral-800/80"></div>`);
|
|
345
|
+
}
|
|
346
|
+
$$renderer.push(`<!--]--> <p class="text-xs text-neutral-500">Loading log groups…</p></div>`);
|
|
347
|
+
} else if (groups.length === 0) $$renderer.push(`<!--[2--><p data-testid="group-empty" class="rounded-md border border-neutral-800 bg-neutral-900/60 px-3 py-2 text-xs leading-5 text-neutral-400">${escape_html(noGroupsMessage(region))}</p>`);
|
|
348
|
+
else if (visible().length === 0) $$renderer.push(`<!--[3--><p data-testid="group-no-match" class="px-1 py-2 text-xs text-neutral-500">No log groups match “${escape_html(query())}”.</p>`);
|
|
349
|
+
else {
|
|
350
|
+
$$renderer.push(`<!--[-1--><ul class="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto pr-1"><!--[-->`);
|
|
351
|
+
const each_array_1 = ensure_array_like(visible());
|
|
352
|
+
for (let $$index_1 = 0, $$length = each_array_1.length; $$index_1 < $$length; $$index_1++) {
|
|
353
|
+
let group = each_array_1[$$index_1];
|
|
354
|
+
$$renderer.push(`<li><button type="button" data-testid="group-row"${attr("aria-current", group.name === selected ? "true" : void 0)}${attr_class(`flex w-full items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors ${stringify(rowTone(group.name))}`)}><span class="truncate font-mono">${escape_html(group.name)}</span> `);
|
|
355
|
+
if (group.storedBytes !== void 0) $$renderer.push(`<!--[0--><span class="shrink-0 text-[0.6875rem] text-neutral-500">${escape_html(formatBytes(group.storedBytes))}</span>`);
|
|
356
|
+
else $$renderer.push("<!--[-1-->");
|
|
357
|
+
$$renderer.push(`<!--]--></button></li>`);
|
|
358
|
+
}
|
|
359
|
+
$$renderer.push(`<!--]--></ul> `);
|
|
360
|
+
if (truncated()) $$renderer.push(`<!--[0--><p class="text-[0.6875rem] text-neutral-500" data-testid="group-truncated">Showing first ${escape_html(formatCount(maxRows))} of ${escape_html(formatCount(filtered().length))} groups.</p>`);
|
|
361
|
+
else $$renderer.push("<!--[-1-->");
|
|
362
|
+
$$renderer.push(`<!--]-->`);
|
|
363
|
+
}
|
|
364
|
+
$$renderer.push(`<!--]--></section>`);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
//#endregion
|
|
368
|
+
//#region src/lib/components/StatusBadge.svelte
|
|
369
|
+
function StatusBadge($$renderer, $$props) {
|
|
370
|
+
/** Connection status reported by the log stream. */
|
|
371
|
+
let { status } = $$props;
|
|
372
|
+
/** Pill text per status. */
|
|
373
|
+
const LABELS = {
|
|
374
|
+
idle: "idle",
|
|
375
|
+
connecting: "connecting",
|
|
376
|
+
live: "live",
|
|
377
|
+
reconnecting: "reconnecting",
|
|
378
|
+
error: "error",
|
|
379
|
+
ended: "ended"
|
|
380
|
+
};
|
|
381
|
+
/** Pill colours per status. */
|
|
382
|
+
const TONES = {
|
|
383
|
+
idle: "border-neutral-700 bg-neutral-800/60 text-neutral-400",
|
|
384
|
+
connecting: "border-sky-900 bg-sky-950/70 text-sky-300",
|
|
385
|
+
live: "border-emerald-900 bg-emerald-950/70 text-emerald-300",
|
|
386
|
+
reconnecting: "border-amber-900 bg-amber-950/70 text-amber-300",
|
|
387
|
+
error: "border-red-900 bg-red-950/70 text-red-300",
|
|
388
|
+
ended: "border-neutral-700 bg-neutral-800/60 text-neutral-400"
|
|
389
|
+
};
|
|
390
|
+
/** Dot colours per status. */
|
|
391
|
+
const DOTS = {
|
|
392
|
+
idle: "bg-neutral-500",
|
|
393
|
+
connecting: "bg-sky-400 animate-pulse",
|
|
394
|
+
live: "bg-emerald-400",
|
|
395
|
+
reconnecting: "bg-amber-400 animate-pulse",
|
|
396
|
+
error: "bg-red-400",
|
|
397
|
+
ended: "bg-neutral-500"
|
|
398
|
+
};
|
|
399
|
+
let tone = derived(() => TONES[status]);
|
|
400
|
+
let dot = derived(() => DOTS[status]);
|
|
401
|
+
let text = derived(() => LABELS[status]);
|
|
402
|
+
$$renderer.push(`<span data-testid="status-badge"${attr("data-status", status)}${attr_class(`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${stringify(tone())}`)}><span${attr_class(`size-1.5 rounded-full ${stringify(dot())}`)} aria-hidden="true"></span> ${escape_html(text())}</span>`);
|
|
403
|
+
}
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/lib/log-buffer.ts
|
|
406
|
+
/** Maximum number of lines kept in the visible client buffer. */
|
|
407
|
+
var DEFAULT_CAPACITY = 5e3;
|
|
408
|
+
var ERROR_PATTERN = /\b(ERROR|FATAL|CRITICAL|CRIT|PANIC|EXCEPTION|TRACEBACK|SEVERE|FAILED|FAILURE)\b/;
|
|
409
|
+
var WARN_PATTERN = /\b(WARN|WARNING)\b/;
|
|
410
|
+
var DEBUG_PATTERN = /\b(DEBUG|TRACE|VERBOSE)\b/;
|
|
411
|
+
/**
|
|
412
|
+
* Guesses the severity of a log line from its text.
|
|
413
|
+
* Plain structured logs such as `{"level":"WARN"}` are detected too, because the level word is
|
|
414
|
+
* matched anywhere in the message. Order of precedence: error, warn, debug, info.
|
|
415
|
+
*/
|
|
416
|
+
function detectLevel(message) {
|
|
417
|
+
const upper = (message ?? "").toUpperCase();
|
|
418
|
+
if (ERROR_PATTERN.test(upper)) return "error";
|
|
419
|
+
if (WARN_PATTERN.test(upper)) return "warn";
|
|
420
|
+
if (DEBUG_PATTERN.test(upper)) return "debug";
|
|
421
|
+
return "info";
|
|
422
|
+
}
|
|
423
|
+
/** Tailwind text colour class for a severity. */
|
|
424
|
+
function levelColorClass(level) {
|
|
425
|
+
switch (level) {
|
|
426
|
+
case "error": return "text-red-400";
|
|
427
|
+
case "warn": return "text-amber-300";
|
|
428
|
+
case "debug": return "text-neutral-500";
|
|
429
|
+
default: return "text-neutral-200";
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/** Stable key for rendering a log line, since event ids can repeat or be missing. */
|
|
433
|
+
function eventKey(event, index) {
|
|
434
|
+
return `${event.id ?? "no-id"}:${event.timestamp}:${index}`;
|
|
435
|
+
}
|
|
436
|
+
/** Case-insensitive substring match over the message and the stream name. */
|
|
437
|
+
function matchesFilter(event, query) {
|
|
438
|
+
const needle = (query ?? "").trim().toLowerCase();
|
|
439
|
+
if (needle === "") return true;
|
|
440
|
+
return `${event.message ?? ""} ${event.streamName ?? ""}`.toLowerCase().includes(needle);
|
|
441
|
+
}
|
|
442
|
+
/** Returns the events matching `query`, in arrival order. */
|
|
443
|
+
function filterEvents(events, query) {
|
|
444
|
+
if ((query ?? "").trim() === "") return events.slice();
|
|
445
|
+
return events.filter((event) => matchesFilter(event, query));
|
|
446
|
+
}
|
|
447
|
+
/** Creates an array of `length` undefined slots. */
|
|
448
|
+
function emptySlots(length) {
|
|
449
|
+
return Array.from({ length });
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Fixed-capacity ring buffer that overwrites the oldest item once it is full.
|
|
453
|
+
*/
|
|
454
|
+
var LogRingBuffer = class {
|
|
455
|
+
/** Maximum number of items kept. */
|
|
456
|
+
capacity;
|
|
457
|
+
#items;
|
|
458
|
+
#start = 0;
|
|
459
|
+
#size = 0;
|
|
460
|
+
#dropped = 0;
|
|
461
|
+
constructor(capacity = DEFAULT_CAPACITY) {
|
|
462
|
+
if (!Number.isInteger(capacity) || capacity <= 0) throw new RangeError(`capacity must be a positive integer, received ${capacity}`);
|
|
463
|
+
this.capacity = capacity;
|
|
464
|
+
this.#items = emptySlots(capacity);
|
|
465
|
+
}
|
|
466
|
+
/** Number of items currently stored. */
|
|
467
|
+
get size() {
|
|
468
|
+
return this.#size;
|
|
469
|
+
}
|
|
470
|
+
/** Number of items discarded because the buffer was full. */
|
|
471
|
+
get dropped() {
|
|
472
|
+
return this.#dropped;
|
|
473
|
+
}
|
|
474
|
+
/** True when the buffer holds `capacity` items. */
|
|
475
|
+
get full() {
|
|
476
|
+
return this.#size === this.capacity;
|
|
477
|
+
}
|
|
478
|
+
/** Appends one item, dropping the oldest item when the buffer is full. */
|
|
479
|
+
push(item) {
|
|
480
|
+
if (this.#size === this.capacity) {
|
|
481
|
+
this.#dropped += 1;
|
|
482
|
+
this.#start = (this.#start + 1) % this.capacity;
|
|
483
|
+
this.#size -= 1;
|
|
484
|
+
}
|
|
485
|
+
const index = (this.#start + this.#size) % this.capacity;
|
|
486
|
+
this.#items[index] = item;
|
|
487
|
+
this.#size += 1;
|
|
488
|
+
}
|
|
489
|
+
/** Appends several items in order. */
|
|
490
|
+
pushMany(items) {
|
|
491
|
+
for (const item of items) this.push(item);
|
|
492
|
+
}
|
|
493
|
+
/** Copies the stored items into an array ordered oldest to newest. */
|
|
494
|
+
toArray() {
|
|
495
|
+
const output = emptySlots(this.#size);
|
|
496
|
+
for (let offset = 0; offset < this.#size; offset += 1) output[offset] = this.#items[(this.#start + offset) % this.capacity];
|
|
497
|
+
return output;
|
|
498
|
+
}
|
|
499
|
+
/** Oldest stored item, or `undefined` when empty. */
|
|
500
|
+
first() {
|
|
501
|
+
return this.#size === 0 ? void 0 : this.#items[this.#start];
|
|
502
|
+
}
|
|
503
|
+
/** Newest stored item, or `undefined` when empty. */
|
|
504
|
+
last() {
|
|
505
|
+
return this.#size === 0 ? void 0 : this.#items[(this.#start + this.#size - 1) % this.capacity];
|
|
506
|
+
}
|
|
507
|
+
/** Removes every item and resets the dropped counter. */
|
|
508
|
+
clear() {
|
|
509
|
+
this.#items = emptySlots(this.capacity);
|
|
510
|
+
this.#start = 0;
|
|
511
|
+
this.#size = 0;
|
|
512
|
+
this.#dropped = 0;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
/**
|
|
516
|
+
* Log store used by the UI: a visible ring buffer plus a pending buffer that fills up while the
|
|
517
|
+
* view is paused. `push()` never drops silent data, it either appends to the visible buffer or to
|
|
518
|
+
* the pending buffer; `resume()` moves the pending events into the visible buffer in order.
|
|
519
|
+
*/
|
|
520
|
+
var LogStore = class {
|
|
521
|
+
buffer;
|
|
522
|
+
#pending;
|
|
523
|
+
#paused = false;
|
|
524
|
+
constructor(capacity = DEFAULT_CAPACITY) {
|
|
525
|
+
this.buffer = new LogRingBuffer(capacity);
|
|
526
|
+
this.#pending = new LogRingBuffer(capacity);
|
|
527
|
+
}
|
|
528
|
+
/** True when incoming events are buffered instead of appended to the visible buffer. */
|
|
529
|
+
get paused() {
|
|
530
|
+
return this.#paused;
|
|
531
|
+
}
|
|
532
|
+
/** Number of visible lines. */
|
|
533
|
+
get size() {
|
|
534
|
+
return this.buffer.size;
|
|
535
|
+
}
|
|
536
|
+
/** Number of lines held back while paused. */
|
|
537
|
+
get pendingCount() {
|
|
538
|
+
return this.#pending.size;
|
|
539
|
+
}
|
|
540
|
+
/** Number of visible lines dropped because the buffer was full. */
|
|
541
|
+
get droppedCount() {
|
|
542
|
+
return this.buffer.dropped;
|
|
543
|
+
}
|
|
544
|
+
/** Appends events to the visible buffer, or holds them back while paused. */
|
|
545
|
+
push(events) {
|
|
546
|
+
if (this.#paused) {
|
|
547
|
+
this.#pending.pushMany(events);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
this.buffer.pushMany(events);
|
|
551
|
+
}
|
|
552
|
+
/** Pauses or resumes appending. Resuming flushes the pending events immediately. */
|
|
553
|
+
setPaused(paused) {
|
|
554
|
+
if (paused === this.#paused) return [];
|
|
555
|
+
this.#paused = paused;
|
|
556
|
+
return paused ? [] : this.flush();
|
|
557
|
+
}
|
|
558
|
+
/** Pauses appending to the visible buffer. */
|
|
559
|
+
pause() {
|
|
560
|
+
this.#paused = true;
|
|
561
|
+
}
|
|
562
|
+
/** Resumes appending and returns the events flushed from the pending buffer. */
|
|
563
|
+
resume() {
|
|
564
|
+
this.#paused = false;
|
|
565
|
+
return this.flush();
|
|
566
|
+
}
|
|
567
|
+
/** Moves every pending event into the visible buffer, oldest first. */
|
|
568
|
+
flush() {
|
|
569
|
+
const flushed = this.#pending.toArray();
|
|
570
|
+
this.#pending.clear();
|
|
571
|
+
this.buffer.pushMany(flushed);
|
|
572
|
+
return flushed;
|
|
573
|
+
}
|
|
574
|
+
/** Copies the visible lines, oldest first. */
|
|
575
|
+
toArray() {
|
|
576
|
+
return this.buffer.toArray();
|
|
577
|
+
}
|
|
578
|
+
/** Empties the visible buffer and discards pending events. */
|
|
579
|
+
clear() {
|
|
580
|
+
this.buffer.clear();
|
|
581
|
+
this.#pending.clear();
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region src/lib/log-format.ts
|
|
586
|
+
/**
|
|
587
|
+
* Parses a log message that is entirely JSON.
|
|
588
|
+
*
|
|
589
|
+
* Only objects and arrays count: a message that happens to be `42` or `"ok"` is
|
|
590
|
+
* left alone, and anything that does not parse is not JSON. Leading and trailing
|
|
591
|
+
* whitespace is ignored.
|
|
592
|
+
*/
|
|
593
|
+
function detectJson(message) {
|
|
594
|
+
if (typeof message !== "string") return null;
|
|
595
|
+
const trimmed = message.trim();
|
|
596
|
+
if (trimmed.length === 0) return null;
|
|
597
|
+
const first = trimmed[0];
|
|
598
|
+
if (first !== "{" && first !== "[") return null;
|
|
599
|
+
try {
|
|
600
|
+
const parsed = JSON.parse(trimmed);
|
|
601
|
+
if (parsed === null || typeof parsed !== "object") return null;
|
|
602
|
+
return parsed;
|
|
603
|
+
} catch {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Returns the text to render for a log message.
|
|
609
|
+
*
|
|
610
|
+
* `prettyJson` is normally true: JSON messages are re-serialised with two-space
|
|
611
|
+
* indentation. With `prettyJson` false the raw message is returned unchanged,
|
|
612
|
+
* which is what the viewer's JSON toggle switches to.
|
|
613
|
+
*/
|
|
614
|
+
function formatLogMessage(message, options = {}) {
|
|
615
|
+
const { prettyJson = true } = options;
|
|
616
|
+
const value = detectJson(message);
|
|
617
|
+
if (value === null || !prettyJson) return {
|
|
618
|
+
text: message,
|
|
619
|
+
isJson: value !== null,
|
|
620
|
+
value
|
|
621
|
+
};
|
|
622
|
+
return {
|
|
623
|
+
text: JSON.stringify(value, null, 2),
|
|
624
|
+
isJson: true,
|
|
625
|
+
value
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
var NUMBER_PATTERN = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
629
|
+
/**
|
|
630
|
+
* Splits pretty-printed JSON into coloured tokens.
|
|
631
|
+
*
|
|
632
|
+
* The concatenation of every token's `text` is always identical to the input, so
|
|
633
|
+
* rendering tokens can never lose or alter a character. This is a display-only
|
|
634
|
+
* scanner: it assumes well-formed JSON produced by `JSON.stringify`.
|
|
635
|
+
*/
|
|
636
|
+
function tokenizeJson(text) {
|
|
637
|
+
const tokens = [];
|
|
638
|
+
let index = 0;
|
|
639
|
+
const push = (type, value) => {
|
|
640
|
+
if (value.length > 0) tokens.push({
|
|
641
|
+
type,
|
|
642
|
+
text: value
|
|
643
|
+
});
|
|
644
|
+
};
|
|
645
|
+
while (index < text.length) {
|
|
646
|
+
const char = text[index];
|
|
647
|
+
if (char === " " || char === "\n" || char === " " || char === "\r") {
|
|
648
|
+
let end = index;
|
|
649
|
+
while (end < text.length && " \n \r".includes(text[end])) end += 1;
|
|
650
|
+
push("whitespace", text.slice(index, end));
|
|
651
|
+
index = end;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (char === "\"") {
|
|
655
|
+
let end = index + 1;
|
|
656
|
+
while (end < text.length) {
|
|
657
|
+
if (text[end] === "\\") {
|
|
658
|
+
end += 2;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (text[end] === "\"") break;
|
|
662
|
+
end += 1;
|
|
663
|
+
}
|
|
664
|
+
const raw = text.slice(index, Math.min(end + 1, text.length));
|
|
665
|
+
let lookahead = index + raw.length;
|
|
666
|
+
while (lookahead < text.length && " \n \r".includes(text[lookahead])) lookahead += 1;
|
|
667
|
+
push(text[lookahead] === ":" ? "key" : "string", raw);
|
|
668
|
+
index += raw.length;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (char === "{" || char === "}" || char === "[" || char === "]" || char === "," || char === ":") {
|
|
672
|
+
push("punctuation", char);
|
|
673
|
+
index += 1;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (text.startsWith("true", index) || text.startsWith("false", index)) {
|
|
677
|
+
const literal = text.startsWith("true", index) ? "true" : "false";
|
|
678
|
+
push("boolean", literal);
|
|
679
|
+
index += literal.length;
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (text.startsWith("null", index)) {
|
|
683
|
+
push("null", "null");
|
|
684
|
+
index += 4;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
let end = index;
|
|
688
|
+
while (end < text.length && !"{}[],:\" \n \r".includes(text[end])) end += 1;
|
|
689
|
+
const raw = text.slice(index, Math.max(end, index + 1));
|
|
690
|
+
push(NUMBER_PATTERN.test(raw) ? "number" : "other", raw);
|
|
691
|
+
index += raw.length;
|
|
692
|
+
}
|
|
693
|
+
return tokens;
|
|
694
|
+
}
|
|
695
|
+
//#endregion
|
|
696
|
+
//#region src/lib/components/LogViewer.svelte
|
|
697
|
+
function LogViewer($$renderer, $$props) {
|
|
698
|
+
$$renderer.component(($$renderer) => {
|
|
699
|
+
/** Lines held by the client buffer, oldest first. */
|
|
700
|
+
/** Connection status. */
|
|
701
|
+
/** Last stream error, or `null`. */
|
|
702
|
+
/** Active region, used for error and empty-state context. */
|
|
703
|
+
/** Group being tailed, or `null` when nothing is selected. */
|
|
704
|
+
/** Client-side text filter, controlled by the parent. */
|
|
705
|
+
/** True while the visible buffer is frozen. */
|
|
706
|
+
/** True when new lines scroll the view. */
|
|
707
|
+
/** Lines held back while paused. */
|
|
708
|
+
/** Lines dropped by the cap. */
|
|
709
|
+
/** Total events received since the last reset. */
|
|
710
|
+
/** Active tail mode, used for the window chip. */
|
|
711
|
+
/** Last `ready` payload, which describes the window the server is scanning. */
|
|
712
|
+
/** Reason the server ended the stream, for example `window-complete`. */
|
|
713
|
+
let { lines = [], status = "idle", error = null, region = "", group = null, filter = "", paused = false, autoScroll = true, pendingCount = 0, droppedCount = 0, receivedCount = 0, mode = "live", ready = null, endReason = null} = $$props;
|
|
714
|
+
const TIMESTAMP_COLUMN_PX = remToPx(7.5);
|
|
715
|
+
const ROW_PADDING_PX = remToPx(.75);
|
|
716
|
+
const ROW_GAP_PX = remToPx(.5);
|
|
717
|
+
const PREFIX_MIN_PX = remToPx(PREFIX_WIDTH.minRem);
|
|
718
|
+
const PREFIX_MAX_PX = remToPx(PREFIX_WIDTH.maxRem);
|
|
719
|
+
const PREFIX_DEFAULT_PX = remToPx(PREFIX_WIDTH.defaultRem);
|
|
720
|
+
/** Persists a preference; failures are ignored (private mode, quota). */
|
|
721
|
+
function writePref(key, value) {
|
|
722
|
+
try {
|
|
723
|
+
if (typeof localStorage === "undefined") return;
|
|
724
|
+
localStorage.setItem(key, value);
|
|
725
|
+
} catch {}
|
|
726
|
+
}
|
|
727
|
+
/** View preferences, restored in `onMount` so SSR output stays deterministic. */
|
|
728
|
+
let wrapLines = false;
|
|
729
|
+
let jsonView = true;
|
|
730
|
+
let prefixPx = PREFIX_DEFAULT_PX;
|
|
731
|
+
/** One rendered row, pre-formatted so the template stays declarative. */
|
|
732
|
+
/** Lines matching the client-side filter. */
|
|
733
|
+
let visible = derived(() => filterEvents(lines, filter));
|
|
734
|
+
/** Presentation rows for the visible lines. */
|
|
735
|
+
let rows = derived(() => visible().map((event, index) => {
|
|
736
|
+
const level = detectLevel(event.message);
|
|
737
|
+
const formatted = formatLogMessage(event.message, { prettyJson: jsonView });
|
|
738
|
+
return {
|
|
739
|
+
key: eventKey(event, index),
|
|
740
|
+
time: formatTime(event.timestamp),
|
|
741
|
+
timestamp: formatTimestamp(event.timestamp),
|
|
742
|
+
streamName: event.streamName,
|
|
743
|
+
message: formatted.text,
|
|
744
|
+
tokens: formatted.isJson ? tokenizeJson(formatted.text) : null,
|
|
745
|
+
level,
|
|
746
|
+
levelClass: levelColorClass(level)
|
|
747
|
+
};
|
|
748
|
+
}));
|
|
749
|
+
/** Keeps the newest line in view while auto-scroll is on (vertical only). */
|
|
750
|
+
/** Error text with the group and region context. */
|
|
751
|
+
let errorText = derived(() => error === null ? null : describeStreamError(region, group, error.message));
|
|
752
|
+
/** Body text when there is nothing to render. */
|
|
753
|
+
let emptyText = derived(() => group === null || group === "" ? "Select a log group to start tailing." : filter.trim() === "" ? `Waiting for events from ${group}\u2026` : "No lines match the filter.");
|
|
754
|
+
/** Auto-scroll button colour. */
|
|
755
|
+
let autoScrollTone = derived(() => autoScroll ? "text-sky-300" : "text-neutral-400");
|
|
756
|
+
/** Chip describing the active window. */
|
|
757
|
+
let windowSummary = derived(() => describeWindow(ready, Date.now()));
|
|
758
|
+
/** True when a historic scan finished on its own. */
|
|
759
|
+
let windowComplete = derived(() => mode === "historic" && endReason === "window-complete");
|
|
760
|
+
/** Wrap toggle colour. */
|
|
761
|
+
let wrapTone = derived(() => "text-neutral-400");
|
|
762
|
+
/** JSON toggle colour. */
|
|
763
|
+
let jsonTone = derived(() => "text-sky-300");
|
|
764
|
+
/** Rows fill the window when wrapping, and grow to their content otherwise. */
|
|
765
|
+
let listClass = derived(() => "w-max min-w-full");
|
|
766
|
+
/** Message cell: wrapped text or a single long line that scrolls sideways. */
|
|
767
|
+
let messageClass = derived(() => "whitespace-pre");
|
|
768
|
+
/** Left edge of the prefix resize handle, in pixels from the scrolled content edge. */
|
|
769
|
+
let handleLeft = derived(() => ROW_PADDING_PX + TIMESTAMP_COLUMN_PX + ROW_GAP_PX + prefixPx);
|
|
770
|
+
/** Prefix column width as a CSS length. */
|
|
771
|
+
let prefixStyle = derived(() => `max-width: ${pxToRem(prefixPx)}`);
|
|
772
|
+
/** Colour for a JSON token, keeping punctuation and whitespace quiet. */
|
|
773
|
+
function tokenClass(type) {
|
|
774
|
+
switch (type) {
|
|
775
|
+
case "key": return "text-sky-300";
|
|
776
|
+
case "string": return "text-emerald-300";
|
|
777
|
+
case "number": return "text-amber-300";
|
|
778
|
+
case "boolean": return "text-fuchsia-300";
|
|
779
|
+
case "null": return "text-neutral-500";
|
|
780
|
+
case "punctuation": return "text-neutral-600";
|
|
781
|
+
default: return "";
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/** Persists the current prefix width. */
|
|
785
|
+
function savePrefixWidth() {
|
|
786
|
+
writePref(STORAGE_KEYS.prefixWidth, String(Math.round(prefixPx)));
|
|
787
|
+
}
|
|
788
|
+
$$renderer.push(`<section class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-950"><div class="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-neutral-800 bg-neutral-900/40 px-3 py-2">`);
|
|
789
|
+
StatusBadge($$renderer, { status });
|
|
790
|
+
$$renderer.push(`<!----> <span class="text-xs text-neutral-400" data-testid="visible-count">${escape_html(formatCount(visible().length))} shown</span> <span class="text-xs text-neutral-500" data-testid="received-count">${escape_html(formatCount(receivedCount))} received</span> `);
|
|
791
|
+
if (pendingCount > 0) $$renderer.push(`<!--[0--><span class="text-xs text-amber-300" data-testid="pending-count">${escape_html(formatCount(pendingCount))} buffered</span>`);
|
|
792
|
+
else $$renderer.push("<!--[-1-->");
|
|
793
|
+
$$renderer.push(`<!--]--> `);
|
|
794
|
+
if (droppedCount > 0) $$renderer.push(`<!--[0--><span class="text-xs text-amber-300/80" data-testid="dropped-count">${escape_html(formatCount(droppedCount))} dropped</span>`);
|
|
795
|
+
else $$renderer.push("<!--[-1-->");
|
|
796
|
+
$$renderer.push(`<!--]--> <span${attr_class(`rounded-full border px-2 py-0.5 text-[0.6875rem] font-medium ${windowSummary().mode === "historic" ? "border-amber-900 bg-amber-950/40 text-amber-300" : "border-neutral-800 bg-neutral-900 text-neutral-400"}`)}${attr("title", windowSummary().title)} data-testid="window-chip">${escape_html(windowSummary().label)}</span> `);
|
|
797
|
+
if (windowSummary().clamped) $$renderer.push(`<!--[0--><span class="text-[0.6875rem] text-amber-400/80" data-testid="window-clamped">14-day limit</span>`);
|
|
798
|
+
else $$renderer.push("<!--[-1-->");
|
|
799
|
+
$$renderer.push(`<!--]--> `);
|
|
800
|
+
if (windowComplete()) $$renderer.push(`<!--[0--><span class="text-[0.6875rem] text-emerald-400/80" data-testid="window-complete">window complete</span>`);
|
|
801
|
+
else $$renderer.push("<!--[-1-->");
|
|
802
|
+
$$renderer.push(`<!--]--> <div class="ml-auto flex flex-wrap items-center gap-2"><input type="search" aria-label="Filter log lines" placeholder="Filter lines"${attr("value", filter)} class="w-40 rounded-md border border-neutral-800 bg-neutral-900 px-2 py-1 text-xs text-neutral-100 outline-none transition-colors placeholder:text-neutral-600 hover:border-neutral-700 focus:border-sky-600"/> <button type="button"${attr("aria-pressed", wrapLines)} title="Wrap long lines instead of scrolling sideways" data-testid="wrap-toggle"${attr_class(`rounded-md border border-neutral-800 bg-neutral-900 px-2 py-1 text-xs font-medium transition-colors hover:border-neutral-700 ${wrapTone()}`)}>Wrap</button> <button type="button"${attr("aria-pressed", jsonView)} title="Pretty-print JSON log entries" data-testid="json-toggle"${attr_class(`rounded-md border border-neutral-800 bg-neutral-900 px-2 py-1 text-xs font-medium transition-colors hover:border-neutral-700 ${jsonTone()}`)}>JSON</button> <button type="button"${attr("aria-pressed", paused)} class="rounded-md border border-neutral-800 bg-neutral-900 px-2 py-1 text-xs font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:text-neutral-100">${escape_html(paused ? "Resume" : "Pause")}</button> <button type="button" class="rounded-md border border-neutral-800 bg-neutral-900 px-2 py-1 text-xs font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:text-neutral-100">Clear</button> <button type="button"${attr("aria-pressed", autoScroll)}${attr_class(`rounded-md border border-neutral-800 bg-neutral-900 px-2 py-1 text-xs font-medium transition-colors hover:border-neutral-700 ${autoScrollTone()}`)}>Auto-scroll</button></div></div> `);
|
|
803
|
+
if (errorText() !== null) $$renderer.push(`<!--[0--><p role="alert" data-testid="stream-error" class="border-b border-red-900 bg-red-950/50 px-3 py-2 text-xs leading-5 text-red-300">${escape_html(errorText())}</p>`);
|
|
804
|
+
else $$renderer.push("<!--[-1-->");
|
|
805
|
+
$$renderer.push(`<!--]--> <div class="min-h-0 min-w-0 flex-1 overflow-auto bg-neutral-950" data-testid="log-scroller">`);
|
|
806
|
+
if (group === null || group === "") $$renderer.push(`<!--[0--><p class="px-3 py-6 text-sm text-neutral-500" data-testid="viewer-idle">Select a log group to start tailing.</p>`);
|
|
807
|
+
else if (rows().length === 0) $$renderer.push(`<!--[1--><p class="px-3 py-6 text-sm text-neutral-500" data-testid="viewer-empty">${escape_html(emptyText())}</p>`);
|
|
808
|
+
else {
|
|
809
|
+
$$renderer.push(`<!--[-1--><div${attr_class(`relative min-h-full ${listClass()}`)} data-testid="log-canvas"><ol class="font-mono text-xs leading-5" data-testid="log-lines"><!--[-->`);
|
|
810
|
+
const each_array = ensure_array_like(rows());
|
|
811
|
+
for (let $$index_1 = 0, $$length = each_array.length; $$index_1 < $$length; $$index_1++) {
|
|
812
|
+
let row = each_array[$$index_1];
|
|
813
|
+
$$renderer.push(`<li${attr_class(`flex items-baseline gap-2 px-3 py-0.5 hover:bg-neutral-900/60 ${listClass()}`)} data-testid="log-line"${attr("data-level", row.level)}><span class="w-[7.5rem] shrink-0 text-neutral-500"${attr("title", row.timestamp)}>${escape_html(row.time)}</span> <span class="shrink-0 truncate text-sky-400/80"${attr_style(prefixStyle())}${attr("title", row.streamName ?? "")} data-testid="log-stream">${escape_html(row.streamName ?? "")}</span> <span${attr_class(`${messageClass()} ${stringify(row.levelClass)}`)} data-testid="log-message">`);
|
|
814
|
+
if (row.tokens !== null) {
|
|
815
|
+
$$renderer.push(`<!--[0--><!--[-->`);
|
|
816
|
+
const each_array_1 = ensure_array_like(row.tokens);
|
|
817
|
+
for (let tokenIndex = 0, $$length = each_array_1.length; tokenIndex < $$length; tokenIndex++) {
|
|
818
|
+
let token = each_array_1[tokenIndex];
|
|
819
|
+
$$renderer.push(`<span${attr_class(clsx(tokenClass(token.type)))}>${escape_html(token.text)}</span>`);
|
|
820
|
+
}
|
|
821
|
+
$$renderer.push(`<!--]-->`);
|
|
822
|
+
} else $$renderer.push(`<!--[-1-->${escape_html(row.message)}`);
|
|
823
|
+
$$renderer.push(`<!--]--></span></li>`);
|
|
824
|
+
}
|
|
825
|
+
$$renderer.push(`<!--]--></ol> `);
|
|
826
|
+
ColumnResizer($$renderer, {
|
|
827
|
+
label: "Resize prefix column",
|
|
828
|
+
width: prefixPx,
|
|
829
|
+
min: PREFIX_MIN_PX,
|
|
830
|
+
max: PREFIX_MAX_PX,
|
|
831
|
+
testId: "prefix-resizer",
|
|
832
|
+
onChange: (next) => prefixPx = next,
|
|
833
|
+
onCommit: () => savePrefixWidth(),
|
|
834
|
+
class: "absolute inset-y-0 w-2",
|
|
835
|
+
style: `left: ${stringify(handleLeft() - 4)}px`
|
|
836
|
+
});
|
|
837
|
+
$$renderer.push(`<!----></div>`);
|
|
838
|
+
}
|
|
839
|
+
$$renderer.push(`<!--]--></div></section>`);
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/lib/components/RegionSelect.svelte
|
|
844
|
+
function RegionSelect($$renderer, $$props) {
|
|
845
|
+
$$renderer.component(($$renderer) => {
|
|
846
|
+
/** Regions offered by the picker; defaults to {@link DEFAULT_REGIONS}. */
|
|
847
|
+
/** Currently selected region. */
|
|
848
|
+
/** Called with the newly selected region. */
|
|
849
|
+
let { regions = DEFAULT_REGIONS, value = "", disabled = false, label = "Region", id = "region-select", onchange } = $$props;
|
|
850
|
+
/** Offered regions, plus the current value when the server does not list it. */
|
|
851
|
+
let options = derived(() => value !== "" && !regions.includes(value) ? [value, ...regions] : [...regions]);
|
|
852
|
+
$$renderer.push(`<div class="flex flex-col gap-1.5"><label${attr("for", id)} class="text-[0.6875rem] font-semibold uppercase tracking-wider text-neutral-500">${escape_html(label)}</label> `);
|
|
853
|
+
$$renderer.select({
|
|
854
|
+
id,
|
|
855
|
+
disabled,
|
|
856
|
+
value,
|
|
857
|
+
onchange: (event) => onchange?.(event.currentTarget.value),
|
|
858
|
+
class: "w-full rounded-md border border-neutral-800 bg-neutral-900 px-2.5 py-1.5 text-sm text-neutral-100 outline-none transition-colors hover:border-neutral-700 focus:border-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
|
859
|
+
}, ($$renderer) => {
|
|
860
|
+
$$renderer.push(`<!--[-->`);
|
|
861
|
+
const each_array = ensure_array_like(options());
|
|
862
|
+
for (let $$index = 0, $$length = each_array.length; $$index < $$length; $$index++) {
|
|
863
|
+
let option = each_array[$$index];
|
|
864
|
+
$$renderer.option({ value: option }, ($$renderer) => {
|
|
865
|
+
$$renderer.push(`${escape_html(option)}`);
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
$$renderer.push(`<!--]-->`);
|
|
869
|
+
});
|
|
870
|
+
$$renderer.push(`</div>`);
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
function createBrowserEventSource(url) {
|
|
874
|
+
return new EventSource(url);
|
|
875
|
+
}
|
|
876
|
+
/** Builds the `/api/stream` SSE URL for a target. `base` allows an absolute URL in tests. */
|
|
877
|
+
function buildStreamUrl(target, base = "") {
|
|
878
|
+
const search = new URLSearchParams();
|
|
879
|
+
search.set("region", target.region);
|
|
880
|
+
search.set("group", target.group);
|
|
881
|
+
if (target.filterPattern) search.set("filterPattern", target.filterPattern);
|
|
882
|
+
if (target.mode === "historic") {
|
|
883
|
+
search.set("mode", "historic");
|
|
884
|
+
if (target.range) search.set("range", target.range);
|
|
885
|
+
if (target.from !== void 0 && target.from !== "") search.set("from", String(target.from));
|
|
886
|
+
if (target.to !== void 0 && target.to !== "") search.set("to", String(target.to));
|
|
887
|
+
} else if (target.mode === "live") search.set("mode", "live");
|
|
888
|
+
if (target.startTime !== void 0 && target.startTime !== "") search.set("startTime", String(target.startTime));
|
|
889
|
+
if (target.lookback) search.set("lookback", target.lookback);
|
|
890
|
+
if (typeof target.poll === "number" && Number.isFinite(target.poll)) search.set("poll", String(target.poll));
|
|
891
|
+
return `${base}/api/stream?${search.toString()}`;
|
|
892
|
+
}
|
|
893
|
+
/** Parses an SSE payload, returning `null` instead of throwing on malformed JSON. */
|
|
894
|
+
function parsePayload(data) {
|
|
895
|
+
try {
|
|
896
|
+
return JSON.parse(data);
|
|
897
|
+
} catch {
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Live view over one CloudWatch log group.
|
|
903
|
+
*
|
|
904
|
+
* Pause policy: pausing keeps the connection open and keeps receiving events; those events are
|
|
905
|
+
* held in a pending buffer and flushed into the visible buffer on resume.
|
|
906
|
+
*/
|
|
907
|
+
var LogStream = class {
|
|
908
|
+
/** Connection status shown by the status badge. */
|
|
909
|
+
status = "idle";
|
|
910
|
+
/** Last server-side or transport error, or `null`. */
|
|
911
|
+
lastError = null;
|
|
912
|
+
/** Payload of the last `ready` event. */
|
|
913
|
+
ready = null;
|
|
914
|
+
/** Current target, or `null` after `stop()`. */
|
|
915
|
+
target = null;
|
|
916
|
+
/** Visible log lines, oldest first, capped by the buffer capacity. */
|
|
917
|
+
lines = [];
|
|
918
|
+
/** Total number of events received since the last reset. */
|
|
919
|
+
receivedCount = 0;
|
|
920
|
+
/** Lines dropped because the visible buffer was full. */
|
|
921
|
+
droppedCount = 0;
|
|
922
|
+
/** Lines held back while paused. */
|
|
923
|
+
pendingCount = 0;
|
|
924
|
+
/** True when the visible buffer is frozen and events are buffered. */
|
|
925
|
+
paused = false;
|
|
926
|
+
/** Timestamp of the last `ping` or `log` event. */
|
|
927
|
+
lastEventAt = null;
|
|
928
|
+
/** Reason given by the last `end` event. */
|
|
929
|
+
endReason = null;
|
|
930
|
+
#store;
|
|
931
|
+
#source = null;
|
|
932
|
+
#listeners = [];
|
|
933
|
+
#reconnectTimer = null;
|
|
934
|
+
#createEventSource;
|
|
935
|
+
#reconnectDelayMs;
|
|
936
|
+
#url;
|
|
937
|
+
#schedule;
|
|
938
|
+
#cancel;
|
|
939
|
+
constructor(options = {}) {
|
|
940
|
+
this.#store = new LogStore(options.capacity ?? 5e3);
|
|
941
|
+
this.#createEventSource = options.createEventSource ?? createBrowserEventSource;
|
|
942
|
+
this.#reconnectDelayMs = options.reconnectDelayMs ?? 1e3;
|
|
943
|
+
this.#url = options.url ?? ((target) => buildStreamUrl(target));
|
|
944
|
+
this.#schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
945
|
+
this.#cancel = options.cancel ?? ((handle) => clearTimeout(handle));
|
|
946
|
+
}
|
|
947
|
+
/** Starts tailing a target, replacing any previous stream and clearing the buffer. */
|
|
948
|
+
start(target) {
|
|
949
|
+
this.#closeSource();
|
|
950
|
+
this.#clearReconnect();
|
|
951
|
+
this.clear();
|
|
952
|
+
this.status = "connecting";
|
|
953
|
+
this.lastError = null;
|
|
954
|
+
this.ready = null;
|
|
955
|
+
this.endReason = null;
|
|
956
|
+
this.target = { ...target };
|
|
957
|
+
this.#connect(false);
|
|
958
|
+
}
|
|
959
|
+
/** Stops tailing and forgets the target; the buffered lines stay visible. */
|
|
960
|
+
stop() {
|
|
961
|
+
this.#clearReconnect();
|
|
962
|
+
this.#closeSource();
|
|
963
|
+
this.target = null;
|
|
964
|
+
this.status = "idle";
|
|
965
|
+
}
|
|
966
|
+
/** Freezes the visible buffer; incoming events are held until {@link resume}. */
|
|
967
|
+
pause() {
|
|
968
|
+
this.#store.pause();
|
|
969
|
+
this.paused = true;
|
|
970
|
+
}
|
|
971
|
+
/** Thaws the visible buffer and flushes the events held while paused. */
|
|
972
|
+
resume() {
|
|
973
|
+
this.#store.resume();
|
|
974
|
+
this.paused = false;
|
|
975
|
+
this.#syncStore();
|
|
976
|
+
}
|
|
977
|
+
/** Pauses or resumes, depending on the current state. */
|
|
978
|
+
togglePause() {
|
|
979
|
+
if (this.paused) this.resume();
|
|
980
|
+
else this.pause();
|
|
981
|
+
}
|
|
982
|
+
/** Empties the visible buffer, the pending buffer and the counters. */
|
|
983
|
+
clear() {
|
|
984
|
+
this.#store.clear();
|
|
985
|
+
this.paused = false;
|
|
986
|
+
this.lines = [];
|
|
987
|
+
this.receivedCount = 0;
|
|
988
|
+
this.droppedCount = 0;
|
|
989
|
+
this.pendingCount = 0;
|
|
990
|
+
}
|
|
991
|
+
/** Number of visible lines. */
|
|
992
|
+
get size() {
|
|
993
|
+
return this.#store.size;
|
|
994
|
+
}
|
|
995
|
+
/** Opens a connection for the current target. */
|
|
996
|
+
#connect(retry) {
|
|
997
|
+
const target = this.target;
|
|
998
|
+
if (target === null) return;
|
|
999
|
+
this.#closeSource();
|
|
1000
|
+
this.status = retry ? "reconnecting" : "connecting";
|
|
1001
|
+
let source;
|
|
1002
|
+
try {
|
|
1003
|
+
source = this.#createEventSource(this.#url(target));
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
this.status = "error";
|
|
1006
|
+
this.lastError = { message: error instanceof Error ? error.message : String(error) };
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
this.#source = source;
|
|
1010
|
+
this.#listen(source, "open", () => {
|
|
1011
|
+
this.status = "live";
|
|
1012
|
+
this.lastError = null;
|
|
1013
|
+
});
|
|
1014
|
+
this.#listen(source, "error", (event) => this.#handleErrorEvent(event));
|
|
1015
|
+
this.#listen(source, "ready", (event) => this.#handleReady(event));
|
|
1016
|
+
this.#listen(source, "log", (event) => this.#handleLog(event));
|
|
1017
|
+
this.#listen(source, "ping", (event) => this.#handlePing(event));
|
|
1018
|
+
this.#listen(source, "end", (event) => this.#handleEnd(event));
|
|
1019
|
+
}
|
|
1020
|
+
/** Registers a named-event listener and remembers it for cleanup. */
|
|
1021
|
+
#listen(source, type, handler) {
|
|
1022
|
+
source.addEventListener(type, handler);
|
|
1023
|
+
this.#listeners.push({
|
|
1024
|
+
source,
|
|
1025
|
+
type,
|
|
1026
|
+
handler
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
/** Detaches listeners and closes the current connection. */
|
|
1030
|
+
#closeSource() {
|
|
1031
|
+
for (const listener of this.#listeners) listener.source.removeEventListener(listener.type, listener.handler);
|
|
1032
|
+
this.#listeners = [];
|
|
1033
|
+
this.#source?.close();
|
|
1034
|
+
this.#source = null;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Handles every `error` event. A payload means the server reported a problem; an empty payload
|
|
1038
|
+
* means the connection dropped, which {@link #handleTransportError} deals with.
|
|
1039
|
+
*/
|
|
1040
|
+
#handleErrorEvent(event) {
|
|
1041
|
+
const data = typeof event.data === "string" ? event.data.trim() : "";
|
|
1042
|
+
if (data === "") {
|
|
1043
|
+
this.#handleTransportError();
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
this.lastError = parsePayload(data) ?? { message: data };
|
|
1047
|
+
this.status = "error";
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Handles a transport failure. While the source is still `CONNECTING` the browser keeps retrying
|
|
1051
|
+
* on its own; a `CLOSED` source is rebuilt after {@link LogStreamOptions.reconnectDelayMs}.
|
|
1052
|
+
*/
|
|
1053
|
+
#handleTransportError() {
|
|
1054
|
+
const source = this.#source;
|
|
1055
|
+
if (source === null) return;
|
|
1056
|
+
this.status = "reconnecting";
|
|
1057
|
+
if (source.readyState === 2) this.#scheduleReconnect();
|
|
1058
|
+
}
|
|
1059
|
+
/** Schedules one reconnect attempt, unless one is already pending. */
|
|
1060
|
+
#scheduleReconnect() {
|
|
1061
|
+
if (this.#reconnectTimer !== null) return;
|
|
1062
|
+
this.#reconnectTimer = this.#schedule(() => {
|
|
1063
|
+
this.#reconnectTimer = null;
|
|
1064
|
+
if (this.target !== null) this.#connect(true);
|
|
1065
|
+
}, this.#reconnectDelayMs);
|
|
1066
|
+
}
|
|
1067
|
+
/** Drops a pending reconnect attempt. */
|
|
1068
|
+
#clearReconnect() {
|
|
1069
|
+
if (this.#reconnectTimer === null) return;
|
|
1070
|
+
this.#cancel(this.#reconnectTimer);
|
|
1071
|
+
this.#reconnectTimer = null;
|
|
1072
|
+
}
|
|
1073
|
+
/** Marks the stream as live after receiving data. */
|
|
1074
|
+
#markLive() {
|
|
1075
|
+
this.lastEventAt = Date.now();
|
|
1076
|
+
if (this.status !== "ended") this.status = "live";
|
|
1077
|
+
}
|
|
1078
|
+
/** Copies buffer state onto the reactive fields. */
|
|
1079
|
+
#syncStore() {
|
|
1080
|
+
this.lines = this.#store.toArray();
|
|
1081
|
+
this.pendingCount = this.#store.pendingCount;
|
|
1082
|
+
this.droppedCount = this.#store.droppedCount;
|
|
1083
|
+
}
|
|
1084
|
+
#handleReady(event) {
|
|
1085
|
+
const payload = parsePayload(event.data);
|
|
1086
|
+
if (payload !== null) this.ready = payload;
|
|
1087
|
+
this.#markLive();
|
|
1088
|
+
}
|
|
1089
|
+
#handleLog(event) {
|
|
1090
|
+
const payload = parsePayload(event.data);
|
|
1091
|
+
const events = payload !== null && Array.isArray(payload.events) ? payload.events : [];
|
|
1092
|
+
if (events.length === 0) return;
|
|
1093
|
+
this.#store.push(events);
|
|
1094
|
+
this.#syncStore();
|
|
1095
|
+
this.receivedCount += events.length;
|
|
1096
|
+
this.#markLive();
|
|
1097
|
+
}
|
|
1098
|
+
#handlePing(event) {
|
|
1099
|
+
const payload = parsePayload(event.data);
|
|
1100
|
+
this.lastEventAt = payload?.at ?? Date.now();
|
|
1101
|
+
if (this.status !== "ended") this.status = "live";
|
|
1102
|
+
}
|
|
1103
|
+
#handleEnd(event) {
|
|
1104
|
+
const payload = parsePayload(event.data);
|
|
1105
|
+
this.endReason = payload?.reason ?? null;
|
|
1106
|
+
this.status = "ended";
|
|
1107
|
+
this.#clearReconnect();
|
|
1108
|
+
this.#closeSource();
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
//#endregion
|
|
1112
|
+
//#region src/routes/+page.svelte
|
|
1113
|
+
function _page($$renderer, $$props) {
|
|
1114
|
+
$$renderer.component(($$renderer) => {
|
|
1115
|
+
/** Owns the SSE connection; the page only wires it to the UI. */
|
|
1116
|
+
const stream = new LogStream();
|
|
1117
|
+
/** Region picker state. Seeded from the URL so a shared link restores the view. */
|
|
1118
|
+
let region = page.url.searchParams.get("region") ?? "";
|
|
1119
|
+
/** Tail mode and historic window, seeded from the URL. */
|
|
1120
|
+
let mode = page.url.searchParams.get("mode") === "historic" ? "historic" : "live";
|
|
1121
|
+
let range = page.url.searchParams.get("range") ?? "15m";
|
|
1122
|
+
let windowFrom = parseUrlEpoch(page.url.searchParams.get("from"));
|
|
1123
|
+
let windowTo = parseUrlEpoch(page.url.searchParams.get("to"));
|
|
1124
|
+
let rangeLoading = false;
|
|
1125
|
+
let regions = [...DEFAULT_REGIONS];
|
|
1126
|
+
let groups = [];
|
|
1127
|
+
let groupsLoading = false;
|
|
1128
|
+
let groupsError = null;
|
|
1129
|
+
let endpoint = null;
|
|
1130
|
+
let filter = "";
|
|
1131
|
+
let autoScroll = true;
|
|
1132
|
+
/** Group currently being tailed, or `null`. */
|
|
1133
|
+
let selectedGroup = derived(() => stream.target?.group ?? null);
|
|
1134
|
+
/** Sidebar width in pixels; the layout default is 22rem. */
|
|
1135
|
+
let sidebarPx = remToPx(SIDEBAR_WIDTH.defaultRem);
|
|
1136
|
+
/** Largest sidebar width allowed: 44vw, but never below the minimum. */
|
|
1137
|
+
let sidebarMaxPx = derived(() => remToPx(SIDEBAR_WIDTH.defaultRem));
|
|
1138
|
+
let sidebarMinPx = remToPx(SIDEBAR_WIDTH.minRem);
|
|
1139
|
+
let sidebarStyle = derived(() => `width: min(${sidebarPx}px, ${SIDEBAR_WIDTH.maxVw}vw)`);
|
|
1140
|
+
/** Persists the sidebar width; failures are ignored (private mode, quota). */
|
|
1141
|
+
function saveSidebarWidth() {
|
|
1142
|
+
try {
|
|
1143
|
+
if (typeof localStorage === "undefined") return;
|
|
1144
|
+
localStorage.setItem(STORAGE_KEYS.sidebarWidth, String(Math.round(sidebarPx)));
|
|
1145
|
+
} catch {}
|
|
1146
|
+
}
|
|
1147
|
+
/** Loads the log groups of a region; a newer region change wins the race. */
|
|
1148
|
+
async function loadGroups(target) {
|
|
1149
|
+
groupsLoading = true;
|
|
1150
|
+
groupsError = null;
|
|
1151
|
+
try {
|
|
1152
|
+
const response = await fetchLogGroups({ region: target });
|
|
1153
|
+
if (target !== region) return;
|
|
1154
|
+
if (response.endpoint !== null) endpoint = response.endpoint;
|
|
1155
|
+
groups = response.groups;
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
if (target !== region) return;
|
|
1158
|
+
groups = [];
|
|
1159
|
+
groupsError = describeGroupsError(target, error);
|
|
1160
|
+
} finally {
|
|
1161
|
+
if (target === region) groupsLoading = false;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/** Parses an epoch-millisecond URL parameter, or `null`. */
|
|
1165
|
+
function parseUrlEpoch(value) {
|
|
1166
|
+
if (value === null || value.trim() === "") return null;
|
|
1167
|
+
const parsed = Number(value);
|
|
1168
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1169
|
+
}
|
|
1170
|
+
/** Switches region: stops the stream, clears the list and reloads the groups. */
|
|
1171
|
+
function changeRegion(next) {
|
|
1172
|
+
if (next === "" || next === region) return;
|
|
1173
|
+
region = next;
|
|
1174
|
+
stream.stop();
|
|
1175
|
+
groups = [];
|
|
1176
|
+
syncUrl(next);
|
|
1177
|
+
loadGroups(next);
|
|
1178
|
+
}
|
|
1179
|
+
/** Replaces the view parameters in the address bar. */
|
|
1180
|
+
function syncUrl(nextRegion, nextGroup) {
|
|
1181
|
+
const url = new URL(page.url);
|
|
1182
|
+
if (nextRegion === "") url.searchParams.delete("region");
|
|
1183
|
+
else url.searchParams.set("region", nextRegion);
|
|
1184
|
+
url.searchParams.delete("group");
|
|
1185
|
+
if (mode === "live") {
|
|
1186
|
+
url.searchParams.delete("mode");
|
|
1187
|
+
url.searchParams.delete("range");
|
|
1188
|
+
url.searchParams.delete("from");
|
|
1189
|
+
url.searchParams.delete("to");
|
|
1190
|
+
} else {
|
|
1191
|
+
url.searchParams.set("mode", "historic");
|
|
1192
|
+
url.searchParams.delete("from");
|
|
1193
|
+
url.searchParams.delete("to");
|
|
1194
|
+
url.searchParams.delete("range");
|
|
1195
|
+
if (range === "" && windowFrom !== null && windowTo !== null) {
|
|
1196
|
+
url.searchParams.set("from", String(windowFrom));
|
|
1197
|
+
url.searchParams.set("to", String(windowTo));
|
|
1198
|
+
} else url.searchParams.set("range", range === "" ? "15m" : range);
|
|
1199
|
+
}
|
|
1200
|
+
if (url.href !== page.url.href) replaceState();
|
|
1201
|
+
}
|
|
1202
|
+
$$renderer.push(`<div class="flex flex-wrap items-center gap-x-3 gap-y-2"><span class="text-[0.6875rem] font-semibold uppercase tracking-wider text-neutral-500">Watching</span> <span class="max-w-[22rem] truncate rounded-md border border-neutral-800 bg-neutral-900 px-2 py-1 font-mono text-xs text-neutral-200">${escape_html(selectedGroup() ?? "no group selected")}</span> <span class="text-xs text-neutral-500">in</span> <span class="font-mono text-xs text-neutral-300">${escape_html(region === "" ? "resolving region…" : region)}</span> `);
|
|
1203
|
+
EndpointBadge($$renderer, {
|
|
1204
|
+
endpoint,
|
|
1205
|
+
credentials: null
|
|
1206
|
+
});
|
|
1207
|
+
$$renderer.push(`<!----> `);
|
|
1208
|
+
$$renderer.push("<!--[-1-->");
|
|
1209
|
+
$$renderer.push(`<!--]--></div> `);
|
|
1210
|
+
$$renderer.push("<!--[-1-->");
|
|
1211
|
+
$$renderer.push(`<!--]--> `);
|
|
1212
|
+
RangeControls($$renderer, {
|
|
1213
|
+
mode,
|
|
1214
|
+
range,
|
|
1215
|
+
from: windowFrom,
|
|
1216
|
+
to: windowTo,
|
|
1217
|
+
loading: rangeLoading,
|
|
1218
|
+
disabled: selectedGroup() === null});
|
|
1219
|
+
$$renderer.push(`<!----> <div class="flex min-h-0 min-w-0 flex-1 flex-col gap-3 lg:flex-row"><div class="flex min-h-0 w-full flex-col gap-3 rounded-lg border border-neutral-800 bg-neutral-950/60 p-3 lg:shrink-0"${attr_style(sidebarStyle())} data-testid="sidebar">`);
|
|
1220
|
+
RegionSelect($$renderer, {
|
|
1221
|
+
regions,
|
|
1222
|
+
value: region,
|
|
1223
|
+
onchange: changeRegion
|
|
1224
|
+
});
|
|
1225
|
+
$$renderer.push(`<!----> `);
|
|
1226
|
+
LogGroupList($$renderer, {
|
|
1227
|
+
groups,
|
|
1228
|
+
region,
|
|
1229
|
+
selected: selectedGroup(),
|
|
1230
|
+
loading: groupsLoading,
|
|
1231
|
+
error: groupsError});
|
|
1232
|
+
$$renderer.push(`<!----></div> `);
|
|
1233
|
+
ColumnResizer($$renderer, {
|
|
1234
|
+
label: "Resize group list",
|
|
1235
|
+
width: sidebarPx,
|
|
1236
|
+
min: sidebarMinPx,
|
|
1237
|
+
max: sidebarMaxPx(),
|
|
1238
|
+
testId: "sidebar-resizer",
|
|
1239
|
+
onChange: (next) => sidebarPx = next,
|
|
1240
|
+
onCommit: () => saveSidebarWidth(),
|
|
1241
|
+
class: "hidden lg:block lg:w-2"
|
|
1242
|
+
});
|
|
1243
|
+
$$renderer.push(`<!----> <div class="flex min-h-0 min-w-0 flex-1 flex-col">`);
|
|
1244
|
+
LogViewer($$renderer, {
|
|
1245
|
+
lines: stream.lines,
|
|
1246
|
+
status: stream.status,
|
|
1247
|
+
error: stream.lastError,
|
|
1248
|
+
mode,
|
|
1249
|
+
ready: stream.ready,
|
|
1250
|
+
endReason: stream.endReason,
|
|
1251
|
+
region,
|
|
1252
|
+
group: selectedGroup(),
|
|
1253
|
+
filter,
|
|
1254
|
+
paused: stream.paused,
|
|
1255
|
+
autoScroll,
|
|
1256
|
+
pendingCount: stream.pendingCount,
|
|
1257
|
+
droppedCount: stream.droppedCount,
|
|
1258
|
+
receivedCount: stream.receivedCount});
|
|
1259
|
+
$$renderer.push(`<!----></div></div>`);
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
export { _page as default };
|
|
1264
|
+
//# sourceMappingURL=_page.svelte.js-BsvaKqbk.js.map
|