@camstack/ui-library 1.2.304 → 1.2.305

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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure paging logic for the server-served log history (D898).
3
+ *
4
+ * The server answers one TIME-BOUNDED slice per call: `done: false` with fewer
5
+ * than `limit` entries means "the budget ran out, still searching". The client
6
+ * keeps calling with `before: nextBefore` until it holds a full page, the
7
+ * source says `done`, or the operator cancels. Kept free of React and tRPC so
8
+ * the loop is unit-testable.
9
+ */
10
+ export interface LogWireEntry {
11
+ readonly id?: string;
12
+ readonly timestamp: string;
13
+ readonly level: string;
14
+ readonly message: string;
15
+ readonly scope?: string;
16
+ readonly tags?: Record<string, string | undefined>;
17
+ readonly meta?: Record<string, unknown>;
18
+ }
19
+ export interface LogWirePage {
20
+ /** NEWEST FIRST. */
21
+ readonly entries: readonly LogWireEntry[];
22
+ readonly nextBefore: string | null;
23
+ readonly nextAfter: string | null;
24
+ readonly scannedBytes: number;
25
+ readonly done: boolean;
26
+ readonly cancelled?: boolean;
27
+ readonly source: string;
28
+ }
29
+ /** Fetches one slice older than `before` (undefined = the first page). */
30
+ export type FetchLogSlice = (before: string | undefined, remaining: number, signal: AbortSignal) => Promise<LogWirePage>;
31
+ export interface CollectedPage {
32
+ /** NEWEST FIRST, across every slice the loop made. */
33
+ readonly entries: readonly LogWireEntry[];
34
+ /** Cursor for the next older page; null when nothing older exists. */
35
+ readonly nextBefore: string | null;
36
+ readonly done: boolean;
37
+ readonly cancelled: boolean;
38
+ readonly scannedBytes: number;
39
+ readonly source: string | undefined;
40
+ }
41
+ export interface CollectPageOptions {
42
+ readonly fetchSlice: FetchLogSlice;
43
+ readonly limit: number;
44
+ readonly before?: string;
45
+ readonly signal: AbortSignal;
46
+ /** Called after every slice with the bytes scanned so far. */
47
+ readonly onProgress?: (scannedBytes: number) => void;
48
+ }
49
+ /**
50
+ * Accumulate slices until `limit` entries, `done`, or an abort. An abort keeps
51
+ * what was already collected and the cursor reached, so "Load older" resumes
52
+ * where the scan stopped instead of starting again.
53
+ */
54
+ export declare function collectPage(options: CollectPageOptions): Promise<CollectedPage>;
55
+ /** Stable identity of an entry: the server id, else the legacy composite key. */
56
+ export declare function logEntryKey(entry: LogWireEntry): string;
57
+ /**
58
+ * History (newest first, as paged) merged with live entries (arrival order),
59
+ * deduped by key — a live entry wins (richer tags/meta) — and returned
60
+ * chronologically, newest LAST. Ties keep insertion order.
61
+ */
62
+ export declare function mergeLogEntries(history: readonly LogWireEntry[], live: readonly LogWireEntry[]): LogWireEntry[];
@@ -10,14 +10,8 @@
10
10
  */
11
11
  import { type JSX } from "react";
12
12
  import { type LiveBuffer } from "../hooks/use-live-buffer";
13
- interface LogEntry {
14
- readonly timestamp: string;
15
- readonly level: string;
16
- readonly message: string;
17
- readonly scope?: string;
18
- readonly tags?: Record<string, string | undefined>;
19
- readonly meta?: Record<string, unknown>;
20
- }
13
+ import { type LogWireEntry } from "./log-history-paging";
14
+ type LogEntry = LogWireEntry;
21
15
  export interface LogStreamProps {
22
16
  /** Lock to a specific agent. When set, the agent selector is hidden. */
23
17
  readonly agentId?: string;
@@ -65,7 +59,9 @@ export interface LogStreamProps {
65
59
  readonly showDeviceChip?: boolean;
66
60
  /** Show the filter bar with selectors. Set false for compact inline use. */
67
61
  readonly showFilters?: boolean;
68
- /** Initial number of historical entries to fetch (default: 100). */
62
+ /** Show the history source selector (default: follows `showFilters`). */
63
+ readonly showSourceSelect?: boolean;
64
+ /** Entries per history page (default: 100). */
69
65
  readonly limit?: number;
70
66
  /**
71
67
  * Optional controlled live buffer. When provided, the stream reads
@@ -79,7 +75,7 @@ export interface LogStreamProps {
79
75
  readonly onClose?: () => void;
80
76
  readonly className?: string;
81
77
  }
82
- export declare function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: propsDeviceId, containerDeviceId: propsContainerDeviceId, integrationId: propsIntegrationId, requestId: propsRequestId, level: initialLevel, maxHeight, showScope, showDeviceChip, showFilters, limit, liveBuffer: externalBuffer, onClose, className }: LogStreamProps): JSX.Element;
78
+ export declare function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: propsDeviceId, containerDeviceId: propsContainerDeviceId, integrationId: propsIntegrationId, requestId: propsRequestId, level: initialLevel, maxHeight, showScope, showDeviceChip, showFilters, showSourceSelect, limit, liveBuffer: externalBuffer, onClose, className }: LogStreamProps): JSX.Element;
83
79
  export interface FloatingLogStreamProps extends LogStreamProps {
84
80
  /** Position: 'bottom' anchors to parent bottom, 'overlay' is absolute positioned. */
85
81
  readonly position?: "bottom" | "overlay";
@@ -487,6 +487,8 @@ export declare const useDeviceHistoryListNodeSeries: typeof trpc.deviceHistory.l
487
487
  export declare const useDeviceHistoryQuery: typeof trpc.deviceHistory.query.useQuery;
488
488
  /** Generated alias around `trpc.deviceHistory.queryNode.useQuery`. */
489
489
  export declare const useDeviceHistoryQueryNode: typeof trpc.deviceHistory.queryNode.useQuery;
490
+ /** Generated alias around `trpc.deviceHistory.queryMany.useQuery`. */
491
+ export declare const useDeviceHistoryQueryMany: typeof trpc.deviceHistory.queryMany.useQuery;
490
492
  /** Generated alias around `trpc.deviceHistory.declareSeries.useMutation`. */
491
493
  export declare const useDeviceHistoryDeclareSeries: typeof trpc.deviceHistory.declareSeries.useMutation;
492
494
  /** Generated alias around `trpc.deviceHistory.record.useMutation`. */
@@ -0,0 +1,26 @@
1
+ import { type LogWireEntry } from "../composites/log-history-paging";
2
+ export type LogHistoryLevel = "debug" | "info" | "warn" | "error";
3
+ /** The filters the SERVER applies; everything but paging. */
4
+ export interface LogHistoryQuery {
5
+ /** A log-destination addonId or `ring`; undefined = the server default. */
6
+ readonly source?: string;
7
+ readonly level: LogHistoryLevel;
8
+ readonly text?: string;
9
+ readonly tags?: Record<string, string | number>;
10
+ }
11
+ export type LogHistoryStatus = "idle" | "loading";
12
+ export interface LogHistory {
13
+ /** Newest first, as paged. */
14
+ readonly entries: readonly LogWireEntry[];
15
+ readonly status: LogHistoryStatus;
16
+ /** True when nothing older exists ("Beginning of logs"). */
17
+ readonly done: boolean;
18
+ /** Bytes scanned by the scan in flight (0 until its first slice answers). */
19
+ readonly scannedBytes: number;
20
+ /** The source that answered (the server may fall back to `ring`). */
21
+ readonly source: string | undefined;
22
+ readonly error: string | null;
23
+ readonly loadOlder: () => void;
24
+ readonly cancel: () => void;
25
+ }
26
+ export declare function useLogHistory(query: LogHistoryQuery, limit: number): LogHistory;
package/dist/index.cjs CHANGED
@@ -10754,6 +10754,8 @@ var useDeviceHistoryListNodeSeries = trpc.deviceHistory.listNodeSeries.useQuery;
10754
10754
  var useDeviceHistoryQuery = trpc.deviceHistory.query.useQuery;
10755
10755
  /** Generated alias around `trpc.deviceHistory.queryNode.useQuery`. */
10756
10756
  var useDeviceHistoryQueryNode = trpc.deviceHistory.queryNode.useQuery;
10757
+ /** Generated alias around `trpc.deviceHistory.queryMany.useQuery`. */
10758
+ var useDeviceHistoryQueryMany = trpc.deviceHistory.queryMany.useQuery;
10757
10759
  /** Generated alias around `trpc.deviceHistory.declareSeries.useMutation`. */
10758
10760
  var useDeviceHistoryDeclareSeries = trpc.deviceHistory.declareSeries.useMutation;
10759
10761
  /** Generated alias around `trpc.deviceHistory.record.useMutation`. */
@@ -39832,6 +39834,202 @@ function useLiveBuffer(max) {
39832
39834
  reset
39833
39835
  ]);
39834
39836
  }
39837
+ //#endregion
39838
+ //#region src/hooks/use-debounced-string.ts
39839
+ /**
39840
+ * useDebouncedString — return `value` after `delay` ms have passed
39841
+ * without further changes. Resets the timer on every change.
39842
+ *
39843
+ * Useful for search inputs that should NOT push to URL state on every
39844
+ * keystroke. The DOM input stays controlled by an external `value`
39845
+ * (caller's `useState`); this hook produces a debounced derivation.
39846
+ */
39847
+ function useDebouncedString(value, delayMs) {
39848
+ const [debounced, setDebounced] = (0, react$1.useState)(value);
39849
+ (0, react$1.useEffect)(() => {
39850
+ const t = setTimeout(() => setDebounced(value), delayMs);
39851
+ return () => clearTimeout(t);
39852
+ }, [value, delayMs]);
39853
+ return debounced;
39854
+ }
39855
+ //#endregion
39856
+ //#region src/composites/log-history-paging.ts
39857
+ /**
39858
+ * Accumulate slices until `limit` entries, `done`, or an abort. An abort keeps
39859
+ * what was already collected and the cursor reached, so "Load older" resumes
39860
+ * where the scan stopped instead of starting again.
39861
+ */
39862
+ async function collectPage(options) {
39863
+ const { fetchSlice, limit, signal, onProgress } = options;
39864
+ const entries = [];
39865
+ let cursor = options.before;
39866
+ let scannedBytes = 0;
39867
+ let source;
39868
+ for (;;) {
39869
+ if (signal.aborted) return {
39870
+ entries,
39871
+ nextBefore: cursor ?? null,
39872
+ done: false,
39873
+ cancelled: true,
39874
+ scannedBytes,
39875
+ source
39876
+ };
39877
+ let page;
39878
+ try {
39879
+ page = await fetchSlice(cursor, Math.max(1, limit - entries.length), signal);
39880
+ } catch (error) {
39881
+ if (signal.aborted) return {
39882
+ entries,
39883
+ nextBefore: cursor ?? null,
39884
+ done: false,
39885
+ cancelled: true,
39886
+ scannedBytes,
39887
+ source
39888
+ };
39889
+ throw error;
39890
+ }
39891
+ entries.push(...page.entries);
39892
+ scannedBytes += page.scannedBytes;
39893
+ source = page.source;
39894
+ onProgress?.(scannedBytes);
39895
+ const exhausted = page.done || page.nextBefore === null;
39896
+ if (page.cancelled === true) return {
39897
+ entries,
39898
+ nextBefore: page.nextBefore,
39899
+ done: exhausted,
39900
+ cancelled: true,
39901
+ scannedBytes,
39902
+ source
39903
+ };
39904
+ if (exhausted || entries.length >= limit) return {
39905
+ entries,
39906
+ nextBefore: page.nextBefore,
39907
+ done: exhausted,
39908
+ cancelled: false,
39909
+ scannedBytes,
39910
+ source
39911
+ };
39912
+ cursor = page.nextBefore ?? void 0;
39913
+ }
39914
+ }
39915
+ /** Stable identity of an entry: the server id, else the legacy composite key. */
39916
+ function logEntryKey(entry) {
39917
+ if (entry.id !== void 0 && entry.id !== "") return `id:${entry.id}`;
39918
+ return `${entry.timestamp}|${entry.level}|${entry.scope ?? ""}|${entry.message}`;
39919
+ }
39920
+ /**
39921
+ * History (newest first, as paged) merged with live entries (arrival order),
39922
+ * deduped by key — a live entry wins (richer tags/meta) — and returned
39923
+ * chronologically, newest LAST. Ties keep insertion order.
39924
+ */
39925
+ function mergeLogEntries(history, live) {
39926
+ const byKey = /* @__PURE__ */ new Map();
39927
+ for (const entry of history) byKey.set(logEntryKey(entry), entry);
39928
+ for (const entry of live) byKey.set(logEntryKey(entry), entry);
39929
+ return [...byKey.values()].toSorted((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
39930
+ }
39931
+ //#endregion
39932
+ //#region src/hooks/use-log-history.ts
39933
+ /**
39934
+ * useLogHistory — pages the server-served log history (D898).
39935
+ *
39936
+ * The first page and every older page go through `collectPage`, which keeps
39937
+ * calling `logs.query` with `before: nextBefore` while the server answers a
39938
+ * short, not-done slice. Changing the filters aborts whatever is in flight and
39939
+ * restarts from the first page; `cancel()` aborts the scan but keeps what was
39940
+ * found and the cursor reached, so "Load older" resumes from there.
39941
+ */
39942
+ var INITIAL = {
39943
+ entries: [],
39944
+ nextBefore: null,
39945
+ done: false,
39946
+ status: "loading",
39947
+ scannedBytes: 0,
39948
+ source: void 0,
39949
+ error: null
39950
+ };
39951
+ function useLogHistory(query, limit) {
39952
+ const utils = trpc.useUtils();
39953
+ const [state, setState] = (0, react$1.useState)(INITIAL);
39954
+ const stateRef = (0, react$1.useRef)(state);
39955
+ stateRef.current = state;
39956
+ const queryRef = (0, react$1.useRef)(query);
39957
+ queryRef.current = query;
39958
+ const abortRef = (0, react$1.useRef)(null);
39959
+ const queryKey = JSON.stringify(query);
39960
+ const run = (0, react$1.useCallback)(async (before, reset) => {
39961
+ abortRef.current?.abort();
39962
+ const controller = new AbortController();
39963
+ abortRef.current = controller;
39964
+ setState((prev) => ({
39965
+ ...reset ? INITIAL : prev,
39966
+ status: "loading",
39967
+ scannedBytes: 0,
39968
+ error: null
39969
+ }));
39970
+ const fetchSlice = (cursor, remaining, signal) => utils.client.logs.query.query({
39971
+ ...queryRef.current,
39972
+ limit: remaining,
39973
+ ...cursor !== void 0 ? { before: cursor } : {}
39974
+ }, { signal });
39975
+ try {
39976
+ const out = await collectPage({
39977
+ fetchSlice,
39978
+ limit,
39979
+ ...before !== void 0 ? { before } : {},
39980
+ signal: controller.signal,
39981
+ onProgress: (scannedBytes) => {
39982
+ if (abortRef.current === controller) setState((prev) => ({
39983
+ ...prev,
39984
+ scannedBytes
39985
+ }));
39986
+ }
39987
+ });
39988
+ if (abortRef.current !== controller) return;
39989
+ setState((prev) => ({
39990
+ entries: reset ? out.entries : [...prev.entries, ...out.entries],
39991
+ nextBefore: out.nextBefore,
39992
+ done: out.done,
39993
+ status: "idle",
39994
+ scannedBytes: out.scannedBytes,
39995
+ source: out.source ?? prev.source,
39996
+ error: null
39997
+ }));
39998
+ } catch (error) {
39999
+ if (abortRef.current !== controller) return;
40000
+ setState((prev) => ({
40001
+ ...prev,
40002
+ status: "idle",
40003
+ error: error instanceof Error ? error.message : String(error)
40004
+ }));
40005
+ }
40006
+ }, [utils, limit]);
40007
+ (0, react$1.useEffect)(() => {
40008
+ run(void 0, true);
40009
+ return () => {
40010
+ abortRef.current?.abort();
40011
+ abortRef.current = null;
40012
+ };
40013
+ }, [run, queryKey]);
40014
+ const loadOlder = (0, react$1.useCallback)(() => {
40015
+ const current = stateRef.current;
40016
+ if (current.status === "loading" || current.done) return;
40017
+ run(current.nextBefore ?? void 0, false);
40018
+ }, [run]);
40019
+ const cancel = (0, react$1.useCallback)(() => {
40020
+ abortRef.current?.abort();
40021
+ }, []);
40022
+ return {
40023
+ entries: state.entries,
40024
+ status: state.status,
40025
+ done: state.done,
40026
+ scannedBytes: state.scannedBytes,
40027
+ source: state.source,
40028
+ error: state.error,
40029
+ loadOlder,
40030
+ cancel
40031
+ };
40032
+ }
39835
40033
  /**
39836
40034
  * True when the viewport is scrolled to (or within `threshold` px of) the
39837
40035
  * bottom of its content.
@@ -39888,12 +40086,6 @@ var LEVEL_STYLES = {
39888
40086
  color: "text-gray-500"
39889
40087
  }
39890
40088
  };
39891
- var LEVEL_SEVERITY = {
39892
- debug: 0,
39893
- info: 1,
39894
- warn: 2,
39895
- error: 3
39896
- };
39897
40089
  var LEVEL_OPTIONS = [
39898
40090
  "debug",
39899
40091
  "info",
@@ -39902,15 +40094,18 @@ var LEVEL_OPTIONS = [
39902
40094
  ];
39903
40095
  /** Max live entries kept in the ring buffer. */
39904
40096
  var MAX_LIVE_ENTRIES$1 = 500;
39905
- function passesLevel(logLevel, filterLevel) {
39906
- if (!filterLevel) return true;
39907
- return (LEVEL_SEVERITY[logLevel] ?? 0) >= (LEVEL_SEVERITY[filterLevel] ?? 0);
39908
- }
39909
- function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: propsDeviceId, containerDeviceId: propsContainerDeviceId, integrationId: propsIntegrationId, requestId: propsRequestId, level: initialLevel, maxHeight = "max-h-96", showScope = false, showDeviceChip = false, showFilters = true, limit = 100, liveBuffer: externalBuffer, onClose, className }) {
40097
+ /** Text filter debounce before it goes to the server. */
40098
+ var TEXT_DEBOUNCE_MS = 300;
40099
+ /** Distance from the top (px) that asks for the next older page. */
40100
+ var TOP_LOAD_THRESHOLD_PX = 24;
40101
+ var BYTES_PER_MB = 1024 * 1024;
40102
+ function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: propsDeviceId, containerDeviceId: propsContainerDeviceId, integrationId: propsIntegrationId, requestId: propsRequestId, level: initialLevel, maxHeight = "max-h-96", showScope = false, showDeviceChip = false, showFilters = true, showSourceSelect, limit = 100, liveBuffer: externalBuffer, onClose, className }) {
39910
40103
  const t = useUiText();
40104
+ const sourceSelectOn = showSourceSelect ?? showFilters;
39911
40105
  const [localAddonId, setLocalAddonId] = (0, react$1.useState)("");
39912
40106
  const [localDeviceId, setLocalDeviceId] = (0, react$1.useState)("");
39913
40107
  const [levelFilter, setLevelFilter] = (0, react$1.useState)(initialLevel ?? "info");
40108
+ const [sourceChoice, setSourceChoice] = (0, react$1.useState)(void 0);
39914
40109
  const agentId = propsAgentId;
39915
40110
  const addonId = propsAddonId ?? (localAddonId || void 0);
39916
40111
  const deviceId = propsDeviceId ?? (localDeviceId ? Number(localDeviceId) : void 0);
@@ -39955,48 +40150,63 @@ function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: pro
39955
40150
  setExpandAll(false);
39956
40151
  }
39957
40152
  }, [scopeKey, buffer]);
39958
- const { data: initialLogs, isLoading } = trpc.logs.query.useQuery({
39959
- ...tags ? { tags } : {},
39960
- limit
39961
- }, { staleTime: 3e4 });
39962
- trpc.logs.subscribe.useSubscription(tags ? { tags } : {}, { onData: (entry) => {
40153
+ const debouncedText = useDebouncedString(searchText, TEXT_DEBOUNCE_MS).trim();
40154
+ const sourcesQuery = trpc.logs.sources.useQuery(void 0, { staleTime: 6e4 });
40155
+ const readableSources = (0, react$1.useMemo)(() => (sourcesQuery.data ?? []).filter((source) => source.readable), [sourcesQuery.data]);
40156
+ const history = useLogHistory((0, react$1.useMemo)(() => ({
40157
+ ...sourceChoice ? { source: sourceChoice } : {},
40158
+ level: levelFilter,
40159
+ ...debouncedText ? { text: debouncedText } : {},
40160
+ ...tags ? { tags } : {}
40161
+ }), [
40162
+ sourceChoice,
40163
+ levelFilter,
40164
+ debouncedText,
40165
+ tags
40166
+ ]), limit);
40167
+ trpc.logs.subscribe.useSubscription({
40168
+ level: levelFilter,
40169
+ ...debouncedText ? { text: debouncedText } : {},
40170
+ ...tags ? { tags } : {}
40171
+ }, { onData: (entry) => {
39963
40172
  buffer.append(entry);
39964
40173
  } });
40174
+ const filterKey = `${sourceChoice ?? ""}|${levelFilter}|${debouncedText}`;
40175
+ const prevFilterRef = (0, react$1.useRef)(filterKey);
40176
+ (0, react$1.useEffect)(() => {
40177
+ if (prevFilterRef.current === filterKey) return;
40178
+ prevFilterRef.current = filterKey;
40179
+ buffer.reset();
40180
+ setFollow(true);
40181
+ }, [filterKey, buffer]);
39965
40182
  const allLogs = (0, react$1.useMemo)(() => {
39966
- const initial = (initialLogs ?? []).map((log) => ({
39967
- timestamp: log.timestamp,
39968
- level: log.level,
39969
- message: log.message,
39970
- scope: log.scope,
39971
- tags: log.tags,
39972
- meta: log.meta
39973
- }));
39974
- const byKey = /* @__PURE__ */ new Map();
39975
- for (const log of initial) byKey.set(`${log.timestamp}|${log.level}|${log.scope ?? ""}|${log.message}`, log);
39976
- for (const log of liveLogs) byKey.set(`${log.timestamp}|${log.level}|${log.scope ?? ""}|${log.message}`, log);
39977
- const merged = [...byKey.values()].toSorted((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
39978
- const levelFiltered = (clearedAt > 0 ? merged.filter((log) => new Date(log.timestamp).getTime() > clearedAt) : merged).filter((log) => passesLevel(log.level, levelFilter));
39979
- const needle = searchText.trim().toLowerCase();
39980
- if (!needle) return levelFiltered;
39981
- return levelFiltered.filter((log) => {
39982
- if (log.message.toLowerCase().includes(needle)) return true;
39983
- if (log.scope && log.scope.toLowerCase().includes(needle)) return true;
39984
- if (log.tags) {
39985
- for (const v of Object.values(log.tags)) if (v !== void 0 && String(v).toLowerCase().includes(needle)) return true;
39986
- }
39987
- if (log.meta) for (const v of Object.values(log.meta)) {
39988
- if (v === void 0 || v === null) continue;
39989
- if (formatMetaValue(v).toLowerCase().includes(needle)) return true;
39990
- }
39991
- return false;
39992
- });
40183
+ const merged = mergeLogEntries(history.entries, liveLogs);
40184
+ return clearedAt > 0 ? merged.filter((log) => new Date(log.timestamp).getTime() > clearedAt) : merged;
39993
40185
  }, [
39994
- initialLogs,
40186
+ history.entries,
39995
40187
  liveLogs,
39996
- levelFilter,
39997
- clearedAt,
39998
- searchText
40188
+ clearedAt
39999
40189
  ]);
40190
+ const prependAnchorRef = (0, react$1.useRef)(null);
40191
+ const requestOlder = (0, react$1.useCallback)(() => {
40192
+ if (history.status === "loading" || history.done) return;
40193
+ const el = scrollRef.current;
40194
+ if (el) prependAnchorRef.current = {
40195
+ height: el.scrollHeight,
40196
+ top: el.scrollTop
40197
+ };
40198
+ history.loadOlder();
40199
+ }, [history]);
40200
+ (0, react$1.useLayoutEffect)(() => {
40201
+ const anchor = prependAnchorRef.current;
40202
+ const el = scrollRef.current;
40203
+ if (!anchor || !el || history.status !== "idle") return;
40204
+ prependAnchorRef.current = null;
40205
+ const next = anchor.top + (el.scrollHeight - anchor.height);
40206
+ if (Math.abs(next - el.scrollTop) < 1) return;
40207
+ programmaticScrollRef.current = true;
40208
+ el.scrollTop = next;
40209
+ }, [history.status, history.entries.length]);
40000
40210
  const scrollToBottom = (0, react$1.useCallback)((behavior) => {
40001
40211
  const el = scrollRef.current;
40002
40212
  if (!el) return;
@@ -40007,15 +40217,16 @@ function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: pro
40007
40217
  });
40008
40218
  }, []);
40009
40219
  const [hasNewWhilePaused, setHasNewWhilePaused] = (0, react$1.useState)(false);
40010
- const prevLenRef = (0, react$1.useRef)(allLogs.length);
40220
+ const lastKey = allLogs.length > 0 ? logEntryKey(allLogs[allLogs.length - 1]) : "";
40221
+ const prevLastKeyRef = (0, react$1.useRef)(lastKey);
40011
40222
  (0, react$1.useLayoutEffect)(() => {
40012
- const grew = allLogs.length > prevLenRef.current;
40013
- prevLenRef.current = allLogs.length;
40014
- if (!grew) return;
40223
+ const newestChanged = lastKey !== "" && lastKey !== prevLastKeyRef.current;
40224
+ prevLastKeyRef.current = lastKey;
40225
+ if (!newestChanged) return;
40015
40226
  if (follow) scrollToBottom();
40016
40227
  else setHasNewWhilePaused(true);
40017
40228
  }, [
40018
- allLogs.length,
40229
+ lastKey,
40019
40230
  follow,
40020
40231
  scrollToBottom
40021
40232
  ]);
@@ -40035,8 +40246,9 @@ function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: pro
40035
40246
  if (!el) return;
40036
40247
  const pinned = isPinnedToBottom(el, 40);
40037
40248
  setFollow((prev) => prev === pinned ? prev : pinned);
40038
- }, []);
40039
- const expandableKeys = (0, react$1.useMemo)(() => allLogs.filter((log) => log.tags && Object.keys(log.tags).length > 0 || log.meta && Object.keys(log.meta).length > 0).map((log) => `${log.timestamp}|${log.level}|${log.scope ?? ""}|${log.message}`), [allLogs]);
40249
+ if (el.scrollTop <= TOP_LOAD_THRESHOLD_PX) requestOlder();
40250
+ }, [requestOlder]);
40251
+ const expandableKeys = (0, react$1.useMemo)(() => allLogs.filter((log) => log.tags && Object.keys(log.tags).length > 0 || log.meta && Object.keys(log.meta).length > 0).map(logEntryKey), [allLogs]);
40040
40252
  const toggleExpandAll = (0, react$1.useCallback)(() => {
40041
40253
  setExpandAll((prev) => {
40042
40254
  if (prev) setExpandedRows(/* @__PURE__ */ new Set());
@@ -40083,7 +40295,7 @@ function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: pro
40083
40295
  setTimeout(() => setCopiedRowKey((prev) => prev === key ? null : prev), 1200);
40084
40296
  });
40085
40297
  }, []);
40086
- const rowKey = (0, react$1.useCallback)((log) => `${log.timestamp}|${log.level}|${log.scope ?? ""}|${log.message}`, []);
40298
+ const rowKey = logEntryKey;
40087
40299
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
40088
40300
  className: cn("h-full min-h-0 flex flex-col", className),
40089
40301
  onClick: (e) => e.stopPropagation(),
@@ -40118,6 +40330,23 @@ function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: pro
40118
40330
  children: [l, "+"]
40119
40331
  }, l))
40120
40332
  }),
40333
+ sourceSelectOn && readableSources.length > 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
40334
+ className: "w-36",
40335
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Select, {
40336
+ "aria-label": t("uiLibrary.logStream.source", "Log source"),
40337
+ className: "h-5 text-[10px]",
40338
+ value: sourceChoice ?? readableSources.find((x) => x.isDefault)?.addonId ?? "",
40339
+ onChange: (e) => setSourceChoice(e.target.value),
40340
+ options: readableSources.map((x) => ({
40341
+ value: x.addonId,
40342
+ label: x.addonId
40343
+ }))
40344
+ })
40345
+ }),
40346
+ sourceSelectOn && readableSources.length <= 1 && history.source && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
40347
+ className: "text-[9px] text-foreground-subtle",
40348
+ children: t("uiLibrary.logStream.sourceName", "Source: {{name}}", { name: history.source })
40349
+ }),
40121
40350
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolbarButton, {
40122
40351
  icon: ArrowDownToLine,
40123
40352
  label: follow ? t("uiLibrary.logStream.following", "Following") : t("uiLibrary.logStream.paused", "Paused"),
@@ -40216,11 +40445,16 @@ function LogStream({ agentId: propsAgentId, addonId: propsAddonId, deviceId: pro
40216
40445
  onScroll: handleScroll,
40217
40446
  className: cn("flex-1 min-h-0 overflow-auto font-mono text-[10px]", maxHeight),
40218
40447
  children: [
40219
- isLoading && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
40220
- className: "flex items-center justify-center gap-2 py-6 text-foreground-subtle",
40221
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), t("uiLibrary.logStream.loading", "Loading logs...")]
40448
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HistoryEdge, {
40449
+ status: history.status,
40450
+ done: history.done,
40451
+ scannedBytes: history.scannedBytes,
40452
+ hasRows: allLogs.length > 0,
40453
+ error: history.error,
40454
+ onLoadOlder: requestOlder,
40455
+ onCancel: history.cancel
40222
40456
  }),
40223
- !isLoading && allLogs.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
40457
+ history.status === "idle" && history.done && allLogs.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
40224
40458
  className: "py-4 text-center text-foreground-subtle",
40225
40459
  children: t("uiLibrary.logStream.noLogs", "No logs found")
40226
40460
  }),
@@ -40344,6 +40578,49 @@ function DeviceChip({ deviceId, deviceName }) {
40344
40578
  children: label
40345
40579
  });
40346
40580
  }
40581
+ /**
40582
+ * The top of the list: the state of the older-history scan. A scan that is
40583
+ * still looking (short page, source not done) says how much it has read and
40584
+ * can be cancelled; the end of the source is stated quietly.
40585
+ */
40586
+ function HistoryEdge({ status, done, scannedBytes, hasRows, error, onLoadOlder, onCancel }) {
40587
+ const t = useUiText();
40588
+ if (status === "loading") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
40589
+ className: "flex items-center justify-center gap-2 py-2 text-foreground-subtle",
40590
+ children: [
40591
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }),
40592
+ scannedBytes > 0 ? t("uiLibrary.logStream.searching", "Searching older logs… ({{mb}} MB scanned)", { mb: (scannedBytes / BYTES_PER_MB).toFixed(1) }) : t("uiLibrary.logStream.loading", "Loading logs..."),
40593
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
40594
+ variant: "outline",
40595
+ size: "sm",
40596
+ onClick: onCancel,
40597
+ children: t("uiLibrary.logStream.cancel", "Cancel")
40598
+ })
40599
+ ]
40600
+ });
40601
+ if (error) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
40602
+ className: "flex items-center justify-center gap-2 py-2 text-danger",
40603
+ children: [error, /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
40604
+ variant: "outline",
40605
+ size: "sm",
40606
+ onClick: onLoadOlder,
40607
+ children: t("uiLibrary.logStream.retry", "Retry")
40608
+ })]
40609
+ });
40610
+ if (done) return hasRows ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
40611
+ className: "py-2 text-center text-foreground-subtle",
40612
+ children: t("uiLibrary.logStream.beginning", "Beginning of logs")
40613
+ }) : null;
40614
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
40615
+ className: "flex justify-center py-1.5",
40616
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
40617
+ variant: "outline",
40618
+ size: "sm",
40619
+ onClick: onLoadOlder,
40620
+ children: t("uiLibrary.logStream.loadOlder", "Load older")
40621
+ })
40622
+ });
40623
+ }
40347
40624
  function ScopeBadge$2({ label, value }) {
40348
40625
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
40349
40626
  className: "text-[9px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium",
@@ -52387,24 +52664,6 @@ function DeviceBatchActionsBar(props) {
52387
52664
  })] });
52388
52665
  }
52389
52666
  //#endregion
52390
- //#region src/hooks/use-debounced-string.ts
52391
- /**
52392
- * useDebouncedString — return `value` after `delay` ms have passed
52393
- * without further changes. Resets the timer on every change.
52394
- *
52395
- * Useful for search inputs that should NOT push to URL state on every
52396
- * keystroke. The DOM input stays controlled by an external `value`
52397
- * (caller's `useState`); this hook produces a debounced derivation.
52398
- */
52399
- function useDebouncedString(value, delayMs) {
52400
- const [debounced, setDebounced] = (0, react$1.useState)(value);
52401
- (0, react$1.useEffect)(() => {
52402
- const t = setTimeout(() => setDebounced(value), delayMs);
52403
- return () => clearTimeout(t);
52404
- }, [value, delayMs]);
52405
- return debounced;
52406
- }
52407
- //#endregion
52408
52667
  //#region src/hooks/use-persisted-page-size.ts
52409
52668
  /**
52410
52669
  * usePersistedPageSize — a "rows per page" choice remembered per viewer.
@@ -65703,6 +65962,7 @@ exports.useDeviceHistoryGetStatus = useDeviceHistoryGetStatus;
65703
65962
  exports.useDeviceHistoryListNodeSeries = useDeviceHistoryListNodeSeries;
65704
65963
  exports.useDeviceHistoryListSeries = useDeviceHistoryListSeries;
65705
65964
  exports.useDeviceHistoryQuery = useDeviceHistoryQuery;
65965
+ exports.useDeviceHistoryQueryMany = useDeviceHistoryQueryMany;
65706
65966
  exports.useDeviceHistoryQueryNode = useDeviceHistoryQueryNode;
65707
65967
  exports.useDeviceHistoryRecord = useDeviceHistoryRecord;
65708
65968
  exports.useDeviceHistoryRecordBuckets = useDeviceHistoryRecordBuckets;