@flame0510/project-aether 1.7.0 → 1.9.0

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 (49) hide show
  1. package/README.md +3 -2
  2. package/app/agents/PageClient.tsx +3 -0
  3. package/app/agents/create/loading.tsx +51 -0
  4. package/app/agents/loading.tsx +2 -11
  5. package/app/api/assistant/route.ts +9 -2
  6. package/app/api/metrics/route.ts +126 -23
  7. package/app/api/system-health/route.ts +24 -20
  8. package/app/components/Sidebar.tsx +10 -0
  9. package/app/components/Skeleton.tsx +49 -1
  10. package/app/components/SystemCockpit.tsx +82 -2
  11. package/app/components/ui/Meter.tsx +34 -0
  12. package/app/components/ui/TimeSeriesChart.tsx +226 -0
  13. package/app/components/ui/index.ts +2 -0
  14. package/app/config/loading.tsx +4 -9
  15. package/app/containers/ContainersClient.tsx +4 -2
  16. package/app/containers/loading.tsx +2 -18
  17. package/app/containers/terminal/[id]/TerminalClient.tsx +230 -276
  18. package/app/containers/terminal/[id]/loading.tsx +20 -0
  19. package/app/crons/loading.tsx +4 -9
  20. package/app/gateway/loading.tsx +3 -7
  21. package/app/globals.css +51 -0
  22. package/app/lineage/loading.tsx +4 -9
  23. package/app/memory/loading.tsx +4 -9
  24. package/app/plugins/loading.tsx +4 -9
  25. package/app/skills/loading.tsx +4 -9
  26. package/app/system/PageClient.tsx +263 -0
  27. package/app/system/SystemSkeleton.tsx +115 -0
  28. package/app/system/loading.tsx +13 -0
  29. package/app/system/page.tsx +5 -0
  30. package/app/tools/loading.tsx +4 -9
  31. package/bin/rev4a.js +8 -4
  32. package/daemon.js +271 -190
  33. package/docs/ARCHITECTURE.md +49 -46
  34. package/docs/CONTAINER-TERMINAL.md +169 -261
  35. package/docs/DESIGN-SYSTEM.md +1 -0
  36. package/docs/FRONTEND-ARCHITECTURE.md +7 -2
  37. package/docs/REV4A.md +5 -3
  38. package/docs/dev/API-REFERENCE.md +61 -16
  39. package/docs/dev/DATABASE.md +65 -25
  40. package/docs/rag/DATA-FRESHNESS.md +13 -8
  41. package/docs/rag/GLOSSARY.md +7 -4
  42. package/docs/rag/REV4A-OVERVIEW.md +5 -2
  43. package/docs/rag/WHAT-I-CAN-ANSWER.md +1 -0
  44. package/lib/db-bootstrap.mjs +0 -11
  45. package/lib/metrics-db.ts +48 -0
  46. package/package.json +5 -1
  47. package/scripts/backup.sh +3 -3
  48. package/server.js +183 -0
  49. package/terminal-ws-server.js +329 -96
@@ -0,0 +1,226 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
4
+
5
+ export interface TimeSeriesPoint {
6
+ /** Seconds since the epoch — the start of the bucket. */
7
+ ts: number;
8
+ /** The plotted value (an average over the bucket); null leaves a gap. */
9
+ value: number | null;
10
+ /** The bucket's peak, drawn as a light wash under the line and shown in the tooltip. */
11
+ peak?: number | null;
12
+ }
13
+
14
+ interface TimeSeriesChartProps {
15
+ /** What is plotted, for screen readers and the table caption. */
16
+ label: string;
17
+ points: TimeSeriesPoint[];
18
+ /** The x domain in seconds, so an empty stretch reads as "no data", not as a zoom. */
19
+ from: number;
20
+ to: number;
21
+ /** Seconds per point; two points further apart than 1.5 buckets are not joined. */
22
+ bucket: number;
23
+ yMax?: number;
24
+ unit?: string;
25
+ height?: number;
26
+ /** While a new range loads the previous render stays, dimmed. */
27
+ stale?: boolean;
28
+ formatTime: (ts: number) => string;
29
+ }
30
+
31
+ const PAD = { top: 12, right: 44, bottom: 22, left: 34 };
32
+
33
+ /**
34
+ * One series over time: a 2px line for the value, a 10% wash up to the peak, a hairline
35
+ * grid at 0 / 50 / 100 %, the latest value labelled at the line's end. Hover or focus
36
+ * shows a crosshair snapped to the nearest point with its value and peak; the same data
37
+ * is available as a table. Plain SVG measured to its container, so text is never scaled.
38
+ */
39
+ export function TimeSeriesChart({
40
+ label,
41
+ points,
42
+ from,
43
+ to,
44
+ bucket,
45
+ yMax = 100,
46
+ unit = '%',
47
+ height = 160,
48
+ stale = false,
49
+ formatTime,
50
+ }: TimeSeriesChartProps) {
51
+ const boxRef = useRef<HTMLDivElement>(null);
52
+ const clipId = `ts-clip-${useId().replace(/:/g, '')}`;
53
+ const [width, setWidth] = useState(0);
54
+ const [active, setActive] = useState<number | null>(null);
55
+
56
+ useEffect(() => {
57
+ const el = boxRef.current;
58
+ if (!el) return;
59
+ const ro = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
60
+ ro.observe(el);
61
+ return () => ro.disconnect();
62
+ }, []);
63
+
64
+ const plotW = Math.max(0, width - PAD.left - PAD.right);
65
+ const plotH = height - PAD.top - PAD.bottom;
66
+ const span = Math.max(1, to - from);
67
+ const x = (ts: number) => PAD.left + ((ts - from) / span) * plotW;
68
+ const y = (v: number) => PAD.top + plotH - (Math.max(0, Math.min(yMax, v)) / yMax) * plotH;
69
+
70
+ const valid = useMemo(() => points.filter((p) => p.value !== null), [points]);
71
+
72
+ // Runs of points close enough in time to be joined; a gap means no samples there.
73
+ const runs = useMemo(() => {
74
+ const out: TimeSeriesPoint[][] = [];
75
+ let run: TimeSeriesPoint[] = [];
76
+ for (const p of valid) {
77
+ const prev = run[run.length - 1];
78
+ if (prev && p.ts - prev.ts > bucket * 1.5) {
79
+ out.push(run);
80
+ run = [];
81
+ }
82
+ run.push(p);
83
+ }
84
+ if (run.length) out.push(run);
85
+ return out;
86
+ }, [valid, bucket]);
87
+
88
+ const linePath = (run: TimeSeriesPoint[]) =>
89
+ run.map((p, i) => `${i ? 'L' : 'M'}${x(p.ts).toFixed(1)},${y(p.value as number).toFixed(1)}`).join('');
90
+ const peakArea = (run: TimeSeriesPoint[]) => {
91
+ const top = run.map((p, i) => `${i ? 'L' : 'M'}${x(p.ts).toFixed(1)},${y(p.peak ?? (p.value as number)).toFixed(1)}`).join('');
92
+ const base = y(0).toFixed(1);
93
+ return `${top}L${x(run[run.length - 1].ts).toFixed(1)},${base}L${x(run[0].ts).toFixed(1)},${base}Z`;
94
+ };
95
+
96
+ const last = valid[valid.length - 1];
97
+ const shown = active !== null ? valid[active] : null;
98
+ const fmt = (v: number | null | undefined) => (v === null || v === undefined ? '—' : `${Math.round(v * 10) / 10}${unit}`);
99
+
100
+ function onPointerMove(e: PointerEvent<SVGRectElement>) {
101
+ if (!valid.length || plotW <= 0) return;
102
+ const rect = e.currentTarget.getBoundingClientRect();
103
+ const ts = from + ((e.clientX - rect.left) / rect.width) * span;
104
+ let best = 0;
105
+ for (let i = 1; i < valid.length; i++) {
106
+ if (Math.abs(valid[i].ts - ts) < Math.abs(valid[best].ts - ts)) best = i;
107
+ }
108
+ setActive(best);
109
+ }
110
+
111
+ function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
112
+ if (!valid.length) return;
113
+ if (e.key === 'ArrowLeft') setActive((a) => Math.max(0, (a ?? valid.length) - 1));
114
+ else if (e.key === 'ArrowRight') setActive((a) => Math.min(valid.length - 1, (a ?? -1) + 1));
115
+ else if (e.key === 'Escape') setActive(null);
116
+ else return;
117
+ e.preventDefault();
118
+ }
119
+
120
+ const ticks = [from, from + span / 2, to];
121
+ const tooltipLeft = shown ? Math.min(Math.max(x(shown.ts), 70), Math.max(70, width - 70)) : 0;
122
+
123
+ return (
124
+ <div className="ui-chart" style={{ opacity: stale ? 0.5 : 1 }}>
125
+ <div
126
+ ref={boxRef}
127
+ className="ui-chart__plot"
128
+ style={{ height }}
129
+ tabIndex={0}
130
+ role="img"
131
+ aria-label={last ? `${label}: latest ${fmt(last.value)}` : `${label}: no data`}
132
+ onKeyDown={onKeyDown}
133
+ onFocus={() => setActive((a) => a ?? (valid.length ? valid.length - 1 : null))}
134
+ onBlur={() => setActive(null)}
135
+ >
136
+ {width > 0 && (
137
+ <svg width={width} height={height} aria-hidden="true">
138
+ {[0, 50, 100].map((v) => (
139
+ <g key={v}>
140
+ <line x1={PAD.left} x2={PAD.left + plotW} y1={y((v / 100) * yMax)} y2={y((v / 100) * yMax)} className="ui-chart__grid" />
141
+ <text x={PAD.left - 6} y={y((v / 100) * yMax) + 3} textAnchor="end" className="ui-chart__tick">
142
+ {Math.round((v / 100) * yMax)}{unit}
143
+ </text>
144
+ </g>
145
+ ))}
146
+ {ticks.map((ts, i) => (
147
+ <text
148
+ key={i}
149
+ x={x(ts)}
150
+ y={height - 6}
151
+ textAnchor={i === 0 ? 'start' : i === 2 ? 'end' : 'middle'}
152
+ className="ui-chart__tick"
153
+ >
154
+ {formatTime(ts)}
155
+ </text>
156
+ ))}
157
+ <defs>
158
+ {/* Marks stay inside the plot, never over the axis labels. */}
159
+ <clipPath id={clipId}>
160
+ <rect x={PAD.left} y={0} width={plotW} height={PAD.top + plotH} />
161
+ </clipPath>
162
+ </defs>
163
+ <g clipPath={`url(#${clipId})`}>
164
+ {runs.map((run, i) => (
165
+ <g key={i}>
166
+ <path d={peakArea(run)} className="ui-chart__wash" />
167
+ {run.length > 1 && <path d={linePath(run)} className="ui-chart__line" />}
168
+ {run.length === 1 && <circle cx={x(run[0].ts)} cy={y(run[0].value as number)} r={2} className="ui-chart__dot" />}
169
+ </g>
170
+ ))}
171
+ </g>
172
+ {last && !shown && (
173
+ <g>
174
+ <circle cx={x(last.ts)} cy={y(last.value as number)} r={4} className="ui-chart__marker" />
175
+ <text x={x(last.ts) + 8} y={y(last.value as number) + 3} className="ui-chart__end-label">{fmt(last.value)}</text>
176
+ </g>
177
+ )}
178
+ {shown && (
179
+ <g>
180
+ <line x1={x(shown.ts)} x2={x(shown.ts)} y1={PAD.top} y2={PAD.top + plotH} className="ui-chart__crosshair" />
181
+ <circle cx={x(shown.ts)} cy={y(shown.value as number)} r={4} className="ui-chart__marker" />
182
+ </g>
183
+ )}
184
+ {!valid.length && (
185
+ <text x={PAD.left + plotW / 2} y={PAD.top + plotH / 2} textAnchor="middle" className="ui-chart__tick">No samples in this range</text>
186
+ )}
187
+ <rect
188
+ x={PAD.left}
189
+ y={PAD.top}
190
+ width={plotW}
191
+ height={plotH}
192
+ fill="transparent"
193
+ onPointerMove={onPointerMove}
194
+ onPointerLeave={() => setActive(null)}
195
+ />
196
+ </svg>
197
+ )}
198
+ {shown && (
199
+ <div className="ui-chart__tooltip" style={{ left: tooltipLeft }}>
200
+ <strong>{fmt(shown.value)}</strong>
201
+ {shown.peak !== undefined && <span className="ui-muted"> · peak {fmt(shown.peak)}</span>}
202
+ <div className="ui-muted">{formatTime(shown.ts)}</div>
203
+ </div>
204
+ )}
205
+ </div>
206
+ <details className="ui-chart__table">
207
+ <summary>Table</summary>
208
+ <table>
209
+ <caption>{label}</caption>
210
+ <thead>
211
+ <tr><th scope="col">Time</th><th scope="col">Value</th>{valid.some((p) => p.peak !== undefined) && <th scope="col">Peak</th>}</tr>
212
+ </thead>
213
+ <tbody>
214
+ {valid.map((p) => (
215
+ <tr key={p.ts}>
216
+ <td>{formatTime(p.ts)}</td>
217
+ <td>{fmt(p.value)}</td>
218
+ {p.peak !== undefined && <td>{fmt(p.peak)}</td>}
219
+ </tr>
220
+ ))}
221
+ </tbody>
222
+ </table>
223
+ </details>
224
+ </div>
225
+ );
226
+ }
@@ -19,3 +19,5 @@ export * from './Tabs';
19
19
  export * from './ItemList';
20
20
  export * from './TemplateOption';
21
21
  export { default as ModalityIcons, parseModality, type ParsedModality } from './ModalityIcons';
22
+ export * from './Meter';
23
+ export * from './TimeSeriesChart';
@@ -1,10 +1,5 @@
1
- import { Skeleton, CardRowSkeleton } from '../components/Skeleton';
1
+ import { ListPageSkeleton } from '../components/Skeleton';
2
2
 
3
- const PageSkeleton = () => (
4
- <div style={{ padding: '24px 20px', maxWidth: 1080, margin: '0 auto' }}>
5
- <Skeleton width={120} height={20} accent style={{ marginBottom: 20 }} />
6
- <CardRowSkeleton count={5} height={48} />
7
- </div>
8
- );
9
-
10
- export default PageSkeleton;
3
+ export default function ConfigLoading() {
4
+ return <ListPageSkeleton />;
5
+ }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
+ import Link from 'next/link';
4
5
  import { Button, Pill } from '../components/ui';
5
6
 
6
7
  interface ContainerInfo {
@@ -85,7 +86,8 @@ export default function ContainersClient() {
85
86
  {c.state === 'running' ? c.status.replace(/^Up\s+/, '') : c.status}
86
87
  </span>
87
88
  {c.state === 'running' && (
88
- <a
89
+ // A client-side navigation (prefetched), not a full page reload.
90
+ <Link
89
91
  href={`/containers/terminal/${encodeURIComponent(c.name)}`}
90
92
  style={{
91
93
  fontSize: 10,
@@ -100,7 +102,7 @@ export default function ContainersClient() {
100
102
  }}
101
103
  >
102
104
  TERMINAL
103
- </a>
105
+ </Link>
104
106
  )}
105
107
  </div>
106
108
 
@@ -1,21 +1,5 @@
1
- import { Skeleton, CardRowSkeleton } from '../components/Skeleton';
1
+ import { ListPageSkeleton } from '../components/Skeleton';
2
2
 
3
3
  export default function ContainersLoading() {
4
- return (
5
- <div style={{
6
- padding: '16px 20px',
7
- maxWidth: 1080,
8
- margin: '0 auto',
9
- height: '100%',
10
- boxSizing: 'border-box',
11
- overflowY: 'auto',
12
- WebkitOverflowScrolling: 'touch',
13
- paddingBottom: 72,
14
- }}>
15
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
16
- <Skeleton width={140} height={20} accent />
17
- </div>
18
- <CardRowSkeleton count={4} height={56} />
19
- </div>
20
- );
4
+ return <ListPageSkeleton rows={4} rowHeight={56} titleWidth={140} />;
21
5
  }