@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.
- package/README.md +384 -0
- package/dist/index.js +4630 -0
- package/package.json +43 -0
- package/src/commands/completion.ts +167 -0
- package/src/commands/dashboard.ts +44 -0
- package/src/commands/endpoint.ts +99 -0
- package/src/commands/init.ts +20 -0
- package/src/commands/monitor.ts +52 -0
- package/src/commands/query.ts +81 -0
- package/src/commands/remote.ts +89 -0
- package/src/commands/repl.ts +222 -0
- package/src/commands/serve.ts +122 -0
- package/src/commands/source.ts +150 -0
- package/src/commands/stop.ts +50 -0
- package/src/config/defaults.ts +48 -0
- package/src/config/loader.ts +37 -0
- package/src/config/schema.ts +73 -0
- package/src/docker/templates.ts +43 -0
- package/src/engine.ts +5 -0
- package/src/index.ts +164 -0
- package/src/server/activity-log.ts +28 -0
- package/src/server/app.ts +101 -0
- package/src/server/cache.ts +30 -0
- package/src/server/routes/control.ts +100 -0
- package/src/server/routes/dynamic.ts +51 -0
- package/src/server/routes/health.ts +16 -0
- package/src/server/routes/sse.ts +96 -0
- package/src/server/sources/file.ts +79 -0
- package/src/server/sources/manager.ts +120 -0
- package/src/server/sources/rest.ts +85 -0
- package/src/server/sources/static.ts +31 -0
- package/src/server/sources/types.ts +15 -0
- package/src/server/sources/websocket.ts +86 -0
- package/src/tui/App.tsx +141 -0
- package/src/tui/MonitorApp.tsx +271 -0
- package/src/tui/components/BarChart.tsx +48 -0
- package/src/tui/components/CandleChart.tsx +133 -0
- package/src/tui/components/Heatmap.tsx +92 -0
- package/src/tui/components/Layout.tsx +42 -0
- package/src/tui/components/OrderBook.tsx +94 -0
- package/src/tui/components/Panel.tsx +35 -0
- package/src/tui/components/Sparkline.tsx +55 -0
- package/src/tui/components/StatBox.tsx +37 -0
- package/src/tui/components/StatusBar.tsx +51 -0
- package/src/tui/components/Table.tsx +133 -0
- package/src/tui/hooks/useBatchedData.ts +77 -0
- package/src/tui/hooks/useKeyboard.ts +23 -0
- package/src/tui/hooks/useLiveData.ts +161 -0
- package/src/tui/hooks/useMouse.ts +86 -0
- package/src/tui/hooks/useSSE.ts +95 -0
- package/src/tui/hooks/useServerData.ts +37 -0
- package/src/utils/banner.ts +70 -0
- package/src/utils/logger.ts +10 -0
- package/src/utils/parseDuration.ts +12 -0
- package/src/utils/server-discovery.ts +43 -0
- package/src/utils/sync-output.ts +62 -0
- package/src/utils/yaml.ts +12 -0
- package/tsconfig.json +17 -0
- 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
|
+
}
|