@tracehound/cli 1.2.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 (45) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +17 -0
  3. package/dist/commands/inspect.d.ts +6 -0
  4. package/dist/commands/inspect.d.ts.map +1 -0
  5. package/dist/commands/inspect.js +108 -0
  6. package/dist/commands/stats.d.ts +6 -0
  7. package/dist/commands/stats.d.ts.map +1 -0
  8. package/dist/commands/stats.js +80 -0
  9. package/dist/commands/status.d.ts +6 -0
  10. package/dist/commands/status.d.ts.map +1 -0
  11. package/dist/commands/status.js +98 -0
  12. package/dist/commands/watch.d.ts +45 -0
  13. package/dist/commands/watch.d.ts.map +1 -0
  14. package/dist/commands/watch.js +184 -0
  15. package/dist/index.d.ts +13 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +31 -0
  18. package/dist/lib/theme.d.ts +39 -0
  19. package/dist/lib/theme.d.ts.map +1 -0
  20. package/dist/lib/theme.js +95 -0
  21. package/dist/tui/App.d.ts +10 -0
  22. package/dist/tui/App.d.ts.map +1 -0
  23. package/dist/tui/App.js +19 -0
  24. package/dist/tui/hooks/useSnapshot.d.ts +29 -0
  25. package/dist/tui/hooks/useSnapshot.d.ts.map +1 -0
  26. package/dist/tui/hooks/useSnapshot.js +41 -0
  27. package/dist/tui/panels/Audit.d.ts +15 -0
  28. package/dist/tui/panels/Audit.d.ts.map +1 -0
  29. package/dist/tui/panels/Audit.js +9 -0
  30. package/dist/tui/panels/HoundPool.d.ts +16 -0
  31. package/dist/tui/panels/HoundPool.d.ts.map +1 -0
  32. package/dist/tui/panels/HoundPool.js +15 -0
  33. package/dist/tui/panels/Quarantine.d.ts +21 -0
  34. package/dist/tui/panels/Quarantine.d.ts.map +1 -0
  35. package/dist/tui/panels/Quarantine.js +18 -0
  36. package/package.json +48 -0
  37. package/src/commands/inspect.ts +142 -0
  38. package/src/commands/stats.ts +124 -0
  39. package/src/commands/status.ts +144 -0
  40. package/src/commands/watch.ts +273 -0
  41. package/src/index.ts +40 -0
  42. package/src/lib/theme.ts +117 -0
  43. package/tests/commands.test.ts +226 -0
  44. package/tests/smoke.test.ts +27 -0
  45. package/tsconfig.json +23 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Inspect command - Inspect quarantine contents
3
+ */
4
+
5
+ import Table from 'cli-table3'
6
+ import { Command } from 'commander'
7
+
8
+ export const inspectCommand = new Command('inspect')
9
+ .description('Inspect quarantine contents')
10
+ .option('-s, --signature <sig>', 'Inspect specific signature')
11
+ .option('-l, --limit <n>', 'Limit results', '10')
12
+ .option('-j, --json', 'Output as JSON')
13
+ .action((options) => {
14
+ if (options.signature) {
15
+ inspectSingle(options.signature, options.json)
16
+ } else {
17
+ inspectList(parseInt(options.limit), options.json)
18
+ }
19
+ })
20
+
21
+ interface QuarantineEntry {
22
+ signature: string
23
+ severity: 'critical' | 'high' | 'medium' | 'low'
24
+ category: string
25
+ size: number
26
+ captured: number
27
+ source: string
28
+ }
29
+
30
+ function getQuarantineEntries(limit: number): QuarantineEntry[] {
31
+ // TODO: Connect to real core when available
32
+ void limit
33
+ return []
34
+ }
35
+
36
+ function getEntry(signature: string): QuarantineEntry | null {
37
+ // TODO: Connect to real core when available
38
+ void signature
39
+ return null
40
+ }
41
+
42
+ function inspectSingle(signature: string, json: boolean): void {
43
+ const entry = getEntry(signature)
44
+
45
+ if (!entry) {
46
+ console.log(`\n ❌ Evidence not found: ${signature}\n`)
47
+ return
48
+ }
49
+
50
+ if (json) {
51
+ console.log(JSON.stringify(entry, null, 2))
52
+ } else {
53
+ printEntry(entry)
54
+ }
55
+ }
56
+
57
+ function inspectList(limit: number, json: boolean): void {
58
+ const entries = getQuarantineEntries(limit)
59
+
60
+ if (json) {
61
+ console.log(JSON.stringify(entries, null, 2))
62
+ return
63
+ }
64
+
65
+ if (entries.length === 0) {
66
+ console.log('\n 📭 Quarantine is empty\n')
67
+ return
68
+ }
69
+
70
+ // Header
71
+ console.log('\n ╔══════════════════════════════════════════════════════════════╗')
72
+ console.log(' ║ QUARANTINE CONTENTS ║')
73
+ console.log(' ╚══════════════════════════════════════════════════════════════╝\n')
74
+
75
+ const table = new Table({
76
+ head: ['Signature', 'Severity', 'Category', 'Size', 'Source'],
77
+ style: { head: ['cyan'], border: ['gray'] },
78
+ colWidths: [16, 12, 12, 12, 18],
79
+ })
80
+
81
+ for (const entry of entries) {
82
+ const severityIcon = getSeverityIcon(entry.severity)
83
+ table.push([
84
+ entry.signature.slice(0, 12) + '...',
85
+ `${severityIcon} ${entry.severity}`,
86
+ entry.category,
87
+ formatBytes(entry.size),
88
+ entry.source.slice(0, 15),
89
+ ])
90
+ }
91
+
92
+ console.log(table.toString())
93
+ console.log()
94
+ }
95
+
96
+ function printEntry(entry: QuarantineEntry): void {
97
+ const severityIcon = getSeverityIcon(entry.severity)
98
+
99
+ // Header
100
+ console.log('\n ╔══════════════════════════════════════════════════════════════╗')
101
+ console.log(' ║ EVIDENCE DETAILS ║')
102
+ console.log(' ╚══════════════════════════════════════════════════════════════╝\n')
103
+
104
+ const table = new Table({
105
+ style: { border: ['gray'] },
106
+ })
107
+
108
+ table.push(
109
+ { Signature: entry.signature },
110
+ { Severity: `${severityIcon} ${entry.severity}` },
111
+ { Category: entry.category },
112
+ { Size: formatBytes(entry.size) },
113
+ { Source: entry.source },
114
+ { Captured: new Date(entry.captured).toISOString() }
115
+ )
116
+
117
+ console.log(table.toString())
118
+ console.log()
119
+ }
120
+
121
+ function getSeverityIcon(severity: string): string {
122
+ switch (severity) {
123
+ case 'critical':
124
+ return '🔴'
125
+ case 'high':
126
+ return '🟠'
127
+ case 'medium':
128
+ return '🟡'
129
+ case 'low':
130
+ return '🟢'
131
+ default:
132
+ return '⚪'
133
+ }
134
+ }
135
+
136
+ function formatBytes(bytes: number): string {
137
+ if (bytes === 0) return '0 B'
138
+ const k = 1024
139
+ const sizes = ['B', 'KB', 'MB', 'GB']
140
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
141
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
142
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Stats command - Show threat statistics
3
+ */
4
+
5
+ import Table from 'cli-table3'
6
+ import { Command } from 'commander'
7
+
8
+ export const statsCommand = new Command('stats')
9
+ .description('Show threat statistics')
10
+ .option('-j, --json', 'Output as JSON')
11
+ .option('--since <duration>', 'Time window (e.g., 1h, 24h, 7d)', '24h')
12
+ .action((options) => {
13
+ const stats = getStats(options.since)
14
+
15
+ if (options.json) {
16
+ console.log(JSON.stringify(stats, null, 2))
17
+ } else {
18
+ printStats(stats)
19
+ }
20
+ })
21
+
22
+ interface ThreatStats {
23
+ window: string
24
+ total: number
25
+ bySeverity: {
26
+ critical: number
27
+ high: number
28
+ medium: number
29
+ low: number
30
+ }
31
+ byCategory: {
32
+ injection: number
33
+ ddos: number
34
+ other: number
35
+ }
36
+ outcomes: {
37
+ quarantined: number
38
+ rateLimited: number
39
+ clean: number
40
+ ignored: number
41
+ }
42
+ }
43
+
44
+ function getStats(_since: string): ThreatStats {
45
+ // TODO: Connect to real core when available
46
+ return {
47
+ window: _since,
48
+ total: 0,
49
+ bySeverity: {
50
+ critical: 0,
51
+ high: 0,
52
+ medium: 0,
53
+ low: 0,
54
+ },
55
+ byCategory: {
56
+ injection: 0,
57
+ ddos: 0,
58
+ other: 0,
59
+ },
60
+ outcomes: {
61
+ quarantined: 0,
62
+ rateLimited: 0,
63
+ clean: 0,
64
+ ignored: 0,
65
+ },
66
+ }
67
+ }
68
+
69
+ function printStats(stats: ThreatStats): void {
70
+ // Header
71
+ console.log('\n ╔══════════════════════════════════════════════════════════════╗')
72
+ console.log(` ║ THREAT STATISTICS (${stats.window.padEnd(24)}) ║`)
73
+ console.log(' ╚══════════════════════════════════════════════════════════════╝\n')
74
+
75
+ // Summary
76
+ const summaryTable = new Table({
77
+ head: ['Metric', 'Value'],
78
+ style: { head: ['cyan'], border: ['gray'] },
79
+ })
80
+ summaryTable.push(['Total Threats', String(stats.total)], ['Time Window', stats.window])
81
+ console.log(summaryTable.toString())
82
+ console.log()
83
+
84
+ // By Severity
85
+ const severityTable = new Table({
86
+ head: ['Severity', 'Count'],
87
+ style: { head: ['red'], border: ['gray'] },
88
+ })
89
+ severityTable.push(
90
+ ['🔴 Critical', String(stats.bySeverity.critical)],
91
+ ['🟠 High', String(stats.bySeverity.high)],
92
+ ['🟡 Medium', String(stats.bySeverity.medium)],
93
+ ['🟢 Low', String(stats.bySeverity.low)]
94
+ )
95
+ console.log(severityTable.toString())
96
+ console.log()
97
+
98
+ // By Category
99
+ const categoryTable = new Table({
100
+ head: ['Category', 'Count'],
101
+ style: { head: ['yellow'], border: ['gray'] },
102
+ })
103
+ categoryTable.push(
104
+ ['💉 Injection', String(stats.byCategory.injection)],
105
+ ['🌊 DDoS', String(stats.byCategory.ddos)],
106
+ ['❓ Other', String(stats.byCategory.other)]
107
+ )
108
+ console.log(categoryTable.toString())
109
+ console.log()
110
+
111
+ // Outcomes
112
+ const outcomesTable = new Table({
113
+ head: ['Outcome', 'Count'],
114
+ style: { head: ['green'], border: ['gray'] },
115
+ })
116
+ outcomesTable.push(
117
+ ['🔒 Quarantined', String(stats.outcomes.quarantined)],
118
+ ['⏱️ Rate Limited', String(stats.outcomes.rateLimited)],
119
+ ['✅ Clean', String(stats.outcomes.clean)],
120
+ ['⏭️ Ignored', String(stats.outcomes.ignored)]
121
+ )
122
+ console.log(outcomesTable.toString())
123
+ console.log()
124
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Status command - Show current system status
3
+ */
4
+
5
+ import Table from 'cli-table3'
6
+ import { Command } from 'commander'
7
+ import { createRequire } from 'module'
8
+ const require = createRequire(import.meta.url)
9
+ const { version } = require('../../package.json')
10
+
11
+ export const statusCommand = new Command('status')
12
+ .description('Show current Tracehound system status')
13
+ .option('-j, --json', 'Output as JSON')
14
+ .action((options) => {
15
+ const status = getSystemStatus()
16
+
17
+ if (options.json) {
18
+ console.log(JSON.stringify(status, null, 2))
19
+ } else {
20
+ printStatus(status)
21
+ }
22
+ })
23
+
24
+ interface SystemStatus {
25
+ version: string
26
+ uptime: number
27
+ health: 'healthy' | 'degraded' | 'critical'
28
+ quarantine: {
29
+ count: number
30
+ bytes: number
31
+ capacity: number
32
+ }
33
+ rateLimit: {
34
+ blocked: number
35
+ active: number
36
+ }
37
+ houndPool: {
38
+ active: number
39
+ dormant: number
40
+ total: number
41
+ }
42
+ }
43
+
44
+ function getSystemStatus(): SystemStatus {
45
+ // TODO: Connect to real core when available
46
+ return {
47
+ version: version,
48
+ uptime: Math.floor(process.uptime()),
49
+ health: 'healthy',
50
+ quarantine: {
51
+ count: 0,
52
+ bytes: 0,
53
+ capacity: 1000,
54
+ },
55
+ rateLimit: {
56
+ blocked: 0,
57
+ active: 0,
58
+ },
59
+ houndPool: {
60
+ active: 0,
61
+ dormant: 0,
62
+ total: 0,
63
+ },
64
+ }
65
+ }
66
+
67
+ function printStatus(status: SystemStatus): void {
68
+ const healthIcon = status.health === 'healthy' ? '✅' : status.health === 'degraded' ? '⚠️' : '🔴'
69
+
70
+ // Header
71
+ console.log('\n ╔══════════════════════════════════════════════════════════════╗')
72
+ console.log(' ║ TRACEHOUND STATUS ║')
73
+ console.log(' ╚══════════════════════════════════════════════════════════════╝\n')
74
+
75
+ // System Info Table
76
+ const systemTable = new Table({
77
+ head: ['Property', 'Value'],
78
+ style: { head: ['cyan'], border: ['gray'] },
79
+ })
80
+ systemTable.push(
81
+ ['Version', status.version],
82
+ ['Uptime', formatUptime(status.uptime)],
83
+ ['Health', `${healthIcon} ${status.health}`],
84
+ )
85
+ console.log(systemTable.toString())
86
+ console.log()
87
+
88
+ // Quarantine Table
89
+ const quarantineTable = new Table({
90
+ head: ['QUARANTINE', 'Value'],
91
+ style: { head: ['yellow'], border: ['gray'] },
92
+ })
93
+ const usage =
94
+ status.quarantine.capacity > 0
95
+ ? ((status.quarantine.count / status.quarantine.capacity) * 100).toFixed(1)
96
+ : '0.0'
97
+ quarantineTable.push(
98
+ ['Count', `${status.quarantine.count} / ${status.quarantine.capacity}`],
99
+ ['Usage', `${usage}%`],
100
+ ['Bytes', formatBytes(status.quarantine.bytes)],
101
+ )
102
+ console.log(quarantineTable.toString())
103
+ console.log()
104
+
105
+ // Rate Limit Table
106
+ const rateLimitTable = new Table({
107
+ head: ['RATE LIMIT', 'Value'],
108
+ style: { head: ['magenta'], border: ['gray'] },
109
+ })
110
+ rateLimitTable.push(
111
+ ['Blocked', String(status.rateLimit.blocked)],
112
+ ['Active', String(status.rateLimit.active)],
113
+ )
114
+ console.log(rateLimitTable.toString())
115
+ console.log()
116
+
117
+ // Hound Pool Table
118
+ const poolTable = new Table({
119
+ head: ['HOUND POOL', 'Value'],
120
+ style: { head: ['green'], border: ['gray'] },
121
+ })
122
+ poolTable.push(
123
+ ['Active', String(status.houndPool.active)],
124
+ ['Dormant', String(status.houndPool.dormant)],
125
+ ['Total', String(status.houndPool.total)],
126
+ )
127
+ console.log(poolTable.toString())
128
+ console.log()
129
+ }
130
+
131
+ function formatUptime(seconds: number): string {
132
+ const h = Math.floor(seconds / 3600)
133
+ const m = Math.floor((seconds % 3600) / 60)
134
+ const s = seconds % 60
135
+ return `${h}h ${m}m ${s}s`
136
+ }
137
+
138
+ function formatBytes(bytes: number): string {
139
+ if (bytes === 0) return '0 B'
140
+ const k = 1024
141
+ const sizes = ['B', 'KB', 'MB', 'GB']
142
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
143
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
144
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Watch command - Live dashboard (Pure ANSI, no React)
3
+ */
4
+
5
+ import Table from 'cli-table3'
6
+ import { Command } from 'commander'
7
+ import { createRequire } from 'module'
8
+ import {
9
+ bold,
10
+ clearScreen,
11
+ hideCursor,
12
+ muted,
13
+ primary,
14
+ progressBar,
15
+ secondary,
16
+ severity,
17
+ showCursor,
18
+ theme,
19
+ } from '../lib/theme.js'
20
+
21
+ const require = createRequire(import.meta.url)
22
+ const { version } = require('../../package.json')
23
+
24
+ export const watchCommand = new Command('watch')
25
+ .description('Launch live dashboard')
26
+ .option('-r, --refresh <ms>', 'Refresh interval in ms', '1000')
27
+ .action((options) => {
28
+ const refreshMs = parseInt(options.refresh)
29
+ startDashboard(refreshMs)
30
+ })
31
+
32
+ interface Snapshot {
33
+ timestamp: string
34
+ system: {
35
+ version: string
36
+ uptime: string
37
+ health: 'healthy' | 'degraded' | 'critical'
38
+ memory: { used: number; total: number }
39
+ }
40
+ quarantine: {
41
+ count: number
42
+ capacity: number
43
+ bytes: number
44
+ bySeverity: { critical: number; high: number; medium: number; low: number }
45
+ }
46
+ houndPool: {
47
+ active: number
48
+ dormant: number
49
+ total: number
50
+ status: 'ok' | 'exhausted'
51
+ }
52
+ recentThreats: Array<{
53
+ signature: string
54
+ severity: string
55
+ category: string
56
+ size: string
57
+ time: string
58
+ }>
59
+ }
60
+
61
+ export function getSnapshot(): Snapshot {
62
+ // TODO: Connect to real core
63
+ return {
64
+ timestamp: new Date().toISOString(),
65
+ system: {
66
+ version: version,
67
+ uptime: formatUptime(Math.floor(process.uptime())),
68
+ health: 'healthy',
69
+ memory: { used: 45, total: 256 },
70
+ },
71
+ quarantine: {
72
+ count: 0,
73
+ capacity: 1000,
74
+ bytes: 0,
75
+ bySeverity: { critical: 0, high: 0, medium: 0, low: 0 },
76
+ },
77
+ houndPool: {
78
+ active: 0,
79
+ dormant: 0,
80
+ total: 0,
81
+ status: 'ok',
82
+ },
83
+ recentThreats: [],
84
+ }
85
+ }
86
+
87
+ function startDashboard(refreshMs: number): void {
88
+ hideCursor()
89
+
90
+ // Handle Ctrl+C gracefully
91
+ process.on('SIGINT', () => {
92
+ showCursor()
93
+ clearScreen()
94
+ console.log(muted('\n Dashboard closed.\n'))
95
+ process.exit(0)
96
+ })
97
+
98
+ const render = () => {
99
+ clearScreen()
100
+ const snapshot = getSnapshot()
101
+ renderDashboard(snapshot, refreshMs)
102
+ }
103
+
104
+ render()
105
+ setInterval(render, refreshMs)
106
+ }
107
+
108
+ export function renderDashboard(s: Snapshot, refreshMs: number): void {
109
+ const width = 76
110
+
111
+ // Header
112
+ console.log()
113
+ console.log(primary(` ╔${'═'.repeat(width)}╗`))
114
+ console.log(
115
+ primary(` ║${' '.repeat(20)}`) +
116
+ bold('🐕 TRACEHOUND LIVE DASHBOARD') +
117
+ primary(`${' '.repeat(28)}║`),
118
+ )
119
+ console.log(primary(` ║${' '.repeat(24)}`) + muted(s.timestamp) + primary(`${' '.repeat(28)}║`))
120
+ console.log(primary(` ╚${'═'.repeat(width)}╝`))
121
+ console.log()
122
+
123
+ // System Status
124
+ const systemTable = new Table({
125
+ chars: getTableChars(),
126
+ style: { head: [], border: [] },
127
+ head: [secondary('Version'), secondary('Uptime'), secondary('Health'), secondary('Memory')],
128
+ })
129
+
130
+ const healthIcon =
131
+ s.system.health === 'healthy' ? '✅' : s.system.health === 'degraded' ? '⚠️' : '🔴'
132
+ const memBar = progressBar(s.system.memory.used, s.system.memory.total, 10)
133
+
134
+ systemTable.push([
135
+ s.system.version,
136
+ s.system.uptime,
137
+ `${healthIcon} ${s.system.health}`,
138
+ `${memBar} ${s.system.memory.used}/${s.system.memory.total} MB`,
139
+ ])
140
+
141
+ console.log(muted(' SYSTEM'))
142
+ console.log(indent(systemTable.toString()))
143
+ console.log()
144
+
145
+ // Quarantine & Hound Pool side by side
146
+ const quarantineTable = new Table({
147
+ chars: getTableChars(),
148
+ style: { head: [], border: [] },
149
+ head: [secondary('QUARANTINE'), secondary('Value')],
150
+ })
151
+
152
+ const qUsage = s.quarantine.capacity > 0 ? (s.quarantine.count / s.quarantine.capacity) * 100 : 0
153
+ const qBar = progressBar(s.quarantine.count, s.quarantine.capacity, 8)
154
+
155
+ quarantineTable.push(
156
+ ['Count', `${s.quarantine.count} / ${s.quarantine.capacity}`],
157
+ ['Usage', `${qBar} ${qUsage.toFixed(1)}%`],
158
+ ['Bytes', formatBytes(s.quarantine.bytes)],
159
+ [
160
+ 'Split',
161
+ `${severity('critical').slice(0, 15)} ${s.quarantine.bySeverity.critical} ${severity(
162
+ 'high',
163
+ ).slice(0, 12)} ${s.quarantine.bySeverity.high} ${severity('medium').slice(0, 12)} ${
164
+ s.quarantine.bySeverity.medium
165
+ } ${severity('low').slice(0, 10)} ${s.quarantine.bySeverity.low}`,
166
+ ],
167
+ )
168
+
169
+ const poolTable = new Table({
170
+ chars: getTableChars(),
171
+ style: { head: [], border: [] },
172
+ head: [secondary('HOUND POOL'), secondary('Value')],
173
+ })
174
+
175
+ const poolBar = progressBar(s.houndPool.active, s.houndPool.total || 1, 8)
176
+ const poolStatus = s.houndPool.status === 'ok' ? '✅ OK' : '🔴 EXHAUSTED'
177
+
178
+ poolTable.push(
179
+ ['Active', `${poolBar} ${s.houndPool.active}/${s.houndPool.total}`],
180
+ ['Dormant', String(s.houndPool.dormant)],
181
+ ['Status', poolStatus],
182
+ )
183
+
184
+ console.log(indent(quarantineTable.toString()))
185
+ console.log()
186
+ console.log(indent(poolTable.toString()))
187
+ console.log()
188
+
189
+ // Recent Threats
190
+ if (s.recentThreats.length > 0) {
191
+ const threatTable = new Table({
192
+ chars: getTableChars(),
193
+ style: { head: [], border: [] },
194
+ head: [
195
+ secondary('Signature'),
196
+ secondary('Severity'),
197
+ secondary('Category'),
198
+ secondary('Size'),
199
+ secondary('Time'),
200
+ ],
201
+ })
202
+
203
+ for (const t of s.recentThreats.slice(0, 5)) {
204
+ threatTable.push([
205
+ t.signature.slice(0, 12) + '...',
206
+ severity(t.severity),
207
+ t.category,
208
+ t.size,
209
+ t.time,
210
+ ])
211
+ }
212
+
213
+ console.log(muted(' RECENT THREATS'))
214
+ console.log(indent(threatTable.toString()))
215
+ console.log()
216
+ } else {
217
+ console.log(muted(' RECENT THREATS'))
218
+ console.log(muted(' 📭 No recent threats'))
219
+ console.log()
220
+ }
221
+
222
+ // Footer
223
+ console.log(muted(` ${'─'.repeat(width)}`))
224
+ console.log(
225
+ muted(
226
+ ` Press Ctrl+C to exit │ Refresh: ${refreshMs}ms │ ${theme.reset}${secondary(
227
+ new Date().toLocaleTimeString(),
228
+ )}`,
229
+ ),
230
+ )
231
+ }
232
+
233
+ function getTableChars() {
234
+ return {
235
+ top: '─',
236
+ 'top-mid': '┬',
237
+ 'top-left': '┌',
238
+ 'top-right': '┐',
239
+ bottom: '─',
240
+ 'bottom-mid': '┴',
241
+ 'bottom-left': '└',
242
+ 'bottom-right': '┘',
243
+ left: '│',
244
+ 'left-mid': '├',
245
+ mid: '─',
246
+ 'mid-mid': '┼',
247
+ right: '│',
248
+ 'right-mid': '┤',
249
+ middle: '│',
250
+ }
251
+ }
252
+
253
+ function indent(text: string, spaces = 2): string {
254
+ return text
255
+ .split('\n')
256
+ .map((line) => ' '.repeat(spaces) + line)
257
+ .join('\n')
258
+ }
259
+
260
+ function formatUptime(seconds: number): string {
261
+ const h = Math.floor(seconds / 3600)
262
+ const m = Math.floor((seconds % 3600) / 60)
263
+ const s = seconds % 60
264
+ return `${h}h ${m}m ${s}s`
265
+ }
266
+
267
+ function formatBytes(bytes: number): string {
268
+ if (bytes === 0) return '0 B'
269
+ const k = 1024
270
+ const sizes = ['B', 'KB', 'MB', 'GB']
271
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
272
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
273
+ }