@muhammedaksam/easiarr 0.4.2 → 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.
- package/package.json +1 -1
- package/src/api/arr-api.ts +156 -0
- package/src/api/prowlarr-api.ts +447 -14
- package/src/apps/registry.ts +5 -0
- package/src/config/schema.ts +34 -0
- package/src/index.ts +4 -0
- package/src/ui/components/FooterHint.ts +119 -0
- package/src/ui/components/PageLayout.ts +58 -10
- package/src/ui/screens/AdvancedSettings.ts +4 -1
- package/src/ui/screens/ApiKeyViewer.ts +6 -1
- package/src/ui/screens/AppConfigurator.ts +16 -5
- package/src/ui/screens/AppManager.ts +6 -1
- package/src/ui/screens/ContainerControl.ts +4 -1
- package/src/ui/screens/FullAutoSetup.ts +4 -1
- package/src/ui/screens/MainMenu.ts +19 -3
- package/src/ui/screens/MonitorDashboard.ts +866 -0
- package/src/ui/screens/ProwlarrSetup.ts +260 -5
- package/src/ui/screens/QBittorrentSetup.ts +4 -1
- package/src/ui/screens/QuickSetup.ts +36 -8
- package/src/ui/screens/TRaSHProfileSetup.ts +6 -1
|
@@ -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
|
+
}
|