@vaultgradient/pipequery-cli 0.3.2

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.
Files changed (59) hide show
  1. package/README.md +384 -0
  2. package/dist/index.js +4630 -0
  3. package/package.json +43 -0
  4. package/src/commands/completion.ts +167 -0
  5. package/src/commands/dashboard.ts +44 -0
  6. package/src/commands/endpoint.ts +99 -0
  7. package/src/commands/init.ts +20 -0
  8. package/src/commands/monitor.ts +52 -0
  9. package/src/commands/query.ts +81 -0
  10. package/src/commands/remote.ts +89 -0
  11. package/src/commands/repl.ts +222 -0
  12. package/src/commands/serve.ts +122 -0
  13. package/src/commands/source.ts +150 -0
  14. package/src/commands/stop.ts +50 -0
  15. package/src/config/defaults.ts +48 -0
  16. package/src/config/loader.ts +37 -0
  17. package/src/config/schema.ts +73 -0
  18. package/src/docker/templates.ts +43 -0
  19. package/src/engine.ts +5 -0
  20. package/src/index.ts +164 -0
  21. package/src/server/activity-log.ts +28 -0
  22. package/src/server/app.ts +101 -0
  23. package/src/server/cache.ts +30 -0
  24. package/src/server/routes/control.ts +100 -0
  25. package/src/server/routes/dynamic.ts +51 -0
  26. package/src/server/routes/health.ts +16 -0
  27. package/src/server/routes/sse.ts +96 -0
  28. package/src/server/sources/file.ts +79 -0
  29. package/src/server/sources/manager.ts +120 -0
  30. package/src/server/sources/rest.ts +85 -0
  31. package/src/server/sources/static.ts +31 -0
  32. package/src/server/sources/types.ts +15 -0
  33. package/src/server/sources/websocket.ts +86 -0
  34. package/src/tui/App.tsx +141 -0
  35. package/src/tui/MonitorApp.tsx +271 -0
  36. package/src/tui/components/BarChart.tsx +48 -0
  37. package/src/tui/components/CandleChart.tsx +133 -0
  38. package/src/tui/components/Heatmap.tsx +92 -0
  39. package/src/tui/components/Layout.tsx +42 -0
  40. package/src/tui/components/OrderBook.tsx +94 -0
  41. package/src/tui/components/Panel.tsx +35 -0
  42. package/src/tui/components/Sparkline.tsx +55 -0
  43. package/src/tui/components/StatBox.tsx +37 -0
  44. package/src/tui/components/StatusBar.tsx +51 -0
  45. package/src/tui/components/Table.tsx +133 -0
  46. package/src/tui/hooks/useBatchedData.ts +77 -0
  47. package/src/tui/hooks/useKeyboard.ts +23 -0
  48. package/src/tui/hooks/useLiveData.ts +161 -0
  49. package/src/tui/hooks/useMouse.ts +86 -0
  50. package/src/tui/hooks/useSSE.ts +95 -0
  51. package/src/tui/hooks/useServerData.ts +37 -0
  52. package/src/utils/banner.ts +70 -0
  53. package/src/utils/logger.ts +10 -0
  54. package/src/utils/parseDuration.ts +12 -0
  55. package/src/utils/server-discovery.ts +43 -0
  56. package/src/utils/sync-output.ts +62 -0
  57. package/src/utils/yaml.ts +12 -0
  58. package/tsconfig.json +17 -0
  59. package/tsup.config.ts +13 -0
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface PanelProps {
5
+ title: string;
6
+ focused?: boolean;
7
+ loading?: boolean;
8
+ error?: string;
9
+ children: React.ReactNode;
10
+ }
11
+
12
+ export function Panel({ title, focused = false, loading = false, error, children }: PanelProps) {
13
+ const borderColor = focused ? 'cyan' : 'gray';
14
+
15
+ return (
16
+ <Box
17
+ flexDirection="column"
18
+ flexGrow={1}
19
+ borderStyle="round"
20
+ borderColor={borderColor}
21
+ paddingX={1}
22
+ overflow="hidden"
23
+ >
24
+ <Box>
25
+ <Text bold color={focused ? 'cyan' : undefined}>{title}</Text>
26
+ {loading && <Text dimColor> ↻</Text>}
27
+ </Box>
28
+ {error ? (
29
+ <Text color="red">{error}</Text>
30
+ ) : (
31
+ children
32
+ )}
33
+ </Box>
34
+ );
35
+ }
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface SparklineProps {
5
+ data: Record<string, unknown>[];
6
+ maxPoints?: number;
7
+ }
8
+
9
+ // Braille-based sparkline characters (8 levels)
10
+ const TICKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
11
+
12
+ export function Sparkline({ data, maxPoints = 60 }: SparklineProps) {
13
+ if (!data.length) return <Text dimColor>(empty)</Text>;
14
+
15
+ const keys = Object.keys(data[0]);
16
+ const labelKey = keys[0];
17
+ const valueKey = keys.find((k) => typeof data[0][k] === 'number') ?? keys[1];
18
+
19
+ if (!valueKey) return <Text dimColor>(no numeric column)</Text>;
20
+
21
+ const points = data.slice(-maxPoints);
22
+ const values = points.map((row) => Number(row[valueKey]) || 0);
23
+ const min = Math.min(...values);
24
+ const max = Math.max(...values);
25
+ const range = max - min || 1;
26
+
27
+ const spark = values.map((v) => {
28
+ const level = Math.round(((v - min) / range) * (TICKS.length - 1));
29
+ return TICKS[Math.max(0, Math.min(TICKS.length - 1, level))];
30
+ }).join('');
31
+
32
+ const lastLabel = points.length > 0 ? String(points[points.length - 1][labelKey] ?? '') : '';
33
+ const lastValue = values[values.length - 1];
34
+
35
+ return (
36
+ <Box flexDirection="column">
37
+ <Box>
38
+ <Text color="cyan">{spark}</Text>
39
+ </Box>
40
+ <Box gap={2}>
41
+ <Text dimColor>min: {formatNum(min)}</Text>
42
+ <Text dimColor>max: {formatNum(max)}</Text>
43
+ <Text dimColor>last: {formatNum(lastValue)}</Text>
44
+ {lastLabel && <Text dimColor>({lastLabel})</Text>}
45
+ </Box>
46
+ </Box>
47
+ );
48
+ }
49
+
50
+ function formatNum(n: number): string {
51
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
52
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
53
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
54
+ return Number.isInteger(n) ? String(n) : n.toFixed(2);
55
+ }
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface StatBoxProps {
5
+ data: unknown;
6
+ }
7
+
8
+ export function StatBox({ data }: StatBoxProps) {
9
+ let value: string;
10
+
11
+ if (Array.isArray(data) && data.length === 1 && typeof data[0] === 'object' && data[0] !== null) {
12
+ const obj = data[0] as Record<string, unknown>;
13
+ const firstVal = Object.values(obj)[0];
14
+ value = formatStat(firstVal);
15
+ } else if (typeof data === 'number' || typeof data === 'string') {
16
+ value = formatStat(data);
17
+ } else {
18
+ value = String(data);
19
+ }
20
+
21
+ return (
22
+ <Box justifyContent="center" alignItems="center">
23
+ <Text bold color="green">{value}</Text>
24
+ </Box>
25
+ );
26
+ }
27
+
28
+ function formatStat(val: unknown): string {
29
+ if (typeof val === 'number') {
30
+ if (val >= 1e12) return (val / 1e12).toFixed(2) + 'T';
31
+ if (val >= 1e9) return (val / 1e9).toFixed(2) + 'B';
32
+ if (val >= 1e6) return (val / 1e6).toFixed(2) + 'M';
33
+ if (val >= 1e3) return (val / 1e3).toFixed(2) + 'K';
34
+ return Number.isInteger(val) ? String(val) : val.toFixed(2);
35
+ }
36
+ return String(val ?? '');
37
+ }
@@ -0,0 +1,51 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface StatusBarProps {
5
+ serverUrl: string;
6
+ refreshMs: number;
7
+ }
8
+
9
+ export function StatusBar({ serverUrl, refreshMs }: StatusBarProps) {
10
+ const [status, setStatus] = useState<{ uptime: number; sources: number } | null>(null);
11
+
12
+ useEffect(() => {
13
+ const fetchStatus = async () => {
14
+ try {
15
+ const res = await fetch(`${serverUrl}/status`);
16
+ const data = await res.json() as { uptime: number; sources: Record<string, unknown> };
17
+ setStatus({ uptime: data.uptime, sources: Object.keys(data.sources).length });
18
+ } catch {
19
+ setStatus(null);
20
+ }
21
+ };
22
+ fetchStatus();
23
+ const timer = setInterval(fetchStatus, refreshMs);
24
+ return () => clearInterval(timer);
25
+ }, [serverUrl, refreshMs]);
26
+
27
+ const uptimeStr = status ? formatUptime(status.uptime) : '—';
28
+ const sourcesStr = status ? `${status.sources} source(s)` : 'disconnected';
29
+ const refreshStr = `${Math.floor(refreshMs / 1000)}s`;
30
+
31
+ return (
32
+ <Box justifyContent="space-between" paddingX={1} borderStyle="single" borderColor="gray" borderTop={false} borderLeft={false} borderRight={false}>
33
+ <Box gap={2}>
34
+ <Text dimColor>server: {serverUrl}</Text>
35
+ <Text dimColor>uptime: {uptimeStr}</Text>
36
+ <Text dimColor>{sourcesStr}</Text>
37
+ </Box>
38
+ <Box gap={2}>
39
+ <Text dimColor>refresh: {refreshStr}</Text>
40
+ <Text dimColor>q: quit tab: focus ↑↓: scroll []: resize r: refresh</Text>
41
+ </Box>
42
+ </Box>
43
+ );
44
+ }
45
+
46
+ function formatUptime(ms: number): string {
47
+ const s = Math.floor(ms / 1000);
48
+ if (s < 60) return `${s}s`;
49
+ if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
50
+ return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
51
+ }
@@ -0,0 +1,133 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+
4
+ interface TableProps {
5
+ data: Record<string, unknown>[];
6
+ maxRows?: number;
7
+ focused?: boolean;
8
+ }
9
+
10
+ function formatValue(val: unknown): string {
11
+ if (val == null) return '';
12
+ if (typeof val === 'number') return Number.isInteger(val) ? String(val) : val.toFixed(2);
13
+ return String(val);
14
+ }
15
+
16
+ // Scrollbar characters
17
+ const SCROLLBAR_TRACK = '│';
18
+ const SCROLLBAR_THUMB = '█';
19
+
20
+ function Scrollbar({ totalRows, visibleRows, scrollOffset, height }: {
21
+ totalRows: number;
22
+ visibleRows: number;
23
+ scrollOffset: number;
24
+ height: number;
25
+ }) {
26
+ if (totalRows <= visibleRows) return null;
27
+
28
+ const thumbSize = Math.max(1, Math.round((visibleRows / totalRows) * height));
29
+ const maxScroll = totalRows - visibleRows;
30
+ const thumbPosition = maxScroll > 0
31
+ ? Math.round((scrollOffset / maxScroll) * (height - thumbSize))
32
+ : 0;
33
+
34
+ const lines: Array<{ char: string; isThumb: boolean }> = [];
35
+ for (let i = 0; i < height; i++) {
36
+ const isThumb = i >= thumbPosition && i < thumbPosition + thumbSize;
37
+ lines.push({ char: isThumb ? SCROLLBAR_THUMB : SCROLLBAR_TRACK, isThumb });
38
+ }
39
+
40
+ return (
41
+ <Box flexDirection="column" marginLeft={1}>
42
+ {lines.map((line, i) => (
43
+ <Text key={i} color={line.isThumb ? '#06b6d4' : '#333333'}>{line.char}</Text>
44
+ ))}
45
+ </Box>
46
+ );
47
+ }
48
+
49
+ export function Table({ data, maxRows = 20, focused = false }: TableProps) {
50
+ const [scrollOffset, setScrollOffset] = useState(0);
51
+ const prevDataLenRef = useRef(data.length);
52
+
53
+ // Clamp scroll offset when data length changes, but preserve position
54
+ useEffect(() => {
55
+ const maxOffset = Math.max(0, data.length - maxRows);
56
+ setScrollOffset((prev) => Math.min(prev, maxOffset));
57
+ prevDataLenRef.current = data.length;
58
+ }, [data.length, maxRows]);
59
+
60
+ useInput((input, key) => {
61
+ if (!focused) return;
62
+ const maxOffset = Math.max(0, data.length - maxRows);
63
+ if (key.upArrow) setScrollOffset((o) => Math.max(0, o - 1));
64
+ if (key.downArrow) setScrollOffset((o) => Math.min(maxOffset, o + 1));
65
+ // Page up/down
66
+ if (key.pageUp || (input === 'u' && key.ctrl)) {
67
+ setScrollOffset((o) => Math.max(0, o - maxRows));
68
+ }
69
+ if (key.pageDown || (input === 'd' && key.ctrl)) {
70
+ setScrollOffset((o) => Math.min(maxOffset, o + maxRows));
71
+ }
72
+ });
73
+
74
+ if (!data.length) return <Text dimColor>(empty)</Text>;
75
+
76
+ const columns = Object.keys(data[0]);
77
+ const widths = columns.map((col) => {
78
+ const maxData = data.reduce((max, row) => Math.max(max, formatValue(row[col]).length), 0);
79
+ return Math.min(Math.max(col.length, maxData), 30);
80
+ });
81
+
82
+ const visibleRows = data.slice(scrollOffset, scrollOffset + maxRows);
83
+ const needsScroll = data.length > maxRows;
84
+
85
+ return (
86
+ <Box flexDirection="column">
87
+ <Box>
88
+ <Box flexDirection="column" flexGrow={1}>
89
+ {/* Header */}
90
+ <Box>
91
+ {columns.map((col, i) => (
92
+ <Box key={col} width={widths[i] + 2}>
93
+ <Text bold>{col.slice(0, widths[i]).padEnd(widths[i])}</Text>
94
+ </Box>
95
+ ))}
96
+ </Box>
97
+ {/* Separator */}
98
+ <Box>
99
+ <Text dimColor>{widths.map((w) => '─'.repeat(w)).join('──')}</Text>
100
+ </Box>
101
+ {/* Rows */}
102
+ {visibleRows.map((row, ri) => (
103
+ <Box key={ri}>
104
+ {columns.map((col, i) => (
105
+ <Box key={col} width={widths[i] + 2}>
106
+ <Text color={typeof row[col] === 'number' ? '#f59e0b' : undefined}>
107
+ {formatValue(row[col]).slice(0, widths[i]).padEnd(widths[i])}
108
+ </Text>
109
+ </Box>
110
+ ))}
111
+ </Box>
112
+ ))}
113
+ </Box>
114
+ {/* Scrollbar */}
115
+ {needsScroll && (
116
+ <Scrollbar
117
+ totalRows={data.length}
118
+ visibleRows={maxRows}
119
+ scrollOffset={scrollOffset}
120
+ height={visibleRows.length + 2}
121
+ />
122
+ )}
123
+ </Box>
124
+ {/* Scroll info */}
125
+ {needsScroll && (
126
+ <Text dimColor>
127
+ {scrollOffset + 1}–{Math.min(scrollOffset + maxRows, data.length)} of {data.length} rows
128
+ {focused ? ' │ ↑↓ scroll' : ''}
129
+ </Text>
130
+ )}
131
+ </Box>
132
+ );
133
+ }
@@ -0,0 +1,77 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+
3
+ interface PanelResult {
4
+ data: unknown;
5
+ error?: string;
6
+ }
7
+
8
+ /**
9
+ * Fetches all panel queries in a single batch and updates state once.
10
+ * This prevents multiple re-renders (and thus flicker) that occur when
11
+ * each panel fetches independently.
12
+ */
13
+ export function useBatchedData(
14
+ serverUrl: string,
15
+ queries: string[],
16
+ refreshMs: number,
17
+ ) {
18
+ const [results, setResults] = useState<Map<string, PanelResult>>(new Map());
19
+ const [loading, setLoading] = useState(true);
20
+ const [trigger, setTrigger] = useState(0);
21
+ const queriesKey = queries.join('|||');
22
+
23
+ useEffect(() => {
24
+ let cancelled = false;
25
+
26
+ const fetchAll = async () => {
27
+ const entries = await Promise.all(
28
+ queries.map(async (query) => {
29
+ try {
30
+ const res = await fetch(`${serverUrl}/api/_control/query`, {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json' },
33
+ body: JSON.stringify({ query }),
34
+ });
35
+ const json = (await res.json()) as {
36
+ ok: boolean;
37
+ result?: unknown;
38
+ error?: string;
39
+ };
40
+ if (json.ok) {
41
+ return [query, { data: json.result }] as const;
42
+ }
43
+ return [query, { data: null, error: json.error }] as const;
44
+ } catch (err) {
45
+ return [
46
+ query,
47
+ { data: null, error: err instanceof Error ? err.message : String(err) },
48
+ ] as const;
49
+ }
50
+ }),
51
+ );
52
+
53
+ if (!cancelled) {
54
+ setResults(new Map(entries));
55
+ setLoading(false);
56
+ }
57
+ };
58
+
59
+ fetchAll();
60
+ const timer = setInterval(fetchAll, refreshMs);
61
+
62
+ return () => {
63
+ cancelled = true;
64
+ clearInterval(timer);
65
+ };
66
+ }, [serverUrl, queriesKey, refreshMs, trigger]);
67
+
68
+ // Manual refresh — force sources to re-fetch, then re-run queries
69
+ const refresh = useCallback(async () => {
70
+ try {
71
+ await fetch(`${serverUrl}/api/_control/refresh`, { method: 'POST' });
72
+ } catch { /* ignore */ }
73
+ setTrigger((t) => t + 1);
74
+ }, [serverUrl]);
75
+
76
+ return { results, loading, refresh };
77
+ }
@@ -0,0 +1,23 @@
1
+ import { useInput, useApp } from 'ink';
2
+
3
+ interface KeyboardOptions {
4
+ panelCount: number;
5
+ focusedPanel: number;
6
+ setFocusedPanel: (fn: (i: number) => number) => void;
7
+ onRefresh?: () => void;
8
+ onResize?: (direction: 'grow' | 'shrink') => void;
9
+ }
10
+
11
+ export function useKeyboard({ panelCount, focusedPanel, setFocusedPanel, onRefresh, onResize }: KeyboardOptions) {
12
+ const { exit } = useApp();
13
+
14
+ useInput((input, key) => {
15
+ if (input === 'q') exit();
16
+ if (input === 'r') onRefresh?.();
17
+ if (key.tab && !key.shift) setFocusedPanel((i) => (i + 1) % panelCount);
18
+ if (key.tab && key.shift) setFocusedPanel((i) => (i - 1 + panelCount) % panelCount);
19
+ // Resize: [ to shrink, ] to grow focused panel
20
+ if (input === ']') onResize?.('grow');
21
+ if (input === '[') onResize?.('shrink');
22
+ });
23
+ }
@@ -0,0 +1,161 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import http from 'node:http';
3
+
4
+ interface PanelResult {
5
+ data: unknown;
6
+ error?: string;
7
+ }
8
+
9
+ /**
10
+ * Connects to SSE for real-time push updates from the server.
11
+ * Falls back to polling if SSE connection fails.
12
+ */
13
+ export function useLiveData(
14
+ serverUrl: string,
15
+ queries: string[],
16
+ fallbackMs: number,
17
+ ) {
18
+ const [results, setResults] = useState<Map<string, PanelResult>>(new Map());
19
+ const [loading, setLoading] = useState(true);
20
+ const [connected, setConnected] = useState(false);
21
+ const abortRef = useRef<AbortController | null>(null);
22
+ const fallbackTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
23
+ const queriesKey = queries.join('|||');
24
+
25
+ // SSE connection
26
+ useEffect(() => {
27
+ if (queries.length === 0) return;
28
+
29
+ const abort = new AbortController();
30
+ abortRef.current = abort;
31
+ let sseConnected = false;
32
+
33
+ const connectSSE = () => {
34
+ if (abort.signal.aborted) return;
35
+
36
+ const url = new URL('/api/_control/sse', serverUrl);
37
+ url.searchParams.set('queries', JSON.stringify(queries));
38
+
39
+ const req = http.get(url.toString(), { signal: abort.signal }, (res) => {
40
+ if (res.statusCode !== 200) {
41
+ startPolling();
42
+ return;
43
+ }
44
+
45
+ sseConnected = true;
46
+ setConnected(true);
47
+ stopPolling();
48
+
49
+ let buffer = '';
50
+ res.on('data', (chunk: Buffer) => {
51
+ buffer += chunk.toString();
52
+ const blocks = buffer.split('\n\n');
53
+ buffer = blocks.pop() ?? '';
54
+
55
+ // Batch all events from this chunk into a single state update
56
+ const updates: Array<{ index: number; result?: unknown; error?: string }> = [];
57
+
58
+ for (const block of blocks) {
59
+ const dataLine = block.split('\n').find((l) => l.startsWith('data: '));
60
+ if (!dataLine) continue;
61
+
62
+ try {
63
+ const event = JSON.parse(dataLine.slice(6)) as {
64
+ panelIndex: number;
65
+ result?: unknown;
66
+ error?: string;
67
+ };
68
+ updates.push({ index: event.panelIndex, result: event.result, error: event.error });
69
+ } catch {
70
+ // ignore heartbeats
71
+ }
72
+ }
73
+
74
+ if (updates.length > 0) {
75
+ setResults((prev) => {
76
+ const next = new Map(prev);
77
+ for (const u of updates) {
78
+ const query = queries[u.index];
79
+ if (query) {
80
+ next.set(query, u.error ? { data: null, error: u.error } : { data: u.result });
81
+ }
82
+ }
83
+ return next;
84
+ });
85
+ setLoading(false);
86
+ }
87
+ });
88
+
89
+ res.on('end', () => {
90
+ sseConnected = false;
91
+ setConnected(false);
92
+ if (!abort.signal.aborted) {
93
+ startPolling();
94
+ setTimeout(connectSSE, 3000);
95
+ }
96
+ });
97
+ });
98
+
99
+ req.on('error', () => {
100
+ sseConnected = false;
101
+ setConnected(false);
102
+ if (!abort.signal.aborted) {
103
+ startPolling();
104
+ setTimeout(connectSSE, 3000);
105
+ }
106
+ });
107
+ };
108
+
109
+ // Polling fallback
110
+ const pollAll = async () => {
111
+ const entries = await Promise.all(
112
+ queries.map(async (query) => {
113
+ try {
114
+ const res = await fetch(`${serverUrl}/api/_control/query`, {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ query }),
118
+ });
119
+ const json = (await res.json()) as { ok: boolean; result?: unknown; error?: string };
120
+ if (json.ok) return [query, { data: json.result }] as const;
121
+ return [query, { data: null, error: json.error }] as const;
122
+ } catch (err) {
123
+ return [query, { data: null, error: err instanceof Error ? err.message : String(err) }] as const;
124
+ }
125
+ }),
126
+ );
127
+ setResults(new Map(entries));
128
+ setLoading(false);
129
+ };
130
+
131
+ const startPolling = () => {
132
+ if (fallbackTimerRef.current || sseConnected) return;
133
+ pollAll();
134
+ fallbackTimerRef.current = setInterval(pollAll, fallbackMs);
135
+ };
136
+
137
+ const stopPolling = () => {
138
+ if (fallbackTimerRef.current) {
139
+ clearInterval(fallbackTimerRef.current);
140
+ fallbackTimerRef.current = null;
141
+ }
142
+ };
143
+
144
+ connectSSE();
145
+
146
+ return () => {
147
+ abort.abort();
148
+ stopPolling();
149
+ };
150
+ }, [serverUrl, queriesKey, fallbackMs]);
151
+
152
+ // Manual refresh — force sources to re-fetch
153
+ const refresh = useCallback(async () => {
154
+ try {
155
+ await fetch(`${serverUrl}/api/_control/refresh`, { method: 'POST' });
156
+ } catch { /* ignore */ }
157
+ // SSE will automatically push new data after source refresh
158
+ }, [serverUrl]);
159
+
160
+ return { results, loading, connected, refresh };
161
+ }
@@ -0,0 +1,86 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ interface MouseEvent {
4
+ x: number;
5
+ y: number;
6
+ button: 'left' | 'right' | 'middle' | 'scroll-up' | 'scroll-down';
7
+ type: 'press' | 'release' | 'move';
8
+ }
9
+
10
+ type MouseHandler = (event: MouseEvent) => void;
11
+
12
+ // Regex to detect SGR mouse sequences
13
+ const SGR_MOUSE_RE = /\x1B\[<(\d+);(\d+);(\d+)([Mm])/g;
14
+
15
+ /**
16
+ * Enable mouse click tracking in the terminal.
17
+ * Only captures button press/release — does NOT capture mouse movement
18
+ * to avoid interfering with normal terminal scrolling.
19
+ */
20
+ export function useMouse(handler: MouseHandler) {
21
+ const handlerRef = useRef(handler);
22
+ handlerRef.current = handler;
23
+
24
+ useEffect(() => {
25
+ // Enable button event tracking only (not movement)
26
+ // 1000 = basic click tracking, 1006 = SGR extended coordinates
27
+ process.stdout.write('\x1B[?1000h');
28
+ process.stdout.write('\x1B[?1006h');
29
+
30
+ // Use a keypress listener instead of raw data to avoid blocking Ink
31
+ const onKeypress = (_ch: string | undefined, key: any) => {
32
+ // Ink passes raw sequences through; we need to check raw stdin
33
+ };
34
+
35
+ // Intercept raw data, parse mouse events, and re-emit non-mouse data
36
+ const originalEmit = process.stdin.emit.bind(process.stdin);
37
+ const wrappedEmit = function (event: string, ...args: any[]): boolean {
38
+ if (event === 'data' && args[0]) {
39
+ const buf = args[0] as Buffer;
40
+ const str = buf.toString();
41
+
42
+ // Check if this data contains mouse sequences
43
+ if (str.includes('\x1B[<')) {
44
+ let match: RegExpExecArray | null;
45
+ SGR_MOUSE_RE.lastIndex = 0;
46
+
47
+ while ((match = SGR_MOUSE_RE.exec(str)) !== null) {
48
+ const buttonCode = parseInt(match[1], 10);
49
+ const x = parseInt(match[2], 10);
50
+ const y = parseInt(match[3], 10);
51
+ const isRelease = match[4] === 'm';
52
+
53
+ let button: MouseEvent['button'] = 'left';
54
+ if ((buttonCode & 3) === 1) button = 'middle';
55
+ else if ((buttonCode & 3) === 2) button = 'right';
56
+ else if (buttonCode & 64) button = buttonCode & 1 ? 'scroll-down' : 'scroll-up';
57
+
58
+ const type: MouseEvent['type'] = isRelease
59
+ ? 'release'
60
+ : (buttonCode & 32) ? 'move' : 'press';
61
+
62
+ handlerRef.current({ x, y, button, type });
63
+ }
64
+
65
+ // Strip mouse sequences and pass remaining data through
66
+ const remaining = str.replace(SGR_MOUSE_RE, '');
67
+ if (remaining.length > 0) {
68
+ return originalEmit(event, Buffer.from(remaining));
69
+ }
70
+ // Consumed entirely by mouse — still return true
71
+ return true;
72
+ }
73
+ }
74
+
75
+ return originalEmit(event, ...args);
76
+ } as typeof process.stdin.emit;
77
+
78
+ process.stdin.emit = wrappedEmit;
79
+
80
+ return () => {
81
+ process.stdin.emit = originalEmit;
82
+ process.stdout.write('\x1B[?1006l');
83
+ process.stdout.write('\x1B[?1000l');
84
+ };
85
+ }, []);
86
+ }