@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,43 @@
1
+ export function generateDockerfile(port: number): string {
2
+ return `FROM node:20-alpine
3
+ WORKDIR /app
4
+
5
+ # Copy config and data files
6
+ COPY pipequery.yaml .
7
+ COPY data/ data/ 2>/dev/null || true
8
+
9
+ # Install PipeQuery CLI
10
+ RUN npm install -g @andreadito/pq
11
+
12
+ EXPOSE ${port}
13
+
14
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \\
15
+ CMD wget -qO- http://localhost:${port}/health || exit 1
16
+
17
+ CMD ["pq", "serve", "--host", "0.0.0.0"]
18
+ `;
19
+ }
20
+
21
+ export function generateDockerCompose(port: number): string {
22
+ return `services:
23
+ pipequery:
24
+ build: .
25
+ ports:
26
+ - "${port}:${port}"
27
+ volumes:
28
+ - ./pipequery.yaml:/app/pipequery.yaml:ro
29
+ - ./data:/app/data:ro
30
+ restart: unless-stopped
31
+ environment:
32
+ - NODE_ENV=production
33
+ `;
34
+ }
35
+
36
+ export function generateDockerignore(): string {
37
+ return `node_modules
38
+ .pipequery
39
+ .git
40
+ *.log
41
+ dist
42
+ `;
43
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Re-export the PipeQuery engine from the parent package.
2
+ // This module centralizes the import so we can switch between
3
+ // workspace reference and published package easily.
4
+ export { query, compile, parseQuery, clearCache, LiveQuery, liveQuery } from '../../src/engine/index.js';
5
+ export type { DataContext, CompiledQuery, LiveQueryOptions, LiveQueryStats } from '../../src/engine/index.js';
package/src/index.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { initCommand } from './commands/init.js';
4
+ import { serveCommand } from './commands/serve.js';
5
+ import { queryCommand } from './commands/query.js';
6
+ import { sourceListCommand, sourceAddCommand, sourceRemoveCommand, sourceTestCommand } from './commands/source.js';
7
+ import { endpointListCommand, endpointAddCommand, endpointRemoveCommand } from './commands/endpoint.js';
8
+ import { dashboardCommand } from './commands/dashboard.js';
9
+ import { monitorCommand } from './commands/monitor.js';
10
+ import { remoteDeployCommand, remoteConnectCommand, remoteStatusCommand } from './commands/remote.js';
11
+ import { completionCommand } from './commands/completion.js';
12
+ import { stopCommand } from './commands/stop.js';
13
+ import { startRepl } from './commands/repl.js';
14
+ import { printBanner } from './utils/banner.js';
15
+
16
+ const program = new Command();
17
+
18
+ program
19
+ .name('pq')
20
+ .description('PipeQuery CLI — data sources, aggregations, APIs, and terminal dashboards')
21
+ .version('0.1.0')
22
+ .addHelpText('beforeAll', () => {
23
+ printBanner();
24
+ return '';
25
+ })
26
+ .configureHelp({
27
+ subcommandTerm: (cmd) => chalk.hex('#06b6d4')(cmd.name()) + (cmd.usage() ? ' ' + chalk.dim(cmd.usage()) : ''),
28
+ });
29
+
30
+ // ─── pq init ─────────────────────────────────────────────────────────────────
31
+
32
+ program
33
+ .command('init')
34
+ .description('Create a pipequery.yaml config file')
35
+ .action(initCommand);
36
+
37
+ // ─── pq serve ────────────────────────────────────────────────────────────────
38
+
39
+ program
40
+ .command('serve')
41
+ .description('Start the PipeQuery server')
42
+ .option('-p, --port <port>', 'Server port', parseInt)
43
+ .option('-H, --host <host>', 'Server host')
44
+ .option('-d, --daemon', 'Run as a background daemon')
45
+ .option('--stop', 'Stop a running daemon')
46
+ .action(serveCommand);
47
+
48
+ // ─── pq query ────────────────────────────────────────────────────────────────
49
+
50
+ program
51
+ .command('query <expression>')
52
+ .description('Run an ad-hoc PipeQuery expression')
53
+ .option('-f, --format <format>', 'Output format: table, json', 'table')
54
+ .action(queryCommand);
55
+
56
+ // ─── pq source ───────────────────────────────────────────────────────────────
57
+
58
+ const source = program.command('source').description('Manage data sources');
59
+
60
+ source
61
+ .command('list')
62
+ .description('List configured data sources')
63
+ .action(sourceListCommand);
64
+
65
+ source
66
+ .command('add <name>')
67
+ .description('Add a data source')
68
+ .requiredOption('-t, --type <type>', 'Source type: rest, websocket, file')
69
+ .option('-u, --url <url>', 'URL for REST or WebSocket sources')
70
+ .option('-i, --interval <interval>', 'Poll interval for REST (e.g., 30s, 5m)', '30s')
71
+ .option('-d, --data-path <path>', 'Dot-path to extract data from response')
72
+ .option('-p, --path <path>', 'File path for file sources')
73
+ .option('-w, --watch', 'Watch file for changes')
74
+ .action(sourceAddCommand);
75
+
76
+ source
77
+ .command('remove <name>')
78
+ .description('Remove a data source')
79
+ .action(sourceRemoveCommand);
80
+
81
+ source
82
+ .command('test <name>')
83
+ .description('Test a data source and show sample data')
84
+ .action(sourceTestCommand);
85
+
86
+ // ─── pq endpoint ─────────────────────────────────────────────────────────────
87
+
88
+ const endpoint = program.command('endpoint').description('Manage API endpoints');
89
+
90
+ endpoint
91
+ .command('list')
92
+ .description('List API endpoints')
93
+ .action(endpointListCommand);
94
+
95
+ endpoint
96
+ .command('add <path>')
97
+ .description('Register an API endpoint')
98
+ .requiredOption('-q, --query <query>', 'PipeQuery expression')
99
+ .option('-c, --cache <duration>', 'Cache duration (e.g., 30s)')
100
+ .action(endpointAddCommand);
101
+
102
+ endpoint
103
+ .command('remove <path>')
104
+ .description('Remove an API endpoint')
105
+ .action(endpointRemoveCommand);
106
+
107
+ // ─── pq dashboard ────────────────────────────────────────────────────────────
108
+
109
+ program
110
+ .command('dashboard')
111
+ .description('Launch the interactive terminal dashboard')
112
+ .option('-n, --name <name>', 'Dashboard name from config', 'main')
113
+ .action(dashboardCommand);
114
+
115
+ // ─── pq monitor ─────────────────────────────────────────────────────────────
116
+
117
+ program
118
+ .command('monitor')
119
+ .description('Live server monitor — sources, endpoints, logs')
120
+ .action(monitorCommand);
121
+
122
+ // ─── pq remote ───────────────────────────────────────────────────────────────
123
+
124
+ const remote = program.command('remote').description('Manage remote deployment');
125
+
126
+ remote
127
+ .command('deploy')
128
+ .description('Generate Dockerfile and docker-compose.yaml')
129
+ .action(remoteDeployCommand);
130
+
131
+ remote
132
+ .command('connect <url>')
133
+ .description('Connect CLI to a remote PipeQuery server')
134
+ .action(remoteConnectCommand);
135
+
136
+ remote
137
+ .command('status')
138
+ .description('Check remote server health')
139
+ .action(remoteStatusCommand);
140
+
141
+ // ─── pq stop ────────────────────────────────────────────────────────────────
142
+
143
+ program
144
+ .command('stop')
145
+ .description('Stop the running PipeQuery server (daemon or foreground)')
146
+ .option('-f, --force', 'Force kill (SIGKILL instead of SIGTERM)')
147
+ .action(stopCommand);
148
+
149
+ // ─── pq completion ───────────────────────────────────────────────────────────
150
+
151
+ program
152
+ .command('completion')
153
+ .description('Generate shell completion script')
154
+ .option('-s, --shell <shell>', 'Shell type: bash, zsh, fish')
155
+ .action(completionCommand);
156
+
157
+ // ─── Run ─────────────────────────────────────────────────────────────────────
158
+
159
+ // If no args, start interactive REPL
160
+ if (process.argv.length <= 2) {
161
+ startRepl();
162
+ } else {
163
+ program.parse();
164
+ }
@@ -0,0 +1,28 @@
1
+ export interface ActivityEntry {
2
+ time: string;
3
+ level: 'info' | 'warn' | 'error';
4
+ message: string;
5
+ }
6
+
7
+ const MAX_ENTRIES = 100;
8
+ const entries: ActivityEntry[] = [];
9
+
10
+ export function logActivity(level: ActivityEntry['level'], message: string) {
11
+ entries.push({
12
+ time: new Date().toISOString(),
13
+ level,
14
+ message,
15
+ });
16
+ if (entries.length > MAX_ENTRIES) {
17
+ entries.splice(0, entries.length - MAX_ENTRIES);
18
+ }
19
+ }
20
+
21
+ export function getActivityLog(since?: string): ActivityEntry[] {
22
+ if (!since) return [...entries];
23
+ return entries.filter((e) => e.time > since);
24
+ }
25
+
26
+ export function info(msg: string) { logActivity('info', msg); }
27
+ export function warn(msg: string) { logActivity('warn', msg); }
28
+ export function error(msg: string) { logActivity('error', msg); }
@@ -0,0 +1,101 @@
1
+ import Fastify from 'fastify';
2
+ import cors from '@fastify/cors';
3
+ import type { PipeQueryConfig, EndpointConfig, SourceConfig } from '../config/schema.js';
4
+ import { SourceManager } from './sources/manager.js';
5
+ import { registerHealthRoutes } from './routes/health.js';
6
+ import { registerControlRoutes } from './routes/control.js';
7
+ import { registerDynamicRoutes } from './routes/dynamic.js';
8
+ import { registerSSERoutes } from './routes/sse.js';
9
+ import { saveConfig } from '../config/loader.js';
10
+ import * as activity from './activity-log.js';
11
+ import { getActivityLog } from './activity-log.js';
12
+
13
+ export interface ServerContext {
14
+ app: ReturnType<typeof Fastify>;
15
+ sourceManager: SourceManager;
16
+ endpoints: Map<string, EndpointConfig>;
17
+ }
18
+
19
+ export async function createServer(
20
+ config: PipeQueryConfig,
21
+ configPath: string,
22
+ cwd: string,
23
+ ): Promise<ServerContext> {
24
+ const app = Fastify({ logger: false });
25
+ await app.register(cors);
26
+
27
+ const sourceManager = new SourceManager(cwd);
28
+ const startedAt = new Date();
29
+
30
+ activity.info('Server starting...');
31
+
32
+ // Initialize sources from config
33
+ for (const [name, sourceConfig] of Object.entries(config.sources)) {
34
+ try {
35
+ await sourceManager.addSource(name, sourceConfig);
36
+ activity.info(`Source "${name}" loaded (${sourceConfig.type})`);
37
+ } catch (err) {
38
+ const msg = err instanceof Error ? err.message : String(err);
39
+ activity.error(`Source "${name}" failed: ${msg}`);
40
+ console.error(`Failed to start source "${name}":`, msg);
41
+ }
42
+ }
43
+
44
+ // Build endpoint registry
45
+ const endpoints = new Map<string, EndpointConfig>();
46
+ for (const [path, endpointConfig] of Object.entries(config.endpoints)) {
47
+ endpoints.set(path, endpointConfig);
48
+ activity.info(`Endpoint registered: ${path}`);
49
+ }
50
+
51
+ // Config persistence callbacks (save changes back to YAML)
52
+ const configCallbacks = {
53
+ onSourceChange: async (name: string, sourceConfig: SourceConfig | null) => {
54
+ if (sourceConfig) {
55
+ config.sources[name] = sourceConfig;
56
+ activity.info(`Source "${name}" added (${sourceConfig.type})`);
57
+ } else {
58
+ delete config.sources[name];
59
+ activity.warn(`Source "${name}" removed`);
60
+ }
61
+ await saveConfig(config, configPath);
62
+ },
63
+ onEndpointChange: async (path: string, endpointConfig: EndpointConfig | null) => {
64
+ if (endpointConfig) {
65
+ config.endpoints[path] = endpointConfig;
66
+ activity.info(`Endpoint "${path}" added`);
67
+ } else {
68
+ delete config.endpoints[path];
69
+ activity.warn(`Endpoint "${path}" removed`);
70
+ }
71
+ await saveConfig(config, configPath);
72
+ },
73
+ };
74
+
75
+ // Log requests (skip health/status/activity to reduce noise)
76
+ app.addHook('onResponse', async (request, reply) => {
77
+ const url = request.url;
78
+ if (url === '/health' || url === '/status' || url === '/api/_control/activity') return;
79
+ if (url === '/api/_control/sources' && request.method === 'GET') return;
80
+ if (url === '/api/_control/endpoints' && request.method === 'GET') return;
81
+
82
+ const status = reply.statusCode;
83
+ const level = status >= 400 ? 'error' as const : 'info' as const;
84
+ activity.logActivity(level, `${request.method} ${url} → ${status}`);
85
+ });
86
+
87
+ // Activity log endpoint (for monitor)
88
+ app.get<{ Querystring: { since?: string } }>('/api/_control/activity', async (req) => {
89
+ return getActivityLog(req.query.since);
90
+ });
91
+
92
+ // Register routes
93
+ registerHealthRoutes(app, sourceManager, startedAt);
94
+ registerControlRoutes(app, sourceManager, { endpoints }, configCallbacks);
95
+ registerSSERoutes(app, sourceManager);
96
+ registerDynamicRoutes(app, sourceManager, endpoints);
97
+
98
+ activity.info('Server ready');
99
+
100
+ return { app, sourceManager, endpoints };
101
+ }
@@ -0,0 +1,30 @@
1
+ interface CacheEntry {
2
+ data: unknown;
3
+ expiresAt: number;
4
+ }
5
+
6
+ export class ResponseCache {
7
+ private entries = new Map<string, CacheEntry>();
8
+
9
+ get(key: string): unknown | undefined {
10
+ const entry = this.entries.get(key);
11
+ if (!entry) return undefined;
12
+ if (Date.now() > entry.expiresAt) {
13
+ this.entries.delete(key);
14
+ return undefined;
15
+ }
16
+ return entry.data;
17
+ }
18
+
19
+ set(key: string, data: unknown, ttlMs: number): void {
20
+ this.entries.set(key, { data, expiresAt: Date.now() + ttlMs });
21
+ }
22
+
23
+ invalidate(key: string): void {
24
+ this.entries.delete(key);
25
+ }
26
+
27
+ clear(): void {
28
+ this.entries.clear();
29
+ }
30
+ }
@@ -0,0 +1,100 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { query } from '../../engine.js';
3
+ import type { SourceConfig, EndpointConfig } from '../../config/schema.js';
4
+ import type { SourceManager } from '../sources/manager.js';
5
+
6
+ interface EndpointRegistry {
7
+ endpoints: Map<string, EndpointConfig>;
8
+ onAdd?: (path: string, config: EndpointConfig) => void;
9
+ onRemove?: (path: string) => void;
10
+ }
11
+
12
+ export function registerControlRoutes(
13
+ app: FastifyInstance,
14
+ sourceManager: SourceManager,
15
+ endpointRegistry: EndpointRegistry,
16
+ configCallbacks: {
17
+ onSourceChange: (name: string, config: SourceConfig | null) => Promise<void>;
18
+ onEndpointChange: (path: string, config: EndpointConfig | null) => Promise<void>;
19
+ },
20
+ ) {
21
+ // ─── Sources ─────────────────────────────────────────────────────────────
22
+
23
+ app.get('/api/_control/sources', async () => {
24
+ return sourceManager.getAllStatuses();
25
+ });
26
+
27
+ app.post<{ Body: { name: string; config: SourceConfig } }>('/api/_control/sources', async (req) => {
28
+ const { name, config } = req.body;
29
+ await sourceManager.addSource(name, config);
30
+ await configCallbacks.onSourceChange(name, config);
31
+ return { ok: true, name, status: sourceManager.getSourceStatus(name) };
32
+ });
33
+
34
+ app.delete<{ Params: { name: string } }>('/api/_control/sources/:name', async (req) => {
35
+ const { name } = req.params;
36
+ const removed = sourceManager.removeSource(name);
37
+ if (!removed) return { ok: false, error: `Source "${name}" not found` };
38
+ await configCallbacks.onSourceChange(name, null);
39
+ return { ok: true, name };
40
+ });
41
+
42
+ app.post<{ Params: { name: string } }>('/api/_control/sources/:name/test', async (req) => {
43
+ const { name } = req.params;
44
+ const data = sourceManager.getSourceData(name);
45
+ if (!data) return { ok: false, error: `Source "${name}" not found` };
46
+ return {
47
+ ok: true,
48
+ rowCount: data.length,
49
+ sample: data.slice(0, 3),
50
+ status: sourceManager.getSourceStatus(name),
51
+ };
52
+ });
53
+
54
+ // ─── Endpoints ───────────────────────────────────────────────────────────
55
+
56
+ app.get('/api/_control/endpoints', async () => {
57
+ const result: Record<string, EndpointConfig> = {};
58
+ for (const [path, config] of endpointRegistry.endpoints) {
59
+ result[path] = config;
60
+ }
61
+ return result;
62
+ });
63
+
64
+ app.post<{ Body: { path: string; config: EndpointConfig } }>('/api/_control/endpoints', async (req) => {
65
+ const { path, config } = req.body;
66
+ endpointRegistry.endpoints.set(path, config);
67
+ endpointRegistry.onAdd?.(path, config);
68
+ await configCallbacks.onEndpointChange(path, config);
69
+ return { ok: true, path };
70
+ });
71
+
72
+ app.delete<{ Body: { path: string } }>('/api/_control/endpoints', async (req) => {
73
+ const path = req.body.path;
74
+ const removed = endpointRegistry.endpoints.delete(path);
75
+ if (!removed) return { ok: false, error: `Endpoint "${path}" not found` };
76
+ endpointRegistry.onRemove?.(path);
77
+ await configCallbacks.onEndpointChange(path, null);
78
+ return { ok: true, path };
79
+ });
80
+
81
+ // ─── Refresh all sources ─────────────────────────────────────────────────
82
+
83
+ app.post('/api/_control/refresh', async () => {
84
+ await sourceManager.refreshAll();
85
+ return { ok: true };
86
+ });
87
+
88
+ // ─── Ad-hoc query ────────────────────────────────────────────────────────
89
+
90
+ app.post<{ Body: { query: string } }>('/api/_control/query', async (req) => {
91
+ const { query: expr } = req.body;
92
+ const context = sourceManager.getContext();
93
+ try {
94
+ const result = query(context, expr);
95
+ return { ok: true, result };
96
+ } catch (err) {
97
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
98
+ }
99
+ });
100
+ }
@@ -0,0 +1,51 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { query } from '../../engine.js';
3
+ import type { EndpointConfig } from '../../config/schema.js';
4
+ import type { SourceManager } from '../sources/manager.js';
5
+ import { ResponseCache } from '../cache.js';
6
+ import { parseDuration } from '../../utils/parseDuration.js';
7
+
8
+ export function registerDynamicRoutes(
9
+ app: FastifyInstance,
10
+ sourceManager: SourceManager,
11
+ endpoints: Map<string, EndpointConfig>,
12
+ ) {
13
+ const cache = new ResponseCache();
14
+
15
+ // Use onRequest hook to intercept user-defined endpoints
16
+ // This avoids Fastify wildcard route conflicts with control routes
17
+ app.addHook('onRequest', async (req, reply) => {
18
+ const path = req.url.split('?')[0];
19
+
20
+ // Only handle /api/ paths that aren't control routes
21
+ if (!path.startsWith('/api/') || path.startsWith('/api/_control')) return;
22
+
23
+ const config = endpoints.get(path);
24
+ if (!config) return; // Let it fall through to 404
25
+
26
+ // Check cache
27
+ if (config.cache) {
28
+ const cached = cache.get(path);
29
+ if (cached !== undefined) {
30
+ reply.send(cached);
31
+ return;
32
+ }
33
+ }
34
+
35
+ // Execute query
36
+ const context = sourceManager.getContext();
37
+ try {
38
+ const result = query(context, config.query);
39
+
40
+ if (config.cache) {
41
+ cache.set(path, result, parseDuration(config.cache));
42
+ }
43
+
44
+ reply.send(result);
45
+ } catch (err) {
46
+ reply.status(500).send({
47
+ error: err instanceof Error ? err.message : String(err),
48
+ });
49
+ }
50
+ });
51
+ }
@@ -0,0 +1,16 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { SourceManager } from '../sources/manager.js';
3
+
4
+ export function registerHealthRoutes(app: FastifyInstance, sourceManager: SourceManager, startedAt: Date) {
5
+ app.get('/health', async () => {
6
+ return { status: 'ok', uptime: Date.now() - startedAt.getTime() };
7
+ });
8
+
9
+ app.get('/status', async () => {
10
+ return {
11
+ status: 'ok',
12
+ uptime: Date.now() - startedAt.getTime(),
13
+ sources: sourceManager.getAllStatuses(),
14
+ };
15
+ });
16
+ }
@@ -0,0 +1,96 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { query } from '../../engine.js';
3
+ import type { SourceManager } from '../sources/manager.js';
4
+
5
+ /**
6
+ * SSE endpoint for real-time dashboard updates.
7
+ *
8
+ * Clients connect to GET /api/_control/sse?queries=<json-encoded-array>
9
+ * and receive server-sent events whenever source data changes.
10
+ * Updates are throttled to max once per second to avoid overwhelming clients.
11
+ *
12
+ * Each event is a JSON object: { panelIndex: number, result: unknown }
13
+ */
14
+ export function registerSSERoutes(app: FastifyInstance, sourceManager: SourceManager) {
15
+ app.get('/api/_control/sse', async (req, reply) => {
16
+ const queriesParam = (req.query as Record<string, string>).queries;
17
+ if (!queriesParam) {
18
+ return reply.status(400).send({ error: 'queries parameter required (JSON array of strings)' });
19
+ }
20
+
21
+ let queries: string[];
22
+ try {
23
+ queries = JSON.parse(queriesParam);
24
+ if (!Array.isArray(queries)) throw new Error();
25
+ } catch {
26
+ return reply.status(400).send({ error: 'queries must be a JSON array of strings' });
27
+ }
28
+
29
+ // Set SSE headers
30
+ reply.raw.writeHead(200, {
31
+ 'Content-Type': 'text/event-stream',
32
+ 'Cache-Control': 'no-cache',
33
+ 'Connection': 'keep-alive',
34
+ 'Access-Control-Allow-Origin': '*',
35
+ });
36
+
37
+ // Send initial data for all panels
38
+ sendAllPanels(reply.raw, queries, sourceManager);
39
+
40
+ // Throttled update: max once per second
41
+ let pending = false;
42
+ let timer: ReturnType<typeof setTimeout> | null = null;
43
+
44
+ const throttledSend = () => {
45
+ if (timer) {
46
+ pending = true;
47
+ return;
48
+ }
49
+ sendAllPanels(reply.raw, queries, sourceManager);
50
+ timer = setTimeout(() => {
51
+ timer = null;
52
+ if (pending) {
53
+ pending = false;
54
+ throttledSend();
55
+ }
56
+ }, 1000);
57
+ };
58
+
59
+ // Subscribe to source updates
60
+ const unsubscribe = sourceManager.onSourceUpdate(throttledSend);
61
+
62
+ // Periodic heartbeat to keep connection alive
63
+ const heartbeat = setInterval(() => {
64
+ reply.raw.write(':heartbeat\n\n');
65
+ }, 15_000);
66
+
67
+ // Cleanup on close
68
+ req.raw.on('close', () => {
69
+ unsubscribe();
70
+ clearInterval(heartbeat);
71
+ if (timer) clearTimeout(timer);
72
+ });
73
+
74
+ // Don't let Fastify auto-close the response
75
+ await reply.hijack();
76
+ });
77
+ }
78
+
79
+ function sendAllPanels(
80
+ res: import('node:http').ServerResponse,
81
+ queries: string[],
82
+ sourceManager: SourceManager,
83
+ ) {
84
+ const context = sourceManager.getContext();
85
+
86
+ for (let i = 0; i < queries.length; i++) {
87
+ try {
88
+ const result = query(context, queries[i]);
89
+ const event = JSON.stringify({ panelIndex: i, result });
90
+ res.write(`data: ${event}\n\n`);
91
+ } catch (err) {
92
+ const event = JSON.stringify({ panelIndex: i, error: err instanceof Error ? err.message : String(err) });
93
+ res.write(`data: ${event}\n\n`);
94
+ }
95
+ }
96
+ }