ispbills-icli 8.7.1 → 8.7.3
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/package.json +1 -1
- package/src/client.js +41 -0
- package/src/tui/app.js +40 -2
- package/src/tui/components.js +340 -0
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
import { refreshToken } from './auth.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Generic authenticated GET for any /api/v1/<path> endpoint.
|
|
5
|
+
* Uses the same Bearer token as the AI chat stream.
|
|
6
|
+
*/
|
|
7
|
+
export async function fetchApi(cfg, path) {
|
|
8
|
+
const doFetch = (token) =>
|
|
9
|
+
fetch(`${cfg.url}/api/v1/${path}`, {
|
|
10
|
+
headers: { Accept: 'application/json', Authorization: 'Bearer ' + token },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
let res = await doFetch(cfg.token);
|
|
14
|
+
if (res.status === 401 && cfg.refresh_token) {
|
|
15
|
+
const updated = await refreshToken(cfg);
|
|
16
|
+
if (!updated) throw new Error('Session expired – run `icli login`.');
|
|
17
|
+
Object.assign(cfg, updated);
|
|
18
|
+
res = await doFetch(cfg.token);
|
|
19
|
+
}
|
|
20
|
+
if (!res.ok) throw new Error(`API ${path} — HTTP ${res.status}`);
|
|
21
|
+
return res.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch all device types in parallel for the Devices / Maps tabs.
|
|
26
|
+
* Uses Promise.allSettled so a missing endpoint never kills the whole load.
|
|
27
|
+
*/
|
|
28
|
+
export async function fetchDeviceData(cfg) {
|
|
29
|
+
const [routers, olts, ubiquiti, cambium] = await Promise.allSettled([
|
|
30
|
+
fetchApi(cfg, 'routers'),
|
|
31
|
+
fetchApi(cfg, 'olts'),
|
|
32
|
+
fetchApi(cfg, 'monitoring/ubiquiti'),
|
|
33
|
+
fetchApi(cfg, 'monitoring/cambium'),
|
|
34
|
+
]);
|
|
35
|
+
return {
|
|
36
|
+
routers: routers.status === 'fulfilled' ? (routers.value.data || []) : [],
|
|
37
|
+
olts: olts.status === 'fulfilled' ? (olts.value.data || []) : [],
|
|
38
|
+
ubiquiti: ubiquiti.status === 'fulfilled' ? (ubiquiti.value.data || []) : [],
|
|
39
|
+
cambium: cambium.status === 'fulfilled' ? (cambium.value.data || []) : [],
|
|
40
|
+
fetchedAt: Date.now(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
3
44
|
/**
|
|
4
45
|
* Incrementally extract the answer text from the raw JSON the backend streams
|
|
5
46
|
* in `delta` events (the reply object is built up as a JSON string).
|
package/src/tui/app.js
CHANGED
|
@@ -6,10 +6,10 @@ import { useMouse } from './mouse.js';
|
|
|
6
6
|
import {
|
|
7
7
|
Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
8
8
|
Plan, Connect, Notice, Thinking, Choice, ShellOutput, SearchOverlay, HelpOverlay,
|
|
9
|
-
SessionPicker, DiffViewer, LabeledNotice, TABS, TAB_W,
|
|
9
|
+
SessionPicker, DiffViewer, LabeledNotice, DevicesPanel, MapsPanel, TABS, TAB_W,
|
|
10
10
|
} from './components.js';
|
|
11
11
|
import { Composer } from './composer.js';
|
|
12
|
-
import { streamChat } from '../client.js';
|
|
12
|
+
import { streamChat, fetchDeviceData } from '../client.js';
|
|
13
13
|
import { slashToQuestion, SLASH_COMMANDS } from '../commands.js';
|
|
14
14
|
import { saveConfig } from '../config.js';
|
|
15
15
|
import { shortModel } from '../ui.js';
|
|
@@ -432,6 +432,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
432
432
|
const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
|
|
433
433
|
const [sessionPicker, setSessionPicker] = useState(null);
|
|
434
434
|
const [diffState, setDiffState] = useState(null);
|
|
435
|
+
const [deviceData, setDeviceData] = useState(null);
|
|
436
|
+
const [deviceLoading, setDeviceLoading] = useState(false);
|
|
437
|
+
const [deviceError, setDeviceError] = useState(null);
|
|
435
438
|
const chipsRows = (!busy && chips.length && !choice && !search && !sessionPicker && !diffState) ? 1 : 0;
|
|
436
439
|
const composerRows = 2;
|
|
437
440
|
const chromeRows = 2 + composerRows + 1 + chipsRows + 2; // +2 for two footer rows
|
|
@@ -483,6 +486,31 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
483
486
|
for (const timer of dismissTimers.current.values()) clearTimeout(timer);
|
|
484
487
|
}, []);
|
|
485
488
|
|
|
489
|
+
// ── Device data fetch ───────────────────────────────────────────────────────
|
|
490
|
+
const deviceLoadingRef = useRef(false);
|
|
491
|
+
const loadDeviceData = useCallback(async () => {
|
|
492
|
+
if (deviceLoadingRef.current) return;
|
|
493
|
+
deviceLoadingRef.current = true;
|
|
494
|
+
setDeviceLoading(true);
|
|
495
|
+
setDeviceError(null);
|
|
496
|
+
try {
|
|
497
|
+
const data = await fetchDeviceData(cfg);
|
|
498
|
+
setDeviceData(data);
|
|
499
|
+
} catch (e) {
|
|
500
|
+
setDeviceError(String(e?.message || e));
|
|
501
|
+
} finally {
|
|
502
|
+
deviceLoadingRef.current = false;
|
|
503
|
+
setDeviceLoading(false);
|
|
504
|
+
}
|
|
505
|
+
}, [cfg]);
|
|
506
|
+
|
|
507
|
+
// Auto-fetch when the user first opens the Devices or Maps tab
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if ((activeTab === 'devices' || activeTab === 'maps') && !deviceData && !deviceLoadingRef.current) {
|
|
510
|
+
loadDeviceData();
|
|
511
|
+
}
|
|
512
|
+
}, [activeTab]);
|
|
513
|
+
|
|
486
514
|
const persistCfg = useCallback(() => {
|
|
487
515
|
try { saveConfig(cfg); } catch {}
|
|
488
516
|
}, [cfg]);
|
|
@@ -1997,6 +2025,12 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1997
2025
|
pushNotice(`Search ${activeTab} tab…`, C.muted, true);
|
|
1998
2026
|
return;
|
|
1999
2027
|
}
|
|
2028
|
+
// 'r' refreshes the device list when on Devices or Maps tab
|
|
2029
|
+
if ((activeTab === 'devices' || activeTab === 'maps') && input === 'r' && !key.ctrl) {
|
|
2030
|
+
loadDeviceData();
|
|
2031
|
+
setHint('Refreshing devices…');
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2000
2034
|
if (key.tab && key.shift && !busy) {
|
|
2001
2035
|
const cur = MODES.indexOf(modeRef.current);
|
|
2002
2036
|
const next = MODES[(cur + 1) % MODES.length];
|
|
@@ -2158,6 +2192,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
2158
2192
|
<${Notice} text=${`${glyphs.expand} ${expandedItems.kind}`} color=${C.muted} />
|
|
2159
2193
|
${expandedItems.items.map((it) => renderItem(it))}
|
|
2160
2194
|
<//>`
|
|
2195
|
+
: activeTab === 'devices'
|
|
2196
|
+
? html`<${DevicesPanel} data=${deviceData} loading=${deviceLoading} error=${deviceError} cols=${cols} />`
|
|
2197
|
+
: activeTab === 'maps'
|
|
2198
|
+
? html`<${MapsPanel} data=${deviceData} loading=${deviceLoading} error=${deviceError} cols=${cols} />`
|
|
2161
2199
|
: tabPanel
|
|
2162
2200
|
? html`<${TabView} panel=${tabPanel} cols=${cols} activeTab=${activeTab} />`
|
|
2163
2201
|
: html`<${Box} flexDirection="column">
|
package/src/tui/components.js
CHANGED
|
@@ -591,3 +591,343 @@ export function Choice({ title, note, options = [], sel = 0, allowText = false,
|
|
|
591
591
|
<//>
|
|
592
592
|
<//>`;
|
|
593
593
|
}
|
|
594
|
+
|
|
595
|
+
// ── Shared device-status helpers ────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
function devStatus(d) {
|
|
598
|
+
const s = String(d.status || '').toLowerCase();
|
|
599
|
+
if (d.is_online === true || s === 'online' || s === 'active' || s === 'ok') return 'online';
|
|
600
|
+
if (d.is_online === false || s === 'offline' || s === 'inactive' || s === 'down') return 'offline';
|
|
601
|
+
return 'unknown';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function statusDot(d) {
|
|
605
|
+
const st = devStatus(d);
|
|
606
|
+
return st === 'online' ? { dot: '●', color: C.success }
|
|
607
|
+
: st === 'offline' ? { dot: '○', color: C.error }
|
|
608
|
+
: { dot: '◌', color: C.warning };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function statusCounts(list) {
|
|
612
|
+
const on = list.filter((d) => devStatus(d) === 'online').length;
|
|
613
|
+
const off = list.filter((d) => devStatus(d) === 'offline').length;
|
|
614
|
+
return { on, off, other: list.length - on - off };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Format a timestamp as a relative "Xm ago" / "Xh ago" string. */
|
|
618
|
+
function fmtAgo(ts) {
|
|
619
|
+
if (!ts) return '';
|
|
620
|
+
const diff = Date.now() - new Date(ts).getTime();
|
|
621
|
+
if (isNaN(diff) || diff < 0) return '';
|
|
622
|
+
const s = Math.floor(diff / 1000);
|
|
623
|
+
if (s < 90) return `${s}s ago`;
|
|
624
|
+
const m = Math.floor(s / 60);
|
|
625
|
+
if (m < 90) return `${m}m ago`;
|
|
626
|
+
const h = Math.floor(m / 60);
|
|
627
|
+
if (h < 48) return `${h}h ago`;
|
|
628
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── DevicesPanel ─────────────────────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
export function DevicesPanel({ data, loading, error, cols }) {
|
|
634
|
+
const bw = cols - 4;
|
|
635
|
+
const header = html`
|
|
636
|
+
<${Box} flexDirection="column" paddingX=${2}>
|
|
637
|
+
<${Box} marginBottom=${0}>
|
|
638
|
+
<${Text} color=${C.brand} bold>Devices<//><${Text} color=${C.muted}> ISP fleet — routers · OLTs · access points<//>
|
|
639
|
+
<//>
|
|
640
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
|
|
641
|
+
<//>`;
|
|
642
|
+
|
|
643
|
+
if (loading) return html`
|
|
644
|
+
<${Box} flexDirection="column">
|
|
645
|
+
${header}
|
|
646
|
+
<${Box} paddingX=${2} paddingY=${1} gap=${1}>
|
|
647
|
+
<${Spinner} type=${spinnerType} /><${Text} color=${C.muted}> Loading devices…<//>
|
|
648
|
+
<//>
|
|
649
|
+
<//>`;
|
|
650
|
+
|
|
651
|
+
if (error || !data) return html`
|
|
652
|
+
<${Box} flexDirection="column">
|
|
653
|
+
${header}
|
|
654
|
+
<${Box} paddingX=${2} paddingY=${1} flexDirection="column">
|
|
655
|
+
<${Text} color=${C.error}>${error || 'No data'}<//>
|
|
656
|
+
<${Text} color=${C.muted} dimColor>Press r to retry<//>
|
|
657
|
+
<//>
|
|
658
|
+
<//>`;
|
|
659
|
+
|
|
660
|
+
const groups = [
|
|
661
|
+
{ label: 'Routers', icon: '⬡', items: data.routers, typeTag: 'router' },
|
|
662
|
+
{ label: 'OLTs', icon: '◈', items: data.olts, typeTag: 'olt' },
|
|
663
|
+
{ label: 'Ubiquiti APs', icon: '◉', items: data.ubiquiti, typeTag: 'ubiquiti' },
|
|
664
|
+
{ label: 'Cambium APs', icon: '◉', items: data.cambium, typeTag: 'cambium' },
|
|
665
|
+
].filter((g) => g.items.length > 0);
|
|
666
|
+
|
|
667
|
+
if (groups.length === 0) return html`
|
|
668
|
+
<${Box} flexDirection="column">
|
|
669
|
+
${header}
|
|
670
|
+
<${Box} paddingX=${2} paddingY=${1}>
|
|
671
|
+
<${Text} color=${C.muted}>No devices found. Press r to refresh.<//>
|
|
672
|
+
<//>
|
|
673
|
+
<//>`;
|
|
674
|
+
|
|
675
|
+
// Compute column widths based on terminal width
|
|
676
|
+
const nameW = Math.max(20, Math.floor(cols * 0.32));
|
|
677
|
+
const ipW = 17;
|
|
678
|
+
const statusW = 9; // ' ONLINE ' / ' OFFLINE ' / ' UNKNOWN '
|
|
679
|
+
const maxRowsPerGroup = Math.max(5, cols > 100 ? 14 : 10);
|
|
680
|
+
|
|
681
|
+
const ts = data.fetchedAt
|
|
682
|
+
? new Date(data.fetchedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
683
|
+
: '';
|
|
684
|
+
|
|
685
|
+
// Summary line: total counts across all device types
|
|
686
|
+
const totals = groups.reduce((acc, g) => {
|
|
687
|
+
const { on, off, other } = statusCounts(g.items);
|
|
688
|
+
acc.on += on; acc.off += off; acc.other += other; acc.total += g.items.length;
|
|
689
|
+
return acc;
|
|
690
|
+
}, { on: 0, off: 0, other: 0, total: 0 });
|
|
691
|
+
|
|
692
|
+
return html`
|
|
693
|
+
<${Box} flexDirection="column">
|
|
694
|
+
${header}
|
|
695
|
+
|
|
696
|
+
<!-- Fleet summary bar -->
|
|
697
|
+
<${Box} paddingX=${2} marginBottom=${1}>
|
|
698
|
+
<${Text} color=${C.muted}>Total <//>
|
|
699
|
+
<${Text} bold>${totals.total}<//>
|
|
700
|
+
<${Text} color=${C.muted}> devices <//>
|
|
701
|
+
<${Text} color=${C.success} bold>● ${totals.on} online<//>
|
|
702
|
+
<${Text} color=${C.muted}> <//>
|
|
703
|
+
<${Text} color=${C.error} bold>○ ${totals.off} offline<//>
|
|
704
|
+
${totals.other > 0 ? html`<${Text} color=${C.warning} bold> ◌ ${totals.other} unknown<//>` : null}
|
|
705
|
+
<//>
|
|
706
|
+
|
|
707
|
+
<${Box} flexDirection="column" paddingX=${2} paddingY=${0}>
|
|
708
|
+
${groups.map((g) => {
|
|
709
|
+
const { on, off, other } = statusCounts(g.items);
|
|
710
|
+
// Sort: online first, unknown second, offline last
|
|
711
|
+
const sorted = [...g.items].sort((a, b) => {
|
|
712
|
+
const order = { online: 0, unknown: 1, offline: 2 };
|
|
713
|
+
return (order[devStatus(a)] ?? 1) - (order[devStatus(b)] ?? 1);
|
|
714
|
+
});
|
|
715
|
+
const shown = sorted.slice(0, maxRowsPerGroup);
|
|
716
|
+
const hidden = sorted.length - shown.length;
|
|
717
|
+
|
|
718
|
+
return html`
|
|
719
|
+
<${Box} key=${g.label} flexDirection="column" marginTop=${1}>
|
|
720
|
+
<!-- Group header with status summary -->
|
|
721
|
+
<${Box}>
|
|
722
|
+
<${Text} color=${C.accent} bold>${g.icon} ${g.label}<//>
|
|
723
|
+
<${Text} color=${C.muted}> (${g.items.length}) <//>
|
|
724
|
+
<${Text} color=${C.success}>${on} online<//>
|
|
725
|
+
${off > 0 ? html`<${Text} color=${C.error}> ${off} offline<//>` : null}
|
|
726
|
+
${other > 0 ? html`<${Text} color=${C.warning}> ${other} unknown<//>` : null}
|
|
727
|
+
<//>
|
|
728
|
+
|
|
729
|
+
<!-- Column header -->
|
|
730
|
+
<${Box}>
|
|
731
|
+
<${Text} color=${C.muted}> ${'Name'.padEnd(nameW)}${'IP Address'.padEnd(ipW)}Status Checked<//>
|
|
732
|
+
<//>
|
|
733
|
+
|
|
734
|
+
${shown.map((d, i) => {
|
|
735
|
+
const st = devStatus(d);
|
|
736
|
+
const { dot, color } = statusDot(d);
|
|
737
|
+
const isLast = i === shown.length - 1 && hidden === 0;
|
|
738
|
+
const tree = isLast ? '└─' : '├─';
|
|
739
|
+
|
|
740
|
+
const name = (d.name || d.device_name || `#${d.id}`)
|
|
741
|
+
.slice(0, nameW - 1).padEnd(nameW);
|
|
742
|
+
const ip = (d.ip_address || '─').slice(0, ipW - 1).padEnd(ipW);
|
|
743
|
+
|
|
744
|
+
// Status badge
|
|
745
|
+
const badgeText = st === 'online' ? ' ONLINE ' : st === 'offline' ? ' OFFLINE' : ' UNKNOWN';
|
|
746
|
+
const badgeColor = st === 'online' ? C.success : st === 'offline' ? C.error : C.warning;
|
|
747
|
+
|
|
748
|
+
// Last-check time
|
|
749
|
+
const checkTs = d.last_check || d.last_seen || d.created_at;
|
|
750
|
+
const ago = fmtAgo(checkTs);
|
|
751
|
+
|
|
752
|
+
return html`
|
|
753
|
+
<${Box} key=${d.id}>
|
|
754
|
+
<${Text} color=${C.muted}>${tree} <//>
|
|
755
|
+
<${Text} color=${color}>${dot} <//>
|
|
756
|
+
<${Text}>${name}<//>
|
|
757
|
+
<${Text} color=${C.muted}>${ip}<//>
|
|
758
|
+
<${Text} color=${badgeColor} bold>${badgeText}<//>
|
|
759
|
+
${ago ? html`<${Text} color=${C.muted} dimColor> ${ago}<//>` : null}
|
|
760
|
+
<//>`;
|
|
761
|
+
})}
|
|
762
|
+
${hidden > 0
|
|
763
|
+
? html`<${Box}><${Text} color=${C.muted} dimColor> └─ …and ${hidden} more (press r to refresh)<//> <//>`
|
|
764
|
+
: null}
|
|
765
|
+
<//>`;
|
|
766
|
+
})}
|
|
767
|
+
<//>
|
|
768
|
+
<${Box} paddingX=${2} marginTop=${1}>
|
|
769
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
|
|
770
|
+
<//>
|
|
771
|
+
<${Box} paddingX=${2}>
|
|
772
|
+
<${Text} color=${C.muted} dimColor>r refresh${ts ? ' ' + midDot() + ' updated ' + ts : ''} ${midDot()} ● online ○ offline ◌ unknown ${midDot()} Enter → Session<//>
|
|
773
|
+
<//>
|
|
774
|
+
<//>`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ── MapsPanel (static ASCII topology) ────────────────────────────────────────
|
|
778
|
+
|
|
779
|
+
export function MapsPanel({ data, loading, error, cols }) {
|
|
780
|
+
const bw = cols - 4;
|
|
781
|
+
const header = html`
|
|
782
|
+
<${Box} flexDirection="column" paddingX=${2}>
|
|
783
|
+
<${Box} marginBottom=${0}>
|
|
784
|
+
<${Text} color=${C.brand} bold>Network Map<//><${Text} color=${C.muted}> Static topology — routers · OLTs · access points<//>
|
|
785
|
+
<//>
|
|
786
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
|
|
787
|
+
<//>`;
|
|
788
|
+
|
|
789
|
+
if (loading) return html`
|
|
790
|
+
<${Box} flexDirection="column">
|
|
791
|
+
${header}
|
|
792
|
+
<${Box} paddingX=${2} paddingY=${1} gap=${1}>
|
|
793
|
+
<${Spinner} type=${spinnerType} /><${Text} color=${C.muted}> Building topology…<//>
|
|
794
|
+
<//>
|
|
795
|
+
<//>`;
|
|
796
|
+
|
|
797
|
+
if (error || !data) return html`
|
|
798
|
+
<${Box} flexDirection="column">
|
|
799
|
+
${header}
|
|
800
|
+
<${Box} paddingX=${2} paddingY=${1} flexDirection="column">
|
|
801
|
+
<${Text} color=${C.error}>${error || 'No data'}<//>
|
|
802
|
+
<${Text} color=${C.muted} dimColor>Press r to retry<//>
|
|
803
|
+
<//>
|
|
804
|
+
<//>`;
|
|
805
|
+
|
|
806
|
+
const tiers = [
|
|
807
|
+
{ label: 'Routers', items: data.routers, icon: '⬡', color: C.brand },
|
|
808
|
+
{ label: 'OLTs', items: data.olts, icon: '◈', color: C.accent },
|
|
809
|
+
{ label: 'Ubiquiti APs', items: data.ubiquiti, icon: '◉', color: C.success },
|
|
810
|
+
{ label: 'Cambium APs', items: data.cambium, icon: '◉', color: C.warning },
|
|
811
|
+
].filter((t) => t.items.length > 0);
|
|
812
|
+
|
|
813
|
+
const maxPerTier = cols > 100 ? 12 : 8;
|
|
814
|
+
const nameW = Math.max(18, Math.floor(cols * 0.28));
|
|
815
|
+
const ipW = 17;
|
|
816
|
+
|
|
817
|
+
// Build the total fleet summary
|
|
818
|
+
const fleet = tiers.reduce((acc, t) => {
|
|
819
|
+
const { on, off, other } = statusCounts(t.items);
|
|
820
|
+
acc.on += on; acc.off += off; acc.other += other; acc.total += t.items.length;
|
|
821
|
+
return acc;
|
|
822
|
+
}, { on: 0, off: 0, other: 0, total: 0 });
|
|
823
|
+
|
|
824
|
+
const ts = data.fetchedAt
|
|
825
|
+
? new Date(data.fetchedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
826
|
+
: '';
|
|
827
|
+
|
|
828
|
+
return html`
|
|
829
|
+
<${Box} flexDirection="column">
|
|
830
|
+
${header}
|
|
831
|
+
|
|
832
|
+
<${Box} flexDirection="column" paddingX=${2} paddingY=${0}>
|
|
833
|
+
|
|
834
|
+
<!-- Fleet summary -->
|
|
835
|
+
<${Box} marginTop=${1} marginBottom=${1}>
|
|
836
|
+
<${Text} color=${C.muted}>Fleet: <//>
|
|
837
|
+
<${Text} bold>${fleet.total} devices<//>
|
|
838
|
+
<${Text} color=${C.muted}> <//>
|
|
839
|
+
<${Text} color=${C.success} bold>● ${fleet.on} up<//>
|
|
840
|
+
<${Text} color=${C.muted}> <//>
|
|
841
|
+
<${Text} color=${C.error} bold>○ ${fleet.off} down<//>
|
|
842
|
+
${fleet.other > 0 ? html`<${Text} color=${C.warning} bold> ◌ ${fleet.other} unknown<//>` : null}
|
|
843
|
+
<//>
|
|
844
|
+
|
|
845
|
+
<!-- Internet cloud node -->
|
|
846
|
+
<${Box}>
|
|
847
|
+
<${Text} color=${C.muted}> ╔══════════════╗<//>
|
|
848
|
+
<//>
|
|
849
|
+
<${Box}>
|
|
850
|
+
<${Text} color=${C.muted}> ║ <//>
|
|
851
|
+
<${Text} color=${C.brand} bold> INTERNET <//>
|
|
852
|
+
<${Text} color=${C.muted}> ║<//>
|
|
853
|
+
<//>
|
|
854
|
+
<${Box}>
|
|
855
|
+
<${Text} color=${C.muted}> ╚══════╤═══════╝<//>
|
|
856
|
+
<//>
|
|
857
|
+
|
|
858
|
+
<!-- Each tier rendered as a branch under the topology tree -->
|
|
859
|
+
${tiers.map((tier, ti) => {
|
|
860
|
+
const { on, off, other } = statusCounts(tier.items);
|
|
861
|
+
// Sort: online first, offline last
|
|
862
|
+
const sorted = [...tier.items].sort((a, b) => {
|
|
863
|
+
const order = { online: 0, unknown: 1, offline: 2 };
|
|
864
|
+
return (order[devStatus(a)] ?? 1) - (order[devStatus(b)] ?? 1);
|
|
865
|
+
});
|
|
866
|
+
const shown = sorted.slice(0, maxPerTier);
|
|
867
|
+
const hidden = sorted.length - shown.length;
|
|
868
|
+
const isLast = ti === tiers.length - 1;
|
|
869
|
+
|
|
870
|
+
return html`
|
|
871
|
+
<${Box} key=${tier.label} flexDirection="column">
|
|
872
|
+
|
|
873
|
+
<!-- Vertical connector to this tier -->
|
|
874
|
+
<${Box}>
|
|
875
|
+
<${Text} color=${C.muted}> │<//>
|
|
876
|
+
<//>
|
|
877
|
+
|
|
878
|
+
<!-- Tier header box -->
|
|
879
|
+
<${Box}>
|
|
880
|
+
<${Text} color=${C.muted}> ┌──────┴──────────────────────────────<//>
|
|
881
|
+
<//>
|
|
882
|
+
<${Box}>
|
|
883
|
+
<${Text} color=${C.muted}> │ <//>
|
|
884
|
+
<${Text} color=${tier.color} bold>${tier.icon} ${tier.label.padEnd(14)}<//>
|
|
885
|
+
<${Text} color=${C.success}> ● ${on} up<//>
|
|
886
|
+
${off > 0 ? html`<${Text} color=${C.error}> ○ ${off} down<//>` : null}
|
|
887
|
+
${other > 0 ? html`<${Text} color=${C.warning}> ◌ ${other} unk<//>` : null}
|
|
888
|
+
<//>
|
|
889
|
+
<${Box}>
|
|
890
|
+
<${Text} color=${C.muted}> └─┬────────────────────────────────<//>
|
|
891
|
+
<//>
|
|
892
|
+
|
|
893
|
+
<!-- Device rows -->
|
|
894
|
+
${shown.map((d, i) => {
|
|
895
|
+
const st = devStatus(d);
|
|
896
|
+
const { dot, color } = statusDot(d);
|
|
897
|
+
const isLastItem = i === shown.length - 1 && hidden === 0;
|
|
898
|
+
const branch = isLastItem ? ' └─' : ' ├─';
|
|
899
|
+
const name = (d.name || d.device_name || `#${d.id}`).slice(0, nameW - 1).padEnd(nameW);
|
|
900
|
+
const ip = (d.ip_address || '─').slice(0, ipW - 1).padEnd(ipW);
|
|
901
|
+
const ago = fmtAgo(d.last_check || d.last_seen);
|
|
902
|
+
const badge = st === 'online' ? 'UP ' : st === 'offline' ? 'DOWN' : 'UNKN';
|
|
903
|
+
const badgeColor = st === 'online' ? C.success : st === 'offline' ? C.error : C.warning;
|
|
904
|
+
return html`
|
|
905
|
+
<${Box} key=${d.id}>
|
|
906
|
+
<${Text} color=${C.muted}> ${branch} <//>
|
|
907
|
+
<${Text} color=${color}>${dot} <//>
|
|
908
|
+
<${Text}>${name}<//>
|
|
909
|
+
<${Text} color=${C.muted}>${ip}<//>
|
|
910
|
+
<${Text} color=${badgeColor} bold>[${badge}]<//>
|
|
911
|
+
${ago ? html`<${Text} color=${C.muted} dimColor> ${ago}<//>` : null}
|
|
912
|
+
<//>`;
|
|
913
|
+
})}
|
|
914
|
+
${hidden > 0
|
|
915
|
+
? html`<${Box}><${Text} color=${C.muted} dimColor> └─ …+${hidden} more<//> <//>`
|
|
916
|
+
: null}
|
|
917
|
+
<//>`;
|
|
918
|
+
})}
|
|
919
|
+
|
|
920
|
+
<!-- End cap -->
|
|
921
|
+
<${Box} marginTop=${1}>
|
|
922
|
+
<${Text} color=${C.muted} dimColor> (topology end)<//>
|
|
923
|
+
<//>
|
|
924
|
+
<//>
|
|
925
|
+
|
|
926
|
+
<${Box} paddingX=${2} marginTop=${1}>
|
|
927
|
+
<${Text} color=${C.separator}>${'─'.repeat(Math.max(1, bw))}<//>
|
|
928
|
+
<//>
|
|
929
|
+
<${Box} paddingX=${2}>
|
|
930
|
+
<${Text} color=${C.muted} dimColor>r refresh${ts ? ' ' + midDot() + ' updated ' + ts : ''} ${midDot()} ● up ○ down ◌ unknown ${midDot()} /topology for AI analysis<//>
|
|
931
|
+
<//>
|
|
932
|
+
<//>`;
|
|
933
|
+
}
|