@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,79 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, resolve } from 'node:path';
|
|
3
|
+
import { watch } from 'chokidar';
|
|
4
|
+
import { parse as csvParse } from 'csv-parse/sync';
|
|
5
|
+
import type { FileSourceConfig } from '../../config/schema.js';
|
|
6
|
+
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
7
|
+
|
|
8
|
+
export class FileSourceAdapter implements SourceAdapter {
|
|
9
|
+
private data: unknown[] = [];
|
|
10
|
+
private watcher: ReturnType<typeof watch> | null = null;
|
|
11
|
+
private lastFetch: Date | null = null;
|
|
12
|
+
private error?: string;
|
|
13
|
+
private listeners = new Set<(data: unknown[]) => void>();
|
|
14
|
+
private resolvedPath: string;
|
|
15
|
+
|
|
16
|
+
constructor(private config: FileSourceConfig, private cwd: string) {
|
|
17
|
+
this.resolvedPath = resolve(cwd, config.path);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async start(): Promise<void> {
|
|
21
|
+
await this.load();
|
|
22
|
+
if (this.config.watch) {
|
|
23
|
+
this.watcher = watch(this.resolvedPath, { ignoreInitial: true });
|
|
24
|
+
this.watcher.on('change', () => this.load());
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
stop(): void {
|
|
29
|
+
this.watcher?.close();
|
|
30
|
+
this.watcher = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getData(): unknown[] {
|
|
34
|
+
return this.data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getStatus(): SourceStatus {
|
|
38
|
+
return {
|
|
39
|
+
healthy: !this.error,
|
|
40
|
+
rowCount: this.data.length,
|
|
41
|
+
lastFetch: this.lastFetch,
|
|
42
|
+
error: this.error,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
onUpdate(callback: (data: unknown[]) => void): () => void {
|
|
47
|
+
this.listeners.add(callback);
|
|
48
|
+
return () => this.listeners.delete(callback);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async load(): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
const content = await readFile(this.resolvedPath, 'utf-8');
|
|
54
|
+
const format = this.config.format ?? this.detectFormat();
|
|
55
|
+
|
|
56
|
+
if (format === 'csv') {
|
|
57
|
+
this.data = csvParse(content, { columns: true, skip_empty_lines: true, cast: true });
|
|
58
|
+
} else {
|
|
59
|
+
const parsed = JSON.parse(content);
|
|
60
|
+
this.data = Array.isArray(parsed) ? parsed : [parsed];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.lastFetch = new Date();
|
|
64
|
+
this.error = undefined;
|
|
65
|
+
this.notify();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private detectFormat(): 'json' | 'csv' {
|
|
72
|
+
const ext = extname(this.resolvedPath).toLowerCase();
|
|
73
|
+
return ext === '.csv' || ext === '.tsv' ? 'csv' : 'json';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private notify(): void {
|
|
77
|
+
for (const cb of this.listeners) cb(this.data);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { SourceConfig } from '../../config/schema.js';
|
|
2
|
+
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
3
|
+
import { RestSourceAdapter } from './rest.js';
|
|
4
|
+
import { WebSocketSourceAdapter } from './websocket.js';
|
|
5
|
+
import { FileSourceAdapter } from './file.js';
|
|
6
|
+
import { StaticSourceAdapter } from './static.js';
|
|
7
|
+
|
|
8
|
+
export type DataContext = Record<string, unknown[]>;
|
|
9
|
+
|
|
10
|
+
type UpdateListener = (sourceName: string, data: unknown[]) => void;
|
|
11
|
+
|
|
12
|
+
interface ManagedSource {
|
|
13
|
+
config: SourceConfig;
|
|
14
|
+
adapter: SourceAdapter;
|
|
15
|
+
unsubscribe?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class SourceManager {
|
|
19
|
+
private sources = new Map<string, ManagedSource>();
|
|
20
|
+
private globalListeners = new Set<UpdateListener>();
|
|
21
|
+
|
|
22
|
+
constructor(private cwd: string) {}
|
|
23
|
+
|
|
24
|
+
async addSource(name: string, config: SourceConfig): Promise<void> {
|
|
25
|
+
// Stop existing source with same name if present
|
|
26
|
+
if (this.sources.has(name)) {
|
|
27
|
+
this.removeSource(name);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const adapter = this.createAdapter(config);
|
|
31
|
+
const entry: ManagedSource = { config, adapter };
|
|
32
|
+
|
|
33
|
+
// Subscribe to updates and notify global listeners
|
|
34
|
+
entry.unsubscribe = adapter.onUpdate((data) => {
|
|
35
|
+
for (const listener of this.globalListeners) {
|
|
36
|
+
listener(name, data);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.sources.set(name, entry);
|
|
41
|
+
await adapter.start();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
removeSource(name: string): boolean {
|
|
45
|
+
const entry = this.sources.get(name);
|
|
46
|
+
if (!entry) return false;
|
|
47
|
+
entry.unsubscribe?.();
|
|
48
|
+
entry.adapter.stop();
|
|
49
|
+
this.sources.delete(name);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getContext(): DataContext {
|
|
54
|
+
const ctx: DataContext = {};
|
|
55
|
+
for (const [name, { adapter }] of this.sources) {
|
|
56
|
+
ctx[name] = adapter.getData();
|
|
57
|
+
}
|
|
58
|
+
return ctx;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getSourceData(name: string): unknown[] | undefined {
|
|
62
|
+
return this.sources.get(name)?.adapter.getData();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getSourceStatus(name: string): SourceStatus | undefined {
|
|
66
|
+
return this.sources.get(name)?.adapter.getStatus();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getAllStatuses(): Record<string, SourceStatus> {
|
|
70
|
+
const result: Record<string, SourceStatus> = {};
|
|
71
|
+
for (const [name, { adapter }] of this.sources) {
|
|
72
|
+
result[name] = adapter.getStatus();
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getSourceNames(): string[] {
|
|
78
|
+
return [...this.sources.keys()];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Subscribe to data updates from any source.
|
|
83
|
+
* Used by SSE endpoint for real-time push to TUI clients.
|
|
84
|
+
* Returns unsubscribe function.
|
|
85
|
+
*/
|
|
86
|
+
onSourceUpdate(callback: UpdateListener): () => void {
|
|
87
|
+
this.globalListeners.add(callback);
|
|
88
|
+
return () => this.globalListeners.delete(callback);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async refreshAll(): Promise<void> {
|
|
92
|
+
await Promise.all(
|
|
93
|
+
[...this.sources.values()].map(({ adapter }) => adapter.refresh?.() ?? Promise.resolve()),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async dispose(): Promise<void> {
|
|
98
|
+
this.globalListeners.clear();
|
|
99
|
+
for (const entry of this.sources.values()) {
|
|
100
|
+
entry.unsubscribe?.();
|
|
101
|
+
entry.adapter.stop();
|
|
102
|
+
}
|
|
103
|
+
this.sources.clear();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private createAdapter(config: SourceConfig): SourceAdapter {
|
|
107
|
+
switch (config.type) {
|
|
108
|
+
case 'rest':
|
|
109
|
+
return new RestSourceAdapter(config);
|
|
110
|
+
case 'websocket':
|
|
111
|
+
return new WebSocketSourceAdapter(config);
|
|
112
|
+
case 'file':
|
|
113
|
+
return new FileSourceAdapter(config, this.cwd);
|
|
114
|
+
case 'static':
|
|
115
|
+
return new StaticSourceAdapter(config);
|
|
116
|
+
default:
|
|
117
|
+
throw new Error(`Unknown source type: ${(config as { type: string }).type}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { RestSourceConfig } from '../../config/schema.js';
|
|
2
|
+
import { parseDuration } from '../../utils/parseDuration.js';
|
|
3
|
+
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
4
|
+
|
|
5
|
+
function extractPath(obj: unknown, path: string): unknown[] {
|
|
6
|
+
let current: unknown = obj;
|
|
7
|
+
for (const key of path.split('.')) {
|
|
8
|
+
if (current == null || typeof current !== 'object') return [];
|
|
9
|
+
current = (current as Record<string, unknown>)[key];
|
|
10
|
+
}
|
|
11
|
+
return Array.isArray(current) ? current : [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class RestSourceAdapter implements SourceAdapter {
|
|
15
|
+
private data: unknown[] = [];
|
|
16
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
17
|
+
private lastFetch: Date | null = null;
|
|
18
|
+
private error?: string;
|
|
19
|
+
private listeners = new Set<(data: unknown[]) => void>();
|
|
20
|
+
|
|
21
|
+
constructor(private config: RestSourceConfig) {}
|
|
22
|
+
|
|
23
|
+
async start(): Promise<void> {
|
|
24
|
+
await this.fetch();
|
|
25
|
+
const interval = this.config.interval ? parseDuration(this.config.interval) : 30_000;
|
|
26
|
+
this.timer = setInterval(() => this.fetch(), interval);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
stop(): void {
|
|
30
|
+
if (this.timer) {
|
|
31
|
+
clearInterval(this.timer);
|
|
32
|
+
this.timer = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getData(): unknown[] {
|
|
37
|
+
return this.data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getStatus(): SourceStatus {
|
|
41
|
+
return {
|
|
42
|
+
healthy: !this.error,
|
|
43
|
+
rowCount: this.data.length,
|
|
44
|
+
lastFetch: this.lastFetch,
|
|
45
|
+
error: this.error,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onUpdate(callback: (data: unknown[]) => void): () => void {
|
|
50
|
+
this.listeners.add(callback);
|
|
51
|
+
return () => this.listeners.delete(callback);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async refresh(): Promise<void> {
|
|
55
|
+
await this.fetch();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async fetch(): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
let url = this.config.url;
|
|
61
|
+
if (this.config.params) {
|
|
62
|
+
const params = new URLSearchParams(this.config.params);
|
|
63
|
+
url += (url.includes('?') ? '&' : '?') + params.toString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const res = await globalThis.fetch(url, {
|
|
67
|
+
headers: this.config.headers,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
71
|
+
|
|
72
|
+
const json = await res.json();
|
|
73
|
+
this.data = this.config.dataPath ? extractPath(json, this.config.dataPath) : (Array.isArray(json) ? json : [json]);
|
|
74
|
+
this.lastFetch = new Date();
|
|
75
|
+
this.error = undefined;
|
|
76
|
+
this.notify();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private notify(): void {
|
|
83
|
+
for (const cb of this.listeners) cb(this.data);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { StaticSourceConfig } from '../../config/schema.js';
|
|
2
|
+
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
3
|
+
|
|
4
|
+
export class StaticSourceAdapter implements SourceAdapter {
|
|
5
|
+
private listeners = new Set<(data: unknown[]) => void>();
|
|
6
|
+
|
|
7
|
+
constructor(private config: StaticSourceConfig) {}
|
|
8
|
+
|
|
9
|
+
async start(): Promise<void> {
|
|
10
|
+
// Static data is immediately available
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
stop(): void {}
|
|
14
|
+
|
|
15
|
+
getData(): unknown[] {
|
|
16
|
+
return this.config.data;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getStatus(): SourceStatus {
|
|
20
|
+
return {
|
|
21
|
+
healthy: true,
|
|
22
|
+
rowCount: this.config.data.length,
|
|
23
|
+
lastFetch: new Date(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
onUpdate(callback: (data: unknown[]) => void): () => void {
|
|
28
|
+
this.listeners.add(callback);
|
|
29
|
+
return () => this.listeners.delete(callback);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SourceStatus {
|
|
2
|
+
healthy: boolean;
|
|
3
|
+
rowCount: number;
|
|
4
|
+
lastFetch: Date | null;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SourceAdapter {
|
|
9
|
+
start(): Promise<void>;
|
|
10
|
+
stop(): void;
|
|
11
|
+
getData(): unknown[];
|
|
12
|
+
getStatus(): SourceStatus;
|
|
13
|
+
onUpdate(callback: (data: unknown[]) => void): () => void;
|
|
14
|
+
refresh?(): Promise<void>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import type { WebSocketSourceConfig } from '../../config/schema.js';
|
|
3
|
+
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
4
|
+
|
|
5
|
+
export class WebSocketSourceAdapter implements SourceAdapter {
|
|
6
|
+
private data: unknown[] = [];
|
|
7
|
+
private ws: WebSocket | null = null;
|
|
8
|
+
private lastFetch: Date | null = null;
|
|
9
|
+
private error?: string;
|
|
10
|
+
private listeners = new Set<(data: unknown[]) => void>();
|
|
11
|
+
private maxBuffer: number;
|
|
12
|
+
|
|
13
|
+
constructor(private config: WebSocketSourceConfig) {
|
|
14
|
+
this.maxBuffer = config.maxBuffer ?? 1000;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async start(): Promise<void> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
this.ws = new WebSocket(this.config.url);
|
|
20
|
+
|
|
21
|
+
this.ws.on('open', () => {
|
|
22
|
+
this.error = undefined;
|
|
23
|
+
resolve();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
this.ws.on('message', (raw) => {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(raw.toString());
|
|
29
|
+
if (Array.isArray(parsed)) {
|
|
30
|
+
this.data = parsed;
|
|
31
|
+
} else {
|
|
32
|
+
this.data.push(parsed);
|
|
33
|
+
if (this.data.length > this.maxBuffer) {
|
|
34
|
+
this.data = this.data.slice(-this.maxBuffer);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.lastFetch = new Date();
|
|
38
|
+
this.error = undefined;
|
|
39
|
+
this.notify();
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore non-JSON messages
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.ws.on('error', (err) => {
|
|
46
|
+
this.error = err.message;
|
|
47
|
+
reject(err);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.ws.on('close', () => {
|
|
51
|
+
// Auto-reconnect after 5s
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
if (this.ws) this.start().catch(() => {});
|
|
54
|
+
}, 5000);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
stop(): void {
|
|
60
|
+
const ws = this.ws;
|
|
61
|
+
this.ws = null;
|
|
62
|
+
ws?.close();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getData(): unknown[] {
|
|
66
|
+
return this.data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getStatus(): SourceStatus {
|
|
70
|
+
return {
|
|
71
|
+
healthy: this.ws?.readyState === WebSocket.OPEN,
|
|
72
|
+
rowCount: this.data.length,
|
|
73
|
+
lastFetch: this.lastFetch,
|
|
74
|
+
error: this.error,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onUpdate(callback: (data: unknown[]) => void): () => void {
|
|
79
|
+
this.listeners.add(callback);
|
|
80
|
+
return () => this.listeners.delete(callback);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private notify(): void {
|
|
84
|
+
for (const cb of this.listeners) cb(this.data);
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/tui/App.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import React, { useState, useMemo, useCallback } from 'react';
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import type { DashboardConfig, PanelConfig } from '../config/schema.js';
|
|
4
|
+
import { parseDuration } from '../utils/parseDuration.js';
|
|
5
|
+
import { useLiveData } from './hooks/useLiveData.js';
|
|
6
|
+
import { useKeyboard } from './hooks/useKeyboard.js';
|
|
7
|
+
import { Panel } from './components/Panel.js';
|
|
8
|
+
import { Table } from './components/Table.js';
|
|
9
|
+
import { BarChart } from './components/BarChart.js';
|
|
10
|
+
import { StatBox } from './components/StatBox.js';
|
|
11
|
+
import { Sparkline } from './components/Sparkline.js';
|
|
12
|
+
import { OrderBook } from './components/OrderBook.js';
|
|
13
|
+
import { Heatmap } from './components/Heatmap.js';
|
|
14
|
+
import { CandleChart } from './components/CandleChart.js';
|
|
15
|
+
import { Layout } from './components/Layout.js';
|
|
16
|
+
import { StatusBar } from './components/StatusBar.js';
|
|
17
|
+
|
|
18
|
+
interface AppProps {
|
|
19
|
+
serverUrl: string;
|
|
20
|
+
dashboard: DashboardConfig;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MemoizedPanelContent = React.memo(function PanelContent({ data, error, viz, focused, loading, panelKey, maxTableRows }: {
|
|
24
|
+
data: unknown;
|
|
25
|
+
error?: string;
|
|
26
|
+
viz: string;
|
|
27
|
+
focused: boolean;
|
|
28
|
+
loading: boolean;
|
|
29
|
+
panelKey: string;
|
|
30
|
+
maxTableRows: number;
|
|
31
|
+
}) {
|
|
32
|
+
if (error) return <Text color="red">{error}</Text>;
|
|
33
|
+
if (loading || data == null) return <Text dimColor>Loading...</Text>;
|
|
34
|
+
|
|
35
|
+
const rows = Array.isArray(data) ? data as Record<string, unknown>[] : [];
|
|
36
|
+
|
|
37
|
+
switch (viz) {
|
|
38
|
+
case 'bar':
|
|
39
|
+
return <BarChart data={rows} />;
|
|
40
|
+
case 'stat':
|
|
41
|
+
return <StatBox data={data} />;
|
|
42
|
+
case 'sparkline':
|
|
43
|
+
return <Sparkline data={rows} />;
|
|
44
|
+
case 'orderbook':
|
|
45
|
+
return <OrderBook data={data} />;
|
|
46
|
+
case 'heatmap':
|
|
47
|
+
return <Heatmap data={rows} />;
|
|
48
|
+
case 'candle':
|
|
49
|
+
return <CandleChart data={Array.isArray(data) ? data : []} />;
|
|
50
|
+
case 'table':
|
|
51
|
+
return <Table data={rows} focused={focused} maxRows={maxTableRows} />;
|
|
52
|
+
case 'auto':
|
|
53
|
+
default:
|
|
54
|
+
if (!Array.isArray(data) || rows.length === 0) return <StatBox data={data} />;
|
|
55
|
+
return <Table data={rows} focused={focused} maxRows={maxTableRows} />;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export function App({ serverUrl, dashboard }: AppProps) {
|
|
60
|
+
const [focusedPanel, setFocusedPanel] = useState(0);
|
|
61
|
+
const { stdout } = useStdout();
|
|
62
|
+
const termHeight = stdout?.rows ?? 40;
|
|
63
|
+
const panels = dashboard.panels;
|
|
64
|
+
const refreshMs = dashboard.refresh ? parseDuration(dashboard.refresh) : 10_000;
|
|
65
|
+
|
|
66
|
+
// Column weights: each panel gets a width percentage (default 50% each)
|
|
67
|
+
const [colWeights, setColWeights] = useState<number[]>(() =>
|
|
68
|
+
panels.map(() => 50),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const fixedOverhead = 3; // header + status bar
|
|
72
|
+
const numPanelRows = Math.ceil(panels.length / 2);
|
|
73
|
+
const rowHeight = Math.max(8, Math.floor((termHeight - fixedOverhead) / numPanelRows));
|
|
74
|
+
const panelOverhead = 5; // border + title + padding
|
|
75
|
+
const maxTableRows = Math.max(3, rowHeight - panelOverhead);
|
|
76
|
+
|
|
77
|
+
const queries = useMemo(() => panels.map((p) => p.query), [panels]);
|
|
78
|
+
// Use SSE for real-time push, with polling fallback
|
|
79
|
+
const { results, loading, connected, refresh } = useLiveData(serverUrl, queries, refreshMs);
|
|
80
|
+
|
|
81
|
+
// Resize handler: grow/shrink focused panel, adjust its row neighbor
|
|
82
|
+
const handleResize = useCallback((direction: 'grow' | 'shrink') => {
|
|
83
|
+
setColWeights(prev => {
|
|
84
|
+
const next = [...prev];
|
|
85
|
+
const rowStart = Math.floor(focusedPanel / 2) * 2;
|
|
86
|
+
const partner = focusedPanel % 2 === 0 ? focusedPanel + 1 : focusedPanel - 1;
|
|
87
|
+
if (partner < 0 || partner >= panels.length) return prev;
|
|
88
|
+
if (partner < rowStart || partner >= rowStart + 2) return prev;
|
|
89
|
+
const delta = direction === 'grow' ? 5 : -5;
|
|
90
|
+
const newVal = Math.min(75, Math.max(25, next[focusedPanel] + delta));
|
|
91
|
+
next[focusedPanel] = newVal;
|
|
92
|
+
next[partner] = 100 - newVal;
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
}, [focusedPanel, panels.length]);
|
|
96
|
+
|
|
97
|
+
useKeyboard({
|
|
98
|
+
panelCount: panels.length,
|
|
99
|
+
focusedPanel,
|
|
100
|
+
setFocusedPanel,
|
|
101
|
+
onRefresh: refresh,
|
|
102
|
+
onResize: handleResize,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const renderPanel = useCallback((panel: PanelConfig, index: number, focused: boolean) => {
|
|
106
|
+
const result = results.get(panel.query);
|
|
107
|
+
return (
|
|
108
|
+
<Panel title={panel.title} focused={focused}>
|
|
109
|
+
<MemoizedPanelContent
|
|
110
|
+
panelKey={`panel-${index}`}
|
|
111
|
+
data={result?.data ?? null}
|
|
112
|
+
error={result?.error}
|
|
113
|
+
viz={panel.viz}
|
|
114
|
+
focused={focused}
|
|
115
|
+
loading={loading && !result}
|
|
116
|
+
maxTableRows={maxTableRows}
|
|
117
|
+
/>
|
|
118
|
+
</Panel>
|
|
119
|
+
);
|
|
120
|
+
}, [results, loading, maxTableRows]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Box flexDirection="column" height={termHeight}>
|
|
124
|
+
<Box justifyContent="center" paddingX={1}>
|
|
125
|
+
<Text bold color="cyan">PipeQuery Dashboard</Text>
|
|
126
|
+
{connected && <Text color="green"> ● LIVE</Text>}
|
|
127
|
+
{!connected && !loading && <Text color="yellow"> ○ polling</Text>}
|
|
128
|
+
</Box>
|
|
129
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
130
|
+
<Layout
|
|
131
|
+
panels={panels}
|
|
132
|
+
focusedIndex={focusedPanel}
|
|
133
|
+
colWeights={colWeights}
|
|
134
|
+
rowHeight={rowHeight}
|
|
135
|
+
renderPanel={renderPanel}
|
|
136
|
+
/>
|
|
137
|
+
</Box>
|
|
138
|
+
<StatusBar serverUrl={serverUrl} refreshMs={refreshMs} />
|
|
139
|
+
</Box>
|
|
140
|
+
);
|
|
141
|
+
}
|