@muhammedaksam/easiarr 0.4.3 → 0.5.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.
@@ -0,0 +1,866 @@
1
+ /**
2
+ * Monitor Dashboard Screen
3
+ * Three-panel master-detail layout for configuring health monitoring
4
+ */
5
+
6
+ import type { CliRenderer, KeyEvent } from "@opentui/core"
7
+ import { BoxRenderable, TextRenderable, TabSelectRenderable, TabSelectRenderableEvents } from "@opentui/core"
8
+ import type { EasiarrConfig, AppCategory, AppId, MonitorOptions, MonitorConfig } from "../../config/schema"
9
+ import { APP_CATEGORIES } from "../../config/schema"
10
+ import { createPageLayout } from "../components/PageLayout"
11
+ import { APPS } from "../../apps/registry"
12
+ import { saveConfig } from "../../config/manager"
13
+ import {
14
+ ArrApiClient,
15
+ type HealthResource,
16
+ type DiskSpaceResource,
17
+ type SystemResource,
18
+ type QueueStatusResource,
19
+ } from "../../api/arr-api"
20
+
21
+ // Default monitoring options
22
+ const DEFAULT_CHECKS: MonitorOptions = {
23
+ health: true,
24
+ diskspace: true,
25
+ status: true,
26
+ queue: true,
27
+ }
28
+
29
+ // Categories that support monitoring (have compatible APIs)
30
+ const MONITORABLE_CATEGORIES: AppCategory[] = ["servarr", "indexer"]
31
+
32
+ // Check type labels
33
+ const CHECK_LABELS: Record<keyof MonitorOptions, string> = {
34
+ health: "Health Warnings",
35
+ diskspace: "Disk Space",
36
+ status: "System Status",
37
+ queue: "Queue Info",
38
+ }
39
+
40
+ export class MonitorDashboard extends BoxRenderable {
41
+ private config: EasiarrConfig
42
+ private onBack: () => void
43
+ private _renderer: CliRenderer
44
+
45
+ private page!: BoxRenderable
46
+ private modeTabs!: TabSelectRenderable
47
+ private categoriesPanel!: BoxRenderable
48
+ private appsPanel!: BoxRenderable
49
+ private checksPanel!: BoxRenderable
50
+
51
+ // State
52
+ private currentMode: "configure" | "status" = "configure"
53
+ private currentPanel: 0 | 1 | 2 = 0 // 0=categories, 1=apps, 2=checks
54
+ private categoryIndex = 0
55
+ private appIndex = 0
56
+ private checkIndex = 0
57
+
58
+ // Data
59
+ private categoryConfigs: Map<AppCategory, { enabled: boolean; checks: MonitorOptions }>
60
+ private appConfigs: Map<AppId, { override: boolean; enabled: boolean; checks: MonitorOptions }>
61
+ private availableCategories: AppCategory[] = []
62
+ private keyHandler: (key: KeyEvent) => void
63
+
64
+ // Status data
65
+ private statusData: Map<
66
+ AppId,
67
+ {
68
+ loading: boolean
69
+ error?: string
70
+ health?: HealthResource[]
71
+ disk?: DiskSpaceResource[]
72
+ system?: SystemResource
73
+ queue?: QueueStatusResource
74
+ }
75
+ > = new Map()
76
+
77
+ constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
78
+ super(renderer, {
79
+ width: "100%",
80
+ height: "100%",
81
+ flexDirection: "column",
82
+ })
83
+
84
+ this._renderer = renderer
85
+ this.config = config
86
+ this.onBack = onBack
87
+
88
+ this.categoryConfigs = new Map()
89
+ this.appConfigs = new Map()
90
+ this.loadFromConfig()
91
+ this.computeAvailableCategories()
92
+
93
+ this.keyHandler = this.handleKey.bind(this)
94
+ this.buildUI()
95
+ }
96
+
97
+ private loadFromConfig(): void {
98
+ const monitor = this.config.monitor
99
+
100
+ for (const cat of MONITORABLE_CATEGORIES) {
101
+ const existing = monitor?.categories?.find((c) => c.category === cat)
102
+ if (existing) {
103
+ this.categoryConfigs.set(cat, {
104
+ enabled: existing.enabled,
105
+ checks: { ...existing.checks },
106
+ })
107
+ } else {
108
+ this.categoryConfigs.set(cat, {
109
+ enabled: true,
110
+ checks: { ...DEFAULT_CHECKS },
111
+ })
112
+ }
113
+ }
114
+
115
+ const arrApps = this.getMonitorableApps()
116
+ for (const app of arrApps) {
117
+ const existing = monitor?.apps?.find((a) => a.appId === app.id)
118
+ if (existing) {
119
+ this.appConfigs.set(app.id, {
120
+ override: existing.override,
121
+ enabled: existing.enabled,
122
+ checks: { ...existing.checks },
123
+ })
124
+ } else {
125
+ this.appConfigs.set(app.id, {
126
+ override: false,
127
+ enabled: true,
128
+ checks: { ...DEFAULT_CHECKS },
129
+ })
130
+ }
131
+ }
132
+ }
133
+
134
+ private computeAvailableCategories(): void {
135
+ // Only show categories that have at least one enabled app
136
+ this.availableCategories = MONITORABLE_CATEGORIES.filter((cat) => {
137
+ const apps = this.getAppsInCategory(cat)
138
+ return apps.length > 0
139
+ })
140
+ }
141
+
142
+ private getMonitorableApps() {
143
+ return this.config.apps
144
+ .filter((a) => a.enabled)
145
+ .map((a) => APPS[a.id])
146
+ .filter((app) => app && MONITORABLE_CATEGORIES.includes(app.category))
147
+ }
148
+
149
+ private getAppsInCategory(category: AppCategory) {
150
+ return this.config.apps
151
+ .filter((a) => a.enabled)
152
+ .map((a) => APPS[a.id])
153
+ .filter((app) => app && app.category === category)
154
+ }
155
+
156
+ private buildUI(): void {
157
+ const { container: page, content } = createPageLayout(this._renderer, {
158
+ title: "Monitor Dashboard",
159
+ stepInfo: "Configure Health Monitoring",
160
+ footerHint: [
161
+ { type: "key", key: "Tab", value: "Panel" },
162
+ { type: "key", key: "↑↓", value: "Navigate" },
163
+ { type: "key", key: "Space", value: "Toggle" },
164
+ { type: "key", key: "1-4", value: "Checks" },
165
+ { type: "key", key: "←→", value: "Mode" },
166
+ { type: "key", key: "s", value: "Save" },
167
+ { type: "key", key: "q", value: "Back" },
168
+ ],
169
+ })
170
+ this.page = page
171
+
172
+ // Mode tabs
173
+ this.modeTabs = new TabSelectRenderable(this._renderer, {
174
+ width: "100%",
175
+ options: [
176
+ { name: "Configure", value: "configure", description: "" },
177
+ { name: "Status", value: "status", description: "" },
178
+ ],
179
+ tabWidth: 12,
180
+ showUnderline: false,
181
+ showDescription: false,
182
+ selectedBackgroundColor: "#4a9eff",
183
+ textColor: "#555555",
184
+ })
185
+
186
+ this.modeTabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index) => {
187
+ this.currentMode = index === 0 ? "configure" : "status"
188
+ this.updateAllPanels()
189
+ })
190
+
191
+ content.add(this.modeTabs)
192
+ content.add(new TextRenderable(this._renderer, { content: " " }))
193
+
194
+ // Three-panel row
195
+ const panelRow = new BoxRenderable(this._renderer, {
196
+ width: "100%",
197
+ flexGrow: 1,
198
+ flexDirection: "row",
199
+ })
200
+
201
+ // Categories panel (left)
202
+ this.categoriesPanel = new BoxRenderable(this._renderer, {
203
+ width: "25%",
204
+ height: "100%",
205
+ flexDirection: "column",
206
+ border: true,
207
+ borderStyle: "single",
208
+ borderColor: "#4a9eff",
209
+ title: "Categories",
210
+ titleAlignment: "left",
211
+ backgroundColor: "#151525",
212
+ })
213
+
214
+ // Apps panel (middle)
215
+ this.appsPanel = new BoxRenderable(this._renderer, {
216
+ width: "35%",
217
+ height: "100%",
218
+ flexDirection: "column",
219
+ border: true,
220
+ borderStyle: "single",
221
+ borderColor: "#555555",
222
+ title: "Apps",
223
+ titleAlignment: "left",
224
+ backgroundColor: "#151525",
225
+ })
226
+
227
+ // Checks panel (right)
228
+ this.checksPanel = new BoxRenderable(this._renderer, {
229
+ width: "40%",
230
+ height: "100%",
231
+ flexDirection: "column",
232
+ border: true,
233
+ borderStyle: "single",
234
+ borderColor: "#555555",
235
+ title: "Checks",
236
+ titleAlignment: "left",
237
+ backgroundColor: "#151525",
238
+ })
239
+
240
+ panelRow.add(this.categoriesPanel)
241
+ panelRow.add(this.appsPanel)
242
+ panelRow.add(this.checksPanel)
243
+ content.add(panelRow)
244
+
245
+ this._renderer.keyInput.on("keypress", this.keyHandler)
246
+ this.add(page)
247
+
248
+ this.updateAllPanels()
249
+ }
250
+
251
+ private updateAllPanels(): void {
252
+ this.renderCategoriesPanel()
253
+ this.renderAppsPanel()
254
+ this.renderChecksPanel()
255
+ this.updatePanelBorders()
256
+ }
257
+
258
+ private updatePanelBorders(): void {
259
+ // Highlight focused panel
260
+ this.categoriesPanel.borderColor = this.currentPanel === 0 ? "#4a9eff" : "#555555"
261
+ this.appsPanel.borderColor = this.currentPanel === 1 ? "#4a9eff" : "#555555"
262
+ this.checksPanel.borderColor = this.currentPanel === 2 ? "#4a9eff" : "#555555"
263
+ }
264
+
265
+ private clearPanel(panel: BoxRenderable): void {
266
+ const children = panel.getChildren()
267
+ children.forEach((c) => panel.remove(c.id))
268
+ }
269
+
270
+ private renderCategoriesPanel(): void {
271
+ this.clearPanel(this.categoriesPanel)
272
+
273
+ if (this.availableCategories.length === 0) {
274
+ this.categoriesPanel.add(
275
+ new TextRenderable(this._renderer, {
276
+ content: "No apps enabled",
277
+ fg: "#888888",
278
+ })
279
+ )
280
+ return
281
+ }
282
+
283
+ this.availableCategories.forEach((cat, idx) => {
284
+ const apps = this.getAppsInCategory(cat)
285
+ const cfg = this.categoryConfigs.get(cat)!
286
+ const pointer = idx === this.categoryIndex ? "▶ " : " "
287
+ const enabledIcon = cfg.enabled ? "●" : "○"
288
+ const fg = idx === this.categoryIndex ? "#50fa7b" : "#aaaaaa"
289
+
290
+ this.categoriesPanel.add(
291
+ new TextRenderable(this._renderer, {
292
+ id: `cat-${idx}`,
293
+ content: `${pointer}${enabledIcon} ${APP_CATEGORIES[cat]} (${apps.length})`,
294
+ fg,
295
+ })
296
+ )
297
+ })
298
+ }
299
+
300
+ private renderAppsPanel(): void {
301
+ this.clearPanel(this.appsPanel)
302
+
303
+ if (this.availableCategories.length === 0) {
304
+ return
305
+ }
306
+
307
+ const selectedCat = this.availableCategories[this.categoryIndex]
308
+ if (!selectedCat) return
309
+
310
+ const apps = this.getAppsInCategory(selectedCat)
311
+
312
+ if (apps.length === 0) {
313
+ this.appsPanel.add(
314
+ new TextRenderable(this._renderer, {
315
+ content: "No apps in category",
316
+ fg: "#888888",
317
+ })
318
+ )
319
+ return
320
+ }
321
+
322
+ // Clamp appIndex
323
+ if (this.appIndex >= apps.length) {
324
+ this.appIndex = 0
325
+ }
326
+
327
+ apps.forEach((app, idx) => {
328
+ const cfg = this.appConfigs.get(app.id)
329
+ const pointer = idx === this.appIndex ? "▶ " : " "
330
+ const overrideIcon = cfg?.override ? "⚙" : " "
331
+ const enabledIcon = cfg?.enabled ? "●" : "○"
332
+ const fg = idx === this.appIndex ? "#50fa7b" : "#aaaaaa"
333
+
334
+ this.appsPanel.add(
335
+ new TextRenderable(this._renderer, {
336
+ id: `app-${idx}`,
337
+ content: `${pointer}${overrideIcon}${enabledIcon} ${app.name}`,
338
+ fg,
339
+ })
340
+ )
341
+ })
342
+ }
343
+
344
+ private renderChecksPanel(): void {
345
+ this.clearPanel(this.checksPanel)
346
+
347
+ if (this.currentMode === "status") {
348
+ this.renderStatusView()
349
+ return
350
+ }
351
+
352
+ if (this.availableCategories.length === 0) {
353
+ return
354
+ }
355
+
356
+ const selectedCat = this.availableCategories[this.categoryIndex]
357
+ if (!selectedCat) return
358
+
359
+ // When Categories panel is focused, show category-level settings
360
+ if (this.currentPanel === 0) {
361
+ this.renderCategoryChecks(selectedCat)
362
+ return
363
+ }
364
+
365
+ // When Apps or Checks panel is focused, show app-level settings
366
+ const apps = this.getAppsInCategory(selectedCat)
367
+ const selectedApp = apps[this.appIndex]
368
+ if (!selectedApp) return
369
+
370
+ const cfg = this.appConfigs.get(selectedApp.id)
371
+ if (!cfg) return
372
+
373
+ // Title
374
+ this.checksPanel.add(
375
+ new TextRenderable(this._renderer, {
376
+ id: "checks-title",
377
+ content: `${selectedApp.name}`,
378
+ fg: "#4a9eff",
379
+ attributes: 1, // bold
380
+ })
381
+ )
382
+
383
+ this.checksPanel.add(
384
+ new TextRenderable(this._renderer, {
385
+ id: "checks-divider",
386
+ content: "─".repeat(20),
387
+ fg: "#555555",
388
+ })
389
+ )
390
+
391
+ // Check toggles
392
+ const checks: (keyof MonitorOptions)[] = ["health", "diskspace", "status", "queue"]
393
+ const effectiveChecks = cfg.override ? cfg.checks : this.categoryConfigs.get(selectedCat)!.checks
394
+
395
+ checks.forEach((check, idx) => {
396
+ const isEnabled = effectiveChecks[check]
397
+ const pointer = idx === this.checkIndex ? "▶ " : " "
398
+ const icon = isEnabled ? "[✓]" : "[ ]"
399
+ const fg = idx === this.checkIndex ? "#50fa7b" : "#aaaaaa"
400
+ const dimmed = !cfg.override && idx === this.checkIndex ? " (category)" : ""
401
+
402
+ this.checksPanel.add(
403
+ new TextRenderable(this._renderer, {
404
+ id: `check-${idx}`,
405
+ content: `${pointer}${icon} ${CHECK_LABELS[check]}${dimmed}`,
406
+ fg,
407
+ })
408
+ )
409
+ })
410
+
411
+ // Override toggle
412
+ this.checksPanel.add(new TextRenderable(this._renderer, { content: " " }))
413
+
414
+ const overridePointer = this.checkIndex === 4 ? "▶ " : " "
415
+ const overrideIcon = cfg.override ? "[✓]" : "[ ]"
416
+ const overrideFg = this.checkIndex === 4 ? "#50fa7b" : "#8be9fd"
417
+
418
+ this.checksPanel.add(
419
+ new TextRenderable(this._renderer, {
420
+ id: "override-toggle",
421
+ content: `${overridePointer}${overrideIcon} Override Category`,
422
+ fg: overrideFg,
423
+ })
424
+ )
425
+ }
426
+
427
+ private renderCategoryChecks(category: AppCategory): void {
428
+ const cfg = this.categoryConfigs.get(category)
429
+ if (!cfg) return
430
+
431
+ // Title
432
+ this.checksPanel.add(
433
+ new TextRenderable(this._renderer, {
434
+ id: "cat-checks-title",
435
+ content: `${APP_CATEGORIES[category]} Defaults`,
436
+ fg: "#4a9eff",
437
+ attributes: 1, // bold
438
+ })
439
+ )
440
+
441
+ this.checksPanel.add(
442
+ new TextRenderable(this._renderer, {
443
+ id: "cat-checks-subtitle",
444
+ content: "Default checks for all apps in category",
445
+ fg: "#888888",
446
+ })
447
+ )
448
+
449
+ this.checksPanel.add(
450
+ new TextRenderable(this._renderer, {
451
+ id: "cat-checks-divider",
452
+ content: "─".repeat(25),
453
+ fg: "#555555",
454
+ })
455
+ )
456
+
457
+ // Check toggles for category defaults
458
+ const checks: (keyof MonitorOptions)[] = ["health", "diskspace", "status", "queue"]
459
+
460
+ checks.forEach((check, idx) => {
461
+ const isEnabled = cfg.checks[check]
462
+ const pointer = idx === this.checkIndex ? "▶ " : " "
463
+ const icon = isEnabled ? "[✓]" : "[ ]"
464
+ const fg = idx === this.checkIndex ? "#50fa7b" : "#aaaaaa"
465
+
466
+ this.checksPanel.add(
467
+ new TextRenderable(this._renderer, {
468
+ id: `cat-check-${idx}`,
469
+ content: `${pointer}${icon} ${CHECK_LABELS[check]}`,
470
+ fg,
471
+ })
472
+ )
473
+ })
474
+
475
+ // Info about inheritance
476
+ this.checksPanel.add(new TextRenderable(this._renderer, { content: " " }))
477
+ this.checksPanel.add(
478
+ new TextRenderable(this._renderer, {
479
+ id: "cat-checks-info",
480
+ content: "Apps inherit these unless overridden",
481
+ fg: "#555555",
482
+ })
483
+ )
484
+ }
485
+
486
+ private renderStatusView(): void {
487
+ if (this.availableCategories.length === 0) {
488
+ return
489
+ }
490
+
491
+ const selectedCat = this.availableCategories[this.categoryIndex]
492
+ if (!selectedCat) return
493
+
494
+ const apps = this.getAppsInCategory(selectedCat)
495
+ const selectedApp = apps[this.appIndex]
496
+ if (!selectedApp) return
497
+
498
+ const status = this.statusData.get(selectedApp.id)
499
+
500
+ // Title
501
+ this.checksPanel.add(
502
+ new TextRenderable(this._renderer, {
503
+ content: `📊 ${selectedApp.name} Status`,
504
+ fg: "#4a9eff",
505
+ attributes: 1,
506
+ })
507
+ )
508
+
509
+ this.checksPanel.add(
510
+ new TextRenderable(this._renderer, {
511
+ content: "─".repeat(25),
512
+ fg: "#555555",
513
+ })
514
+ )
515
+
516
+ if (!status) {
517
+ this.checksPanel.add(
518
+ new TextRenderable(this._renderer, {
519
+ content: "\nPress 'r' to fetch status",
520
+ fg: "#888888",
521
+ })
522
+ )
523
+ return
524
+ }
525
+
526
+ if (status.loading) {
527
+ this.checksPanel.add(
528
+ new TextRenderable(this._renderer, {
529
+ content: "\n⏳ Loading...",
530
+ fg: "#f1fa8c",
531
+ })
532
+ )
533
+ return
534
+ }
535
+
536
+ if (status.error) {
537
+ this.checksPanel.add(
538
+ new TextRenderable(this._renderer, {
539
+ content: `\n❌ Error: ${status.error}`,
540
+ fg: "#ff5555",
541
+ })
542
+ )
543
+ return
544
+ }
545
+
546
+ // Health warnings
547
+ if (status.health && status.health.length > 0) {
548
+ this.checksPanel.add(
549
+ new TextRenderable(this._renderer, {
550
+ content: `\n⚠️ Health Warnings: ${status.health.length}`,
551
+ fg: "#ffb86c",
552
+ })
553
+ )
554
+ status.health.slice(0, 3).forEach((h) => {
555
+ this.checksPanel.add(
556
+ new TextRenderable(this._renderer, {
557
+ content: ` • ${h.message}`,
558
+ fg: "#f1fa8c",
559
+ })
560
+ )
561
+ })
562
+ } else {
563
+ this.checksPanel.add(
564
+ new TextRenderable(this._renderer, {
565
+ content: "\n✓ Health: OK",
566
+ fg: "#50fa7b",
567
+ })
568
+ )
569
+ }
570
+
571
+ // Disk space
572
+ if (status.disk && status.disk.length > 0) {
573
+ const totalFree = status.disk.reduce((sum, d) => sum + (d.freeSpace || 0), 0)
574
+ const freeGB = (totalFree / 1024 / 1024 / 1024).toFixed(1)
575
+ const freeColor = totalFree < 10 * 1024 * 1024 * 1024 ? "#ff5555" : "#50fa7b"
576
+ this.checksPanel.add(
577
+ new TextRenderable(this._renderer, {
578
+ content: `\n💾 Disk Free: ${freeGB} GB`,
579
+ fg: freeColor,
580
+ })
581
+ )
582
+ }
583
+
584
+ // System status
585
+ if (status.system) {
586
+ this.checksPanel.add(
587
+ new TextRenderable(this._renderer, {
588
+ content: `\n📦 Version: ${status.system.version || "Unknown"}`,
589
+ fg: "#8be9fd",
590
+ })
591
+ )
592
+ }
593
+
594
+ // Queue
595
+ if (status.queue) {
596
+ const queueCount = status.queue.totalCount || 0
597
+ this.checksPanel.add(
598
+ new TextRenderable(this._renderer, {
599
+ content: `\n📥 Queue: ${queueCount} items`,
600
+ fg: queueCount > 0 ? "#f1fa8c" : "#50fa7b",
601
+ })
602
+ )
603
+ }
604
+
605
+ this.checksPanel.add(
606
+ new TextRenderable(this._renderer, {
607
+ content: "\n\nPress 'r' to refresh",
608
+ fg: "#555555",
609
+ })
610
+ )
611
+ }
612
+
613
+ private async fetchStatus(): Promise<void> {
614
+ if (this.availableCategories.length === 0) return
615
+
616
+ const selectedCat = this.availableCategories[this.categoryIndex]
617
+ if (!selectedCat) return
618
+
619
+ const apps = this.getAppsInCategory(selectedCat)
620
+ const selectedApp = apps[this.appIndex]
621
+ if (!selectedApp) return
622
+
623
+ // Read API key from env file
624
+ const { readEnv } = await import("../../utils/env")
625
+ const env = await readEnv()
626
+ const apiKey = env[`API_KEY_${selectedApp.id.toUpperCase()}`]
627
+
628
+ if (!apiKey) {
629
+ this.statusData.set(selectedApp.id, {
630
+ loading: false,
631
+ error: "No API key in .env",
632
+ })
633
+ this.renderChecksPanel()
634
+ return
635
+ }
636
+
637
+ // Mark as loading
638
+ this.statusData.set(selectedApp.id, { loading: true })
639
+ this.renderChecksPanel()
640
+
641
+ try {
642
+ const client = new ArrApiClient("localhost", selectedApp.defaultPort, apiKey)
643
+
644
+ const [health, disk, system, queue] = await Promise.allSettled([
645
+ client.getHealth(),
646
+ client.getDiskSpace(),
647
+ client.getSystemStatus(),
648
+ client.getQueueStatus(),
649
+ ])
650
+
651
+ this.statusData.set(selectedApp.id, {
652
+ loading: false,
653
+ health: health.status === "fulfilled" ? health.value : undefined,
654
+ disk: disk.status === "fulfilled" ? disk.value : undefined,
655
+ system: system.status === "fulfilled" ? system.value : undefined,
656
+ queue: queue.status === "fulfilled" ? queue.value : undefined,
657
+ })
658
+ } catch (err) {
659
+ this.statusData.set(selectedApp.id, {
660
+ loading: false,
661
+ error: err instanceof Error ? err.message : String(err),
662
+ })
663
+ }
664
+
665
+ this.renderChecksPanel()
666
+ }
667
+
668
+ private handleKey(key: KeyEvent): void {
669
+ if (!this.page.visible) return
670
+
671
+ // Quit
672
+ if (key.name === "q" || key.name === "escape") {
673
+ this.cleanup()
674
+ this.onBack()
675
+ return
676
+ }
677
+
678
+ // Save
679
+ if (key.name === "s") {
680
+ this.saveConfiguration()
681
+ return
682
+ }
683
+
684
+ // Refresh status (in status mode)
685
+ if (key.name === "r" && this.currentMode === "status") {
686
+ this.fetchStatus()
687
+ return
688
+ }
689
+
690
+ // Mode switch
691
+ if (key.name === "left" || key.name === "right") {
692
+ const newMode = key.name === "left" ? 0 : 1
693
+ this.modeTabs.setSelectedIndex(newMode)
694
+ this.currentMode = newMode === 0 ? "configure" : "status"
695
+ this.updateAllPanels()
696
+ return
697
+ }
698
+
699
+ // Tab to switch panels
700
+ if (key.name === "tab") {
701
+ if (key.shift) {
702
+ this.currentPanel = ((this.currentPanel - 1 + 3) % 3) as 0 | 1 | 2
703
+ } else {
704
+ this.currentPanel = ((this.currentPanel + 1) % 3) as 0 | 1 | 2
705
+ }
706
+ this.updatePanelBorders()
707
+ return
708
+ }
709
+
710
+ // Navigation within panel
711
+ if (key.name === "up" || key.name === "down") {
712
+ const delta = key.name === "up" ? -1 : 1
713
+ this.navigatePanel(delta)
714
+ return
715
+ }
716
+
717
+ // Toggle
718
+ if (key.name === "space" || key.name === "return") {
719
+ this.toggleCurrentItem()
720
+ return
721
+ }
722
+
723
+ // Number keys 1-4 for quick check toggling
724
+ const numKey = parseInt(key.sequence || "", 10)
725
+ if (numKey >= 1 && numKey <= 4) {
726
+ const checkIdx = numKey - 1 // 0-indexed
727
+ if (this.currentPanel === 0) {
728
+ // Toggle category-level check directly
729
+ this.toggleCategoryCheck(checkIdx)
730
+ } else {
731
+ // Toggle app-level check (or category if not overriding)
732
+ this.checkIndex = checkIdx
733
+ this.toggleCurrentItem()
734
+ this.currentPanel = 2 // Move to checks panel for visual feedback
735
+ this.updatePanelBorders()
736
+ }
737
+ this.renderChecksPanel()
738
+ return
739
+ }
740
+ }
741
+
742
+ private navigatePanel(delta: number): void {
743
+ if (this.currentPanel === 0) {
744
+ // Categories
745
+ const max = this.availableCategories.length - 1
746
+ this.categoryIndex = Math.max(0, Math.min(max, this.categoryIndex + delta))
747
+ this.appIndex = 0 // Reset app selection
748
+ this.checkIndex = 0
749
+ this.updateAllPanels()
750
+ } else if (this.currentPanel === 1) {
751
+ // Apps
752
+ const cat = this.availableCategories[this.categoryIndex]
753
+ const apps = cat ? this.getAppsInCategory(cat) : []
754
+ const max = apps.length - 1
755
+ this.appIndex = Math.max(0, Math.min(max, this.appIndex + delta))
756
+ this.checkIndex = 0
757
+ this.renderAppsPanel()
758
+ this.renderChecksPanel()
759
+ } else {
760
+ // Checks (0-3 for checks, 4 for override)
761
+ this.checkIndex = Math.max(0, Math.min(4, this.checkIndex + delta))
762
+ this.renderChecksPanel()
763
+ }
764
+ }
765
+
766
+ private toggleCurrentItem(): void {
767
+ const cat = this.availableCategories[this.categoryIndex]
768
+ if (!cat) return
769
+
770
+ if (this.currentPanel === 0) {
771
+ // When in Categories panel, toggle category enabled status
772
+ const cfg = this.categoryConfigs.get(cat)!
773
+ cfg.enabled = !cfg.enabled
774
+ this.renderCategoriesPanel()
775
+ this.renderChecksPanel()
776
+ } else if (this.currentPanel === 1) {
777
+ // Toggle app enabled
778
+ const apps = this.getAppsInCategory(cat)
779
+ const app = apps[this.appIndex]
780
+ if (app) {
781
+ const cfg = this.appConfigs.get(app.id)!
782
+ cfg.enabled = !cfg.enabled
783
+ this.renderAppsPanel()
784
+ }
785
+ } else if (this.currentPanel === 2) {
786
+ // Toggle check or override - depends on whether we're viewing category or app
787
+ // Since we show category checks when panel 0 is focused, but we're now in panel 2,
788
+ // we're viewing an app's checks
789
+ const apps = this.getAppsInCategory(cat)
790
+ const app = apps[this.appIndex]
791
+ if (!app) return
792
+
793
+ const cfg = this.appConfigs.get(app.id)!
794
+
795
+ if (this.checkIndex === 4) {
796
+ // Toggle override
797
+ cfg.override = !cfg.override
798
+ } else {
799
+ // Toggle specific check
800
+ const checks: (keyof MonitorOptions)[] = ["health", "diskspace", "status", "queue"]
801
+ const checkKey = checks[this.checkIndex]
802
+
803
+ if (cfg.override) {
804
+ cfg.checks[checkKey] = !cfg.checks[checkKey]
805
+ } else {
806
+ // Toggle on category level (affects all apps without override)
807
+ const catCfg = this.categoryConfigs.get(cat)!
808
+ catCfg.checks[checkKey] = !catCfg.checks[checkKey]
809
+ }
810
+ }
811
+ this.renderChecksPanel()
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Toggle category-level check by number key (1-4)
817
+ * This allows direct toggling of category defaults from any panel
818
+ */
819
+ private toggleCategoryCheck(checkIdx: number): void {
820
+ const cat = this.availableCategories[this.categoryIndex]
821
+ if (!cat) return
822
+
823
+ const catCfg = this.categoryConfigs.get(cat)
824
+ if (!catCfg) return
825
+
826
+ const checks: (keyof MonitorOptions)[] = ["health", "diskspace", "status", "queue"]
827
+ if (checkIdx >= 0 && checkIdx < checks.length) {
828
+ catCfg.checks[checks[checkIdx]] = !catCfg.checks[checks[checkIdx]]
829
+ this.renderChecksPanel()
830
+ }
831
+ }
832
+
833
+ private async saveConfiguration(): Promise<void> {
834
+ const monitor: MonitorConfig = {
835
+ categories: Array.from(this.categoryConfigs.entries()).map(([category, cfg]) => ({
836
+ category,
837
+ enabled: cfg.enabled,
838
+ checks: cfg.checks,
839
+ })),
840
+ apps: Array.from(this.appConfigs.entries())
841
+ .filter(([, cfg]) => cfg.override)
842
+ .map(([appId, cfg]) => ({
843
+ appId,
844
+ override: cfg.override,
845
+ enabled: cfg.enabled,
846
+ checks: cfg.checks,
847
+ })),
848
+ pollIntervalSeconds: this.config.monitor?.pollIntervalSeconds ?? 60,
849
+ }
850
+
851
+ this.config.monitor = monitor
852
+ await saveConfig(this.config)
853
+ }
854
+
855
+ private cleanup(): void {
856
+ this._renderer.keyInput.off("keypress", this.keyHandler)
857
+ // Remove self from parent container
858
+ if (this.parent && this.id) {
859
+ try {
860
+ this.parent.remove(this.id)
861
+ } catch {
862
+ /* ignore removal errors */
863
+ }
864
+ }
865
+ }
866
+ }