@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,271 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
|
|
4
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface SourceStatus {
|
|
7
|
+
healthy: boolean;
|
|
8
|
+
rowCount: number;
|
|
9
|
+
lastFetch: string | null;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ServerInfo {
|
|
14
|
+
status: string;
|
|
15
|
+
uptime: number;
|
|
16
|
+
sources: Record<string, SourceStatus>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface EndpointInfo {
|
|
20
|
+
query: string;
|
|
21
|
+
cache?: string;
|
|
22
|
+
method?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ActivityEntry {
|
|
26
|
+
time: string;
|
|
27
|
+
level: 'info' | 'warn' | 'error';
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Hooks ──────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function useServerStatus(serverUrl: string, refreshMs: number) {
|
|
34
|
+
const [info, setInfo] = useState<ServerInfo | null>(null);
|
|
35
|
+
const [connected, setConnected] = useState(false);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const fetchStatus = async () => {
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${serverUrl}/status`);
|
|
41
|
+
const data = await res.json() as ServerInfo;
|
|
42
|
+
setInfo(data);
|
|
43
|
+
setConnected(true);
|
|
44
|
+
} catch {
|
|
45
|
+
setInfo(null);
|
|
46
|
+
setConnected(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
fetchStatus();
|
|
50
|
+
const timer = setInterval(fetchStatus, refreshMs);
|
|
51
|
+
return () => clearInterval(timer);
|
|
52
|
+
}, [serverUrl, refreshMs]);
|
|
53
|
+
|
|
54
|
+
return { info, connected };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function useEndpoints(serverUrl: string, refreshMs: number) {
|
|
58
|
+
const [endpoints, setEndpoints] = useState<Record<string, EndpointInfo>>({});
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const fetchEndpoints = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`${serverUrl}/api/_control/endpoints`);
|
|
64
|
+
const data = await res.json() as Record<string, EndpointInfo>;
|
|
65
|
+
setEndpoints(data);
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
fetchEndpoints();
|
|
71
|
+
const timer = setInterval(fetchEndpoints, refreshMs);
|
|
72
|
+
return () => clearInterval(timer);
|
|
73
|
+
}, [serverUrl, refreshMs]);
|
|
74
|
+
|
|
75
|
+
return endpoints;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function useActivityLog(serverUrl: string, refreshMs: number, maxLines: number) {
|
|
79
|
+
const [entries, setEntries] = useState<ActivityEntry[]>([]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const fetchActivity = async () => {
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(`${serverUrl}/api/_control/activity`);
|
|
85
|
+
const data = await res.json() as ActivityEntry[];
|
|
86
|
+
setEntries(data.slice(-maxLines));
|
|
87
|
+
} catch {
|
|
88
|
+
// ignore
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
fetchActivity();
|
|
92
|
+
const timer = setInterval(fetchActivity, refreshMs);
|
|
93
|
+
return () => clearInterval(timer);
|
|
94
|
+
}, [serverUrl, refreshMs, maxLines]);
|
|
95
|
+
|
|
96
|
+
return entries;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Components ─────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function Header({ connected, uptime }: { connected: boolean; uptime: number }) {
|
|
102
|
+
return (
|
|
103
|
+
<Box paddingX={1} justifyContent="space-between">
|
|
104
|
+
<Box gap={1}>
|
|
105
|
+
<Text bold color="#06b6d4">⬡</Text>
|
|
106
|
+
<Text bold color="#06b6d4">PipeQuery Monitor</Text>
|
|
107
|
+
</Box>
|
|
108
|
+
<Box gap={2}>
|
|
109
|
+
<Text color={connected ? '#22c55e' : '#ef4444'}>
|
|
110
|
+
{connected ? '● connected' : '● disconnected'}
|
|
111
|
+
</Text>
|
|
112
|
+
<Text dimColor>uptime: {formatUptime(uptime)}</Text>
|
|
113
|
+
</Box>
|
|
114
|
+
</Box>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function SourcesPanel({ sources }: { sources: Record<string, SourceStatus> }) {
|
|
119
|
+
const entries = Object.entries(sources);
|
|
120
|
+
return (
|
|
121
|
+
<Box flexDirection="column" borderStyle="round" borderColor="#4f46e5" paddingX={1}>
|
|
122
|
+
<Box marginBottom={0}>
|
|
123
|
+
<Text bold color="#4f46e5"> Sources ({entries.length}) </Text>
|
|
124
|
+
</Box>
|
|
125
|
+
{entries.length === 0 ? (
|
|
126
|
+
<Text dimColor> No sources configured</Text>
|
|
127
|
+
) : (
|
|
128
|
+
<>
|
|
129
|
+
<Box>
|
|
130
|
+
<Box width={20}><Text dimColor bold>NAME</Text></Box>
|
|
131
|
+
<Box width={10}><Text dimColor bold>STATUS</Text></Box>
|
|
132
|
+
<Box width={10}><Text dimColor bold>ROWS</Text></Box>
|
|
133
|
+
<Box width={22}><Text dimColor bold>LAST FETCH</Text></Box>
|
|
134
|
+
<Box><Text dimColor bold>ERROR</Text></Box>
|
|
135
|
+
</Box>
|
|
136
|
+
{entries.map(([name, status]) => (
|
|
137
|
+
<Box key={name}>
|
|
138
|
+
<Box width={20}><Text color="#06b6d4">{name}</Text></Box>
|
|
139
|
+
<Box width={10}>
|
|
140
|
+
<Text color={status.healthy ? '#22c55e' : '#ef4444'}>
|
|
141
|
+
{status.healthy ? '✓ ok' : '✗ err'}
|
|
142
|
+
</Text>
|
|
143
|
+
</Box>
|
|
144
|
+
<Box width={10}><Text>{String(status.rowCount)}</Text></Box>
|
|
145
|
+
<Box width={22}>
|
|
146
|
+
<Text dimColor>
|
|
147
|
+
{status.lastFetch ? new Date(status.lastFetch).toLocaleTimeString() : '—'}
|
|
148
|
+
</Text>
|
|
149
|
+
</Box>
|
|
150
|
+
<Box><Text color="#ef4444">{status.error ?? ''}</Text></Box>
|
|
151
|
+
</Box>
|
|
152
|
+
))}
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
</Box>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function EndpointsPanel({ endpoints, serverUrl }: { endpoints: Record<string, EndpointInfo>; serverUrl: string }) {
|
|
160
|
+
const entries = Object.entries(endpoints);
|
|
161
|
+
return (
|
|
162
|
+
<Box flexDirection="column" borderStyle="round" borderColor="#7c3aed" paddingX={1}>
|
|
163
|
+
<Box marginBottom={0}>
|
|
164
|
+
<Text bold color="#7c3aed"> Endpoints ({entries.length}) </Text>
|
|
165
|
+
</Box>
|
|
166
|
+
{entries.length === 0 ? (
|
|
167
|
+
<Text dimColor> No endpoints registered</Text>
|
|
168
|
+
) : (
|
|
169
|
+
<>
|
|
170
|
+
<Box>
|
|
171
|
+
<Box width={30}><Text dimColor bold>PATH</Text></Box>
|
|
172
|
+
<Box width={10}><Text dimColor bold>CACHE</Text></Box>
|
|
173
|
+
<Box><Text dimColor bold>QUERY</Text></Box>
|
|
174
|
+
</Box>
|
|
175
|
+
{entries.map(([path, config]) => (
|
|
176
|
+
<Box key={path}>
|
|
177
|
+
<Box width={30}><Text color="#06b6d4">{serverUrl}{path}</Text></Box>
|
|
178
|
+
<Box width={10}><Text dimColor>{config.cache ?? '—'}</Text></Box>
|
|
179
|
+
<Box><Text>{config.query}</Text></Box>
|
|
180
|
+
</Box>
|
|
181
|
+
))}
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
</Box>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function ActivityPanel({ entries }: { entries: ActivityEntry[] }) {
|
|
189
|
+
return (
|
|
190
|
+
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
|
191
|
+
<Box marginBottom={0}>
|
|
192
|
+
<Text bold color="gray"> Activity Log </Text>
|
|
193
|
+
</Box>
|
|
194
|
+
{entries.length === 0 ? (
|
|
195
|
+
<Text dimColor> No activity yet — run queries, add sources, or hit endpoints</Text>
|
|
196
|
+
) : (
|
|
197
|
+
entries.map((entry, i) => {
|
|
198
|
+
const time = new Date(entry.time).toLocaleTimeString();
|
|
199
|
+
const levelColor = entry.level === 'error' ? '#ef4444' : entry.level === 'warn' ? '#f59e0b' : '#22c55e';
|
|
200
|
+
const levelIcon = entry.level === 'error' ? '✗' : entry.level === 'warn' ? '⚠' : '→';
|
|
201
|
+
return (
|
|
202
|
+
<Box key={i} gap={1}>
|
|
203
|
+
<Text dimColor>{time}</Text>
|
|
204
|
+
<Text color={levelColor}>{levelIcon}</Text>
|
|
205
|
+
<Text color={entry.level === 'error' ? '#ef4444' : undefined}>
|
|
206
|
+
{entry.message}
|
|
207
|
+
</Text>
|
|
208
|
+
</Box>
|
|
209
|
+
);
|
|
210
|
+
})
|
|
211
|
+
)}
|
|
212
|
+
</Box>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function HelpBar() {
|
|
217
|
+
return (
|
|
218
|
+
<Box paddingX={1} justifyContent="center" gap={3}>
|
|
219
|
+
<Text dimColor>q: quit</Text>
|
|
220
|
+
<Text dimColor>r: refresh</Text>
|
|
221
|
+
<Text dimColor>auto-refresh: 2s</Text>
|
|
222
|
+
</Box>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
function formatUptime(ms: number): string {
|
|
229
|
+
const s = Math.floor(ms / 1000);
|
|
230
|
+
if (s < 60) return `${s}s`;
|
|
231
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
232
|
+
const h = Math.floor(s / 3600);
|
|
233
|
+
const m = Math.floor((s % 3600) / 60);
|
|
234
|
+
return `${h}h ${m}m`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Main App ───────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
interface MonitorAppProps {
|
|
240
|
+
serverUrl: string;
|
|
241
|
+
logPath?: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function MonitorApp({ serverUrl }: MonitorAppProps) {
|
|
245
|
+
const { exit } = useApp();
|
|
246
|
+
const { info, connected } = useServerStatus(serverUrl, 2000);
|
|
247
|
+
const endpoints = useEndpoints(serverUrl, 5000);
|
|
248
|
+
const activityEntries = useActivityLog(serverUrl, 2000, 15);
|
|
249
|
+
const [, setTick] = useState(0);
|
|
250
|
+
|
|
251
|
+
useInput((input) => {
|
|
252
|
+
if (input === 'q') exit();
|
|
253
|
+
if (input === 'r') setTick((t) => t + 1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const sources = info?.sources ?? {};
|
|
257
|
+
const uptime = info?.uptime ?? 0;
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<Box flexDirection="column" padding={0}>
|
|
261
|
+
<Header connected={connected} uptime={uptime} />
|
|
262
|
+
<Box height={1} />
|
|
263
|
+
<SourcesPanel sources={sources} />
|
|
264
|
+
<Box height={1} />
|
|
265
|
+
<EndpointsPanel endpoints={endpoints} serverUrl={serverUrl} />
|
|
266
|
+
<Box height={1} />
|
|
267
|
+
<ActivityPanel entries={activityEntries} />
|
|
268
|
+
<HelpBar />
|
|
269
|
+
</Box>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
interface BarChartProps {
|
|
5
|
+
data: Record<string, unknown>[];
|
|
6
|
+
maxBars?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const BLOCK = '█';
|
|
10
|
+
const MAX_BAR_WIDTH = 40;
|
|
11
|
+
|
|
12
|
+
export function BarChart({ data, maxBars = 10 }: BarChartProps) {
|
|
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
|
+
const items = data.slice(0, maxBars).map((row) => ({
|
|
20
|
+
label: String(row[labelKey] ?? ''),
|
|
21
|
+
value: Number(row[valueKey]) || 0,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const maxValue = Math.max(...items.map((i) => i.value), 1);
|
|
25
|
+
const maxLabelLen = Math.max(...items.map((i) => i.label.length), 1);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Box flexDirection="column">
|
|
29
|
+
{items.map((item, i) => {
|
|
30
|
+
const barLen = Math.round((item.value / maxValue) * MAX_BAR_WIDTH);
|
|
31
|
+
return (
|
|
32
|
+
<Box key={i}>
|
|
33
|
+
<Text>{item.label.padEnd(maxLabelLen)} </Text>
|
|
34
|
+
<Text color="cyan">{BLOCK.repeat(Math.max(1, barLen))}</Text>
|
|
35
|
+
<Text dimColor> {formatNum(item.value)}</Text>
|
|
36
|
+
</Box>
|
|
37
|
+
);
|
|
38
|
+
})}
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatNum(n: number): string {
|
|
44
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
|
45
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
46
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
47
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(2);
|
|
48
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
interface CandleChartProps {
|
|
5
|
+
data: unknown[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface Candle {
|
|
9
|
+
open: number;
|
|
10
|
+
high: number;
|
|
11
|
+
low: number;
|
|
12
|
+
close: number;
|
|
13
|
+
time: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CHART_HEIGHT = 14;
|
|
17
|
+
|
|
18
|
+
export function CandleChart({ data }: CandleChartProps) {
|
|
19
|
+
if (!data.length) return <Text dimColor>Waiting for candle data...</Text>;
|
|
20
|
+
|
|
21
|
+
// Parse kline events: { k: { o, h, l, c, t, ... } }
|
|
22
|
+
const candles: Candle[] = data
|
|
23
|
+
.map((item) => {
|
|
24
|
+
const k = (item as Record<string, unknown>).k as Record<string, unknown> | undefined;
|
|
25
|
+
if (!k) return null;
|
|
26
|
+
return {
|
|
27
|
+
open: parseFloat(k.o as string),
|
|
28
|
+
high: parseFloat(k.h as string),
|
|
29
|
+
low: parseFloat(k.l as string),
|
|
30
|
+
close: parseFloat(k.c as string),
|
|
31
|
+
time: k.t as number,
|
|
32
|
+
};
|
|
33
|
+
})
|
|
34
|
+
.filter((c): c is Candle => c !== null);
|
|
35
|
+
|
|
36
|
+
// Deduplicate by time (keep latest for each candle)
|
|
37
|
+
const byTime = new Map<number, Candle>();
|
|
38
|
+
for (const c of candles) byTime.set(c.time, c);
|
|
39
|
+
const unique = [...byTime.values()].slice(-20); // last 20 candles
|
|
40
|
+
|
|
41
|
+
if (unique.length === 0) return <Text dimColor>Waiting for candle data...</Text>;
|
|
42
|
+
|
|
43
|
+
const allHigh = Math.max(...unique.map((c) => c.high));
|
|
44
|
+
const allLow = Math.min(...unique.map((c) => c.low));
|
|
45
|
+
const priceRange = allHigh - allLow || 1;
|
|
46
|
+
|
|
47
|
+
// Map price to row (0 = top = highest price)
|
|
48
|
+
const toRow = (price: number) =>
|
|
49
|
+
Math.min(CHART_HEIGHT - 1, Math.max(0, Math.round(((allHigh - price) / priceRange) * (CHART_HEIGHT - 1))));
|
|
50
|
+
|
|
51
|
+
// Build output rows
|
|
52
|
+
const priceLabels = Array.from({ length: CHART_HEIGHT }, (_, row) => {
|
|
53
|
+
const price = allHigh - (row / (CHART_HEIGHT - 1)) * priceRange;
|
|
54
|
+
return formatPrice(price);
|
|
55
|
+
});
|
|
56
|
+
const labelWidth = Math.max(...priceLabels.map((l) => l.length));
|
|
57
|
+
|
|
58
|
+
// Render each row
|
|
59
|
+
const rows: React.ReactNode[] = [];
|
|
60
|
+
for (let row = 0; row < CHART_HEIGHT; row++) {
|
|
61
|
+
const cells: React.ReactNode[] = [];
|
|
62
|
+
|
|
63
|
+
for (let col = 0; col < unique.length; col++) {
|
|
64
|
+
const c = unique[col];
|
|
65
|
+
const bullish = c.close >= c.open;
|
|
66
|
+
const color = bullish ? 'green' : 'red';
|
|
67
|
+
const bodyTop = toRow(Math.max(c.open, c.close));
|
|
68
|
+
const bodyBot = toRow(Math.min(c.open, c.close));
|
|
69
|
+
const wickTop = toRow(c.high);
|
|
70
|
+
const wickBot = toRow(c.low);
|
|
71
|
+
|
|
72
|
+
let char: string;
|
|
73
|
+
let cellColor: string;
|
|
74
|
+
|
|
75
|
+
if (row >= bodyTop && row <= bodyBot) {
|
|
76
|
+
// Body — use wide block
|
|
77
|
+
char = '███';
|
|
78
|
+
cellColor = color;
|
|
79
|
+
} else if (row >= wickTop && row <= wickBot) {
|
|
80
|
+
// Wick
|
|
81
|
+
char = ' │ ';
|
|
82
|
+
cellColor = color;
|
|
83
|
+
} else {
|
|
84
|
+
char = ' ';
|
|
85
|
+
cellColor = 'white';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cells.push(
|
|
89
|
+
<Text key={col} color={cellColor as 'green' | 'red' | 'white'}>
|
|
90
|
+
{char}
|
|
91
|
+
</Text>,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
rows.push(
|
|
96
|
+
<Box key={row}>
|
|
97
|
+
<Text dimColor>{priceLabels[row].padStart(labelWidth)} </Text>
|
|
98
|
+
<Text dimColor>│</Text>
|
|
99
|
+
{cells}
|
|
100
|
+
</Box>,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Time axis
|
|
105
|
+
const latest = unique[unique.length - 1];
|
|
106
|
+
const oldest = unique[0];
|
|
107
|
+
const fmtTime = (t: number) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
108
|
+
const axisWidth = unique.length * 3;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Box flexDirection="column">
|
|
112
|
+
{rows}
|
|
113
|
+
<Box>
|
|
114
|
+
<Text dimColor>
|
|
115
|
+
{' '.repeat(labelWidth + 1)}└{'─'.repeat(axisWidth)}
|
|
116
|
+
</Text>
|
|
117
|
+
</Box>
|
|
118
|
+
<Box>
|
|
119
|
+
<Text dimColor>
|
|
120
|
+
{' '.repeat(labelWidth + 2)}
|
|
121
|
+
{fmtTime(oldest.time).padEnd(axisWidth - fmtTime(latest.time).length)}
|
|
122
|
+
{fmtTime(latest.time)}
|
|
123
|
+
</Text>
|
|
124
|
+
</Box>
|
|
125
|
+
</Box>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatPrice(n: number): string {
|
|
130
|
+
if (n >= 1000) return n.toFixed(0);
|
|
131
|
+
if (n >= 1) return n.toFixed(2);
|
|
132
|
+
return n.toFixed(4);
|
|
133
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
interface HeatmapProps {
|
|
5
|
+
data: Record<string, unknown>[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Heatmap({ data }: HeatmapProps) {
|
|
9
|
+
if (!data.length) return <Text dimColor>(empty)</Text>;
|
|
10
|
+
|
|
11
|
+
const keys = Object.keys(data[0]);
|
|
12
|
+
const labelKey = keys[0];
|
|
13
|
+
// Include keys that are numeric OR string-encoded numbers (like Binance data)
|
|
14
|
+
const numericKeys = keys.filter((k) => {
|
|
15
|
+
const v = data[0][k];
|
|
16
|
+
return typeof v === 'number' || (typeof v === 'string' && v !== '' && !isNaN(Number(v)));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (numericKeys.length === 0) return <Text dimColor>No numeric columns for heatmap</Text>;
|
|
20
|
+
|
|
21
|
+
// Compute min/max per column for normalization
|
|
22
|
+
const ranges = new Map<string, { min: number; max: number }>();
|
|
23
|
+
for (const key of numericKeys) {
|
|
24
|
+
const vals = data.map((r) => Number(r[key]) || 0);
|
|
25
|
+
ranges.set(key, { min: Math.min(...vals), max: Math.max(...vals) });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const maxLabel = Math.max(...data.map((r) => String(r[labelKey] ?? '').length), 6);
|
|
29
|
+
const colWidth = 10;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Box flexDirection="column">
|
|
33
|
+
{/* Header */}
|
|
34
|
+
<Box>
|
|
35
|
+
<Text dimColor>{' '.repeat(maxLabel + 1)}</Text>
|
|
36
|
+
{numericKeys.map((k) => (
|
|
37
|
+
<Text key={k} dimColor>
|
|
38
|
+
{k.slice(0, colWidth).padStart(colWidth)}
|
|
39
|
+
</Text>
|
|
40
|
+
))}
|
|
41
|
+
</Box>
|
|
42
|
+
{/* Rows */}
|
|
43
|
+
{data.slice(0, 20).map((row, i) => (
|
|
44
|
+
<Box key={i}>
|
|
45
|
+
<Text bold>
|
|
46
|
+
{String(row[labelKey] ?? '').slice(0, maxLabel).padEnd(maxLabel)}
|
|
47
|
+
</Text>
|
|
48
|
+
<Text> </Text>
|
|
49
|
+
{numericKeys.map((k) => {
|
|
50
|
+
const val = Number(row[k]) || 0;
|
|
51
|
+
const range = ranges.get(k)!;
|
|
52
|
+
const norm = range.max === range.min
|
|
53
|
+
? 0.5
|
|
54
|
+
: (val - range.min) / (range.max - range.min);
|
|
55
|
+
const color = interpolateColor(norm);
|
|
56
|
+
const display = formatCell(val);
|
|
57
|
+
return (
|
|
58
|
+
<Text key={k} backgroundColor={color} color="white">
|
|
59
|
+
{display.padStart(colWidth)}
|
|
60
|
+
</Text>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</Box>
|
|
64
|
+
))}
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function interpolateColor(t: number): string {
|
|
70
|
+
// Red (0.0) → Yellow (0.5) → Green (1.0)
|
|
71
|
+
let r: number, g: number, b: number;
|
|
72
|
+
if (t < 0.5) {
|
|
73
|
+
const s = t * 2;
|
|
74
|
+
r = 180;
|
|
75
|
+
g = Math.round(s * 180);
|
|
76
|
+
b = 30;
|
|
77
|
+
} else {
|
|
78
|
+
const s = (t - 0.5) * 2;
|
|
79
|
+
r = Math.round(180 * (1 - s));
|
|
80
|
+
g = 180;
|
|
81
|
+
b = 30;
|
|
82
|
+
}
|
|
83
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatCell(n: number): string {
|
|
87
|
+
if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
|
88
|
+
if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
89
|
+
if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
90
|
+
if (Number.isInteger(n)) return String(n);
|
|
91
|
+
return n.toFixed(2);
|
|
92
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box } from 'ink';
|
|
3
|
+
import type { PanelConfig } from '../../config/schema.js';
|
|
4
|
+
|
|
5
|
+
interface LayoutProps {
|
|
6
|
+
panels: PanelConfig[];
|
|
7
|
+
focusedIndex: number;
|
|
8
|
+
colWeights: number[];
|
|
9
|
+
rowHeight: number;
|
|
10
|
+
renderPanel: (panel: PanelConfig, index: number, focused: boolean) => React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Layout({ panels, focusedIndex, colWeights, rowHeight, renderPanel }: LayoutProps) {
|
|
14
|
+
// Group panels into rows of 2
|
|
15
|
+
const rows: { panel: PanelConfig; index: number }[][] = [];
|
|
16
|
+
for (let i = 0; i < panels.length; i += 2) {
|
|
17
|
+
const row: { panel: PanelConfig; index: number }[] = [{ panel: panels[i], index: i }];
|
|
18
|
+
if (i + 1 < panels.length) {
|
|
19
|
+
row.push({ panel: panels[i + 1], index: i + 1 });
|
|
20
|
+
}
|
|
21
|
+
rows.push(row);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
26
|
+
{rows.map((row, rowIdx) => (
|
|
27
|
+
<Box key={rowIdx} flexDirection="row" height={rowHeight}>
|
|
28
|
+
{row.map(({ panel, index }) => {
|
|
29
|
+
const width = row.length === 1
|
|
30
|
+
? '100%'
|
|
31
|
+
: `${colWeights[index]}%`;
|
|
32
|
+
return (
|
|
33
|
+
<Box key={index} width={width} minWidth={20}>
|
|
34
|
+
{renderPanel(panel, index, index === focusedIndex)}
|
|
35
|
+
</Box>
|
|
36
|
+
);
|
|
37
|
+
})}
|
|
38
|
+
</Box>
|
|
39
|
+
))}
|
|
40
|
+
</Box>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
interface OrderBookProps {
|
|
5
|
+
data: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const BLOCK = '█';
|
|
9
|
+
const BAR_WIDTH = 20;
|
|
10
|
+
const QTY_WIDTH = 10;
|
|
11
|
+
const PRICE_WIDTH = 12;
|
|
12
|
+
const ROWS = 10;
|
|
13
|
+
|
|
14
|
+
export function OrderBook({ data }: OrderBookProps) {
|
|
15
|
+
const entries = Array.isArray(data) ? data : [];
|
|
16
|
+
const latest = entries[entries.length - 1] as
|
|
17
|
+
| { bids?: [string, string][]; asks?: [string, string][] }
|
|
18
|
+
| undefined;
|
|
19
|
+
|
|
20
|
+
if (!latest || !latest.bids || !latest.asks) {
|
|
21
|
+
return <Text dimColor>Waiting for order book data...</Text>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const bids = latest.bids.slice(0, ROWS).map(([p, q]) => ({
|
|
25
|
+
price: parseFloat(p),
|
|
26
|
+
qty: parseFloat(q),
|
|
27
|
+
}));
|
|
28
|
+
const asks = latest.asks.slice(0, ROWS).map(([p, q]) => ({
|
|
29
|
+
price: parseFloat(p),
|
|
30
|
+
qty: parseFloat(q),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// Use cumulative depth for bar sizing
|
|
34
|
+
const bidCum: number[] = [];
|
|
35
|
+
let sum = 0;
|
|
36
|
+
for (const b of bids) { sum += b.qty; bidCum.push(sum); }
|
|
37
|
+
|
|
38
|
+
const askCum: number[] = [];
|
|
39
|
+
sum = 0;
|
|
40
|
+
for (const a of asks) { sum += a.qty; askCum.push(sum); }
|
|
41
|
+
|
|
42
|
+
// Last cumulative value = full bar width (always fills to BAR_WIDTH at bottom)
|
|
43
|
+
const bidMax = bidCum[bidCum.length - 1] || 1;
|
|
44
|
+
const askMax = askCum[askCum.length - 1] || 1;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Box flexDirection="column">
|
|
48
|
+
<Box>
|
|
49
|
+
<Text dimColor>{pad('BIDS', BAR_WIDTH, 'right')}</Text>
|
|
50
|
+
<Text dimColor>{pad('QTY', QTY_WIDTH, 'left')}</Text>
|
|
51
|
+
<Text dimColor>{pad('PRICE', PRICE_WIDTH, 'left')}</Text>
|
|
52
|
+
<Text dimColor>{pad('QTY', QTY_WIDTH, 'left')}</Text>
|
|
53
|
+
<Text dimColor>{'ASKS'}</Text>
|
|
54
|
+
</Box>
|
|
55
|
+
{Array.from({ length: ROWS }, (_, i) => {
|
|
56
|
+
const bid = bids[i];
|
|
57
|
+
const ask = asks[i];
|
|
58
|
+
// Scale each side independently so bottom row is always full width
|
|
59
|
+
const bidWidth = bid ? Math.max(1, Math.round((bidCum[i] / bidMax) * BAR_WIDTH)) : 0;
|
|
60
|
+
const askWidth = ask ? Math.max(1, Math.round((askCum[i] / askMax) * BAR_WIDTH)) : 0;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Box key={i}>
|
|
64
|
+
<Text color="green">{BLOCK.repeat(bidWidth).padStart(BAR_WIDTH)}</Text>
|
|
65
|
+
<Text dimColor>{pad(bid ? bid.qty.toFixed(4) : '', QTY_WIDTH, 'left')}</Text>
|
|
66
|
+
<Text bold color={i < (bids.length) ? 'green' : 'red'}>
|
|
67
|
+
{pad(formatPrice(bid?.price ?? ask?.price ?? 0), PRICE_WIDTH, 'left')}
|
|
68
|
+
</Text>
|
|
69
|
+
<Text dimColor>{pad(ask ? ask.qty.toFixed(4) : '', QTY_WIDTH, 'left')}</Text>
|
|
70
|
+
<Text color="red">{BLOCK.repeat(askWidth).padEnd(BAR_WIDTH)}</Text>
|
|
71
|
+
</Box>
|
|
72
|
+
);
|
|
73
|
+
})}
|
|
74
|
+
<Box marginTop={1}>
|
|
75
|
+
<Text dimColor>Spread: </Text>
|
|
76
|
+
<Text color="yellow">
|
|
77
|
+
{asks.length > 0 && bids.length > 0
|
|
78
|
+
? `${(asks[0].price - bids[0].price).toFixed(2)} (${((asks[0].price - bids[0].price) / bids[0].price * 100).toFixed(4)}%)`
|
|
79
|
+
: '—'}
|
|
80
|
+
</Text>
|
|
81
|
+
</Box>
|
|
82
|
+
</Box>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pad(s: string, width: number, align: 'left' | 'right'): string {
|
|
87
|
+
return align === 'left' ? s.padStart(width) : s.padEnd(width);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatPrice(n: number): string {
|
|
91
|
+
if (n >= 1000) return n.toFixed(2);
|
|
92
|
+
if (n >= 1) return n.toFixed(4);
|
|
93
|
+
return n.toFixed(6);
|
|
94
|
+
}
|