@muhammedaksam/easiarr 1.1.8 → 1.2.1

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,468 @@
1
+ /**
2
+ * Container Logs Viewer Screen
3
+ * Full-screen log viewer with streaming, search, and save functionality
4
+ */
5
+
6
+ import type { RenderContext, CliRenderer, KeyEvent } from "@opentui/core"
7
+ import { BoxRenderable, TextRenderable, SelectRenderable, SelectRenderableEvents } from "@opentui/core"
8
+ import type { EasiarrConfig } from "../../config/schema"
9
+ import { getContainerLogs, getContainerStatuses, type ContainerStatus } from "../../docker"
10
+ import { createPageLayout } from "../components/PageLayout"
11
+ import { saveLog, listSavedLogs, formatBytes, formatRelativeTime } from "../../utils/logs"
12
+
13
+ type ViewMode = "select" | "logs" | "saved"
14
+
15
+ export class LogsViewer extends BoxRenderable {
16
+ private renderer: CliRenderer
17
+ private config: EasiarrConfig
18
+ private onBack: () => void
19
+ private keyHandler: ((k: KeyEvent) => void) | null = null
20
+
21
+ // State
22
+ private mode: ViewMode = "select"
23
+ private containers: ContainerStatus[] = []
24
+ private selectedContainer: ContainerStatus | null = null
25
+ private logContent: string[] = []
26
+ private scrollOffset = 0
27
+ private lineCount = 100 // Number of log lines to fetch
28
+ private visibleLines = 35 // Number of visible lines in log view
29
+ private isLoading = false
30
+ private statusMessage = ""
31
+ private savedLogs: Array<{ filename: string; path: string; date: Date; size: number }> = []
32
+
33
+ // UI Components
34
+ private page!: BoxRenderable
35
+ private content!: BoxRenderable
36
+ private statusBar!: TextRenderable
37
+
38
+ constructor(renderer: RenderContext, config: EasiarrConfig, onBack: () => void) {
39
+ super(renderer, { width: "100%", height: "100%", flexDirection: "column" })
40
+ this.renderer = renderer as CliRenderer
41
+ this.config = config
42
+ this.onBack = onBack
43
+
44
+ this.init()
45
+ }
46
+
47
+ private async init(): Promise<void> {
48
+ await this.loadContainers()
49
+ this.buildUI()
50
+ this.attachKeyHandler()
51
+ }
52
+
53
+ private async loadContainers(): Promise<void> {
54
+ this.isLoading = true
55
+ const all = await getContainerStatuses()
56
+ // Only show running containers (can fetch logs from them)
57
+ this.containers = all.filter((c) => c.status === "running")
58
+ this.isLoading = false
59
+ }
60
+
61
+ private buildUI(): void {
62
+ const { container: page, content } = createPageLayout(this.renderer, {
63
+ title: "📋 Container Logs",
64
+ stepInfo: `${this.containers.length} running containers`,
65
+ footerHint: this.getFooterHints(),
66
+ })
67
+ this.page = page
68
+ this.content = content
69
+
70
+ this.statusBar = new TextRenderable(this.renderer, {
71
+ id: "status-bar",
72
+ content: "",
73
+ fg: "#f1fa8c",
74
+ })
75
+
76
+ this.renderContent()
77
+ this.add(page)
78
+ }
79
+
80
+ private getFooterHints(): Array<{ type: "key"; key: string; value: string } | { type: "separator" }> {
81
+ switch (this.mode) {
82
+ case "select":
83
+ return [
84
+ { type: "key", key: "Enter", value: "View Logs" },
85
+ { type: "key", key: "h", value: "Saved Logs" },
86
+ { type: "key", key: "q", value: "Back" },
87
+ ]
88
+ case "logs":
89
+ return [
90
+ { type: "key", key: "↑↓", value: "Scroll" },
91
+ { type: "key", key: "s", value: "Save" },
92
+ { type: "key", key: "r", value: "Refresh" },
93
+ { type: "key", key: "+/-", value: "Lines" },
94
+ { type: "key", key: "q", value: "Back" },
95
+ ]
96
+ case "saved":
97
+ return [
98
+ { type: "key", key: "Enter", value: "View" },
99
+ { type: "key", key: "q", value: "Back" },
100
+ ]
101
+ }
102
+ }
103
+
104
+ private clearContent(): void {
105
+ const children = [...this.content.getChildren()]
106
+ for (const child of children) {
107
+ if (child.id) this.content.remove(child.id)
108
+ }
109
+ }
110
+
111
+ private renderContent(): void {
112
+ this.clearContent()
113
+
114
+ switch (this.mode) {
115
+ case "select":
116
+ this.renderContainerSelect()
117
+ break
118
+ case "logs":
119
+ this.renderLogView()
120
+ break
121
+ case "saved":
122
+ this.renderSavedLogs()
123
+ break
124
+ }
125
+
126
+ this.content.add(this.statusBar)
127
+ }
128
+
129
+ private renderContainerSelect(): void {
130
+ if (this.containers.length === 0) {
131
+ this.content.add(
132
+ new TextRenderable(this.renderer, {
133
+ id: "no-containers",
134
+ content: "No running containers found.\nStart containers first to view logs.",
135
+ fg: "#888888",
136
+ })
137
+ )
138
+ return
139
+ }
140
+
141
+ this.content.add(
142
+ new TextRenderable(this.renderer, {
143
+ id: "select-header",
144
+ content: "Select a container to view logs:",
145
+ fg: "#4a9eff",
146
+ })
147
+ )
148
+ this.content.add(new TextRenderable(this.renderer, { content: "" }))
149
+
150
+ const menu = new SelectRenderable(this.renderer, {
151
+ id: "container-select",
152
+ width: "100%",
153
+ flexGrow: 1,
154
+ options: this.containers.map((c) => ({
155
+ name: `🐳 ${c.name}`,
156
+ description: c.ports ? `Ports: ${c.ports.split(",")[0]}` : "",
157
+ })),
158
+ })
159
+
160
+ menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
161
+ this.selectedContainer = this.containers[index]
162
+ await this.fetchLogs()
163
+ this.mode = "logs"
164
+ this.scrollOffset = 0
165
+ this.rebuildUI()
166
+ })
167
+
168
+ this.content.add(menu)
169
+ setTimeout(() => menu.focus(), 10)
170
+ }
171
+
172
+ private renderLogView(): void {
173
+ if (!this.selectedContainer) return
174
+
175
+ // Header with container info
176
+ const header = new BoxRenderable(this.renderer, {
177
+ id: "log-header",
178
+ width: "100%",
179
+ flexDirection: "row",
180
+ marginBottom: 1,
181
+ })
182
+
183
+ header.add(
184
+ new TextRenderable(this.renderer, {
185
+ id: "container-name",
186
+ content: `📦 ${this.selectedContainer.name}`,
187
+ fg: "#4a9eff",
188
+ })
189
+ )
190
+ header.add(
191
+ new TextRenderable(this.renderer, {
192
+ id: "log-info",
193
+ content: ` (${this.lineCount} lines, scroll: ${this.scrollOffset}/${Math.max(0, this.logContent.length - this.visibleLines)})`,
194
+ fg: "#666666",
195
+ })
196
+ )
197
+
198
+ this.content.add(header)
199
+
200
+ // Log content box
201
+ const logBox = new BoxRenderable(this.renderer, {
202
+ id: "log-box",
203
+ width: "100%",
204
+ flexGrow: 1,
205
+ flexDirection: "column",
206
+ border: true,
207
+ borderStyle: "single",
208
+ borderColor: "#444444",
209
+ padding: 1,
210
+ overflow: "hidden",
211
+ })
212
+
213
+ if (this.isLoading) {
214
+ logBox.add(
215
+ new TextRenderable(this.renderer, {
216
+ id: "loading",
217
+ content: "Loading logs...",
218
+ fg: "#f1fa8c",
219
+ })
220
+ )
221
+ } else if (this.logContent.length === 0) {
222
+ logBox.add(
223
+ new TextRenderable(this.renderer, {
224
+ id: "no-logs",
225
+ content: "No logs available",
226
+ fg: "#888888",
227
+ })
228
+ )
229
+ } else {
230
+ // Show visible lines based on scroll offset
231
+ const lines = this.logContent.slice(this.scrollOffset, this.scrollOffset + this.visibleLines)
232
+
233
+ lines.forEach((line, idx) => {
234
+ // Colorize log levels
235
+ let fg = "#888888"
236
+ if (line.includes("ERROR") || line.includes("error")) fg = "#ff5555"
237
+ else if (line.includes("WARN") || line.includes("warn")) fg = "#f1fa8c"
238
+ else if (line.includes("INFO") || line.includes("info")) fg = "#50fa7b"
239
+ else if (line.includes("DEBUG") || line.includes("debug")) fg = "#6272a4"
240
+
241
+ logBox.add(
242
+ new TextRenderable(this.renderer, {
243
+ id: `log-line-${idx}`,
244
+ content: line.substring(0, 120), // Truncate long lines
245
+ fg,
246
+ })
247
+ )
248
+ })
249
+ }
250
+
251
+ this.content.add(logBox)
252
+ }
253
+
254
+ private async renderSavedLogs(): Promise<void> {
255
+ if (!this.selectedContainer) {
256
+ this.content.add(
257
+ new TextRenderable(this.renderer, {
258
+ id: "no-selection",
259
+ content: "Select a container first to view saved logs.",
260
+ fg: "#888888",
261
+ })
262
+ )
263
+ return
264
+ }
265
+
266
+ this.savedLogs = await listSavedLogs(this.selectedContainer.name)
267
+
268
+ this.content.add(
269
+ new TextRenderable(this.renderer, {
270
+ id: "saved-header",
271
+ content: `📁 Saved logs for ${this.selectedContainer.name}:`,
272
+ fg: "#4a9eff",
273
+ })
274
+ )
275
+ this.content.add(new TextRenderable(this.renderer, { content: "" }))
276
+
277
+ if (this.savedLogs.length === 0) {
278
+ this.content.add(
279
+ new TextRenderable(this.renderer, {
280
+ id: "no-saved",
281
+ content: "No saved logs found. Press 's' while viewing logs to save.",
282
+ fg: "#888888",
283
+ })
284
+ )
285
+ return
286
+ }
287
+
288
+ const menu = new SelectRenderable(this.renderer, {
289
+ id: "saved-select",
290
+ width: "100%",
291
+ flexGrow: 1,
292
+ options: this.savedLogs.map((log) => ({
293
+ name: `📄 ${log.filename}`,
294
+ description: `${formatBytes(log.size)} - ${formatRelativeTime(log.date)}`,
295
+ })),
296
+ })
297
+
298
+ menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
299
+ const log = this.savedLogs[index]
300
+ // Load and display saved log
301
+ const content = await Bun.file(log.path).text()
302
+ this.logContent = content.split("\n")
303
+ this.scrollOffset = 0
304
+ this.mode = "logs"
305
+ this.rebuildUI()
306
+ })
307
+
308
+ this.content.add(menu)
309
+ setTimeout(() => menu.focus(), 10)
310
+ }
311
+
312
+ private async fetchLogs(): Promise<void> {
313
+ if (!this.selectedContainer) return
314
+
315
+ this.isLoading = true
316
+ this.setStatus("Fetching logs...", "#f1fa8c")
317
+
318
+ const result = await getContainerLogs(this.selectedContainer.name, this.lineCount)
319
+
320
+ if (result.success) {
321
+ this.logContent = result.output.split("\n").filter((line) => line.trim())
322
+ this.setStatus(`Loaded ${this.logContent.length} lines`, "#50fa7b")
323
+ } else {
324
+ this.logContent = []
325
+ this.setStatus("Failed to fetch logs", "#ff5555")
326
+ }
327
+
328
+ this.isLoading = false
329
+ }
330
+
331
+ private async saveCurrentLogs(): Promise<void> {
332
+ if (!this.selectedContainer || this.logContent.length === 0) {
333
+ this.setStatus("No logs to save", "#ff5555")
334
+ return
335
+ }
336
+
337
+ this.setStatus("Saving logs...", "#f1fa8c")
338
+ const content = this.logContent.join("\n")
339
+ const filepath = await saveLog(this.selectedContainer.name, content)
340
+ this.setStatus(`✓ Saved to ${filepath}`, "#50fa7b")
341
+ }
342
+
343
+ private setStatus(message: string, color: string): void {
344
+ this.statusMessage = message
345
+ if (this.statusBar) {
346
+ this.statusBar.content = message
347
+ this.statusBar.fg = color
348
+ }
349
+ }
350
+
351
+ private rebuildUI(): void {
352
+ // Remove old page
353
+ if (this.page) {
354
+ this.remove(this.page.id)
355
+ }
356
+ this.buildUI()
357
+ }
358
+
359
+ private attachKeyHandler(): void {
360
+ this.keyHandler = async (k: KeyEvent) => {
361
+ switch (this.mode) {
362
+ case "select":
363
+ await this.handleSelectModeKey(k)
364
+ break
365
+ case "logs":
366
+ await this.handleLogsModeKey(k)
367
+ break
368
+ case "saved":
369
+ await this.handleSavedModeKey(k)
370
+ break
371
+ }
372
+ }
373
+ this.renderer.keyInput.on("keypress", this.keyHandler)
374
+ }
375
+
376
+ private async handleSelectModeKey(k: KeyEvent): Promise<void> {
377
+ switch (k.name) {
378
+ case "h":
379
+ if (this.selectedContainer) {
380
+ this.mode = "saved"
381
+ this.rebuildUI()
382
+ }
383
+ break
384
+ case "q":
385
+ case "escape":
386
+ this.cleanup()
387
+ this.onBack()
388
+ break
389
+ }
390
+ }
391
+
392
+ private async handleLogsModeKey(k: KeyEvent): Promise<void> {
393
+ const maxScroll = Math.max(0, this.logContent.length - this.visibleLines)
394
+
395
+ switch (k.name) {
396
+ case "up":
397
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1)
398
+ this.renderContent()
399
+ break
400
+ case "down":
401
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1)
402
+ this.renderContent()
403
+ break
404
+ case "pageup":
405
+ this.scrollOffset = Math.max(0, this.scrollOffset - 10)
406
+ this.renderContent()
407
+ break
408
+ case "pagedown":
409
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 10)
410
+ this.renderContent()
411
+ break
412
+ case "home":
413
+ this.scrollOffset = 0
414
+ this.renderContent()
415
+ break
416
+ case "end":
417
+ this.scrollOffset = maxScroll
418
+ this.renderContent()
419
+ break
420
+ case "s":
421
+ await this.saveCurrentLogs()
422
+ break
423
+ case "r":
424
+ await this.fetchLogs()
425
+ this.renderContent()
426
+ break
427
+ case "+":
428
+ case "=":
429
+ this.lineCount = Math.min(500, this.lineCount + 50)
430
+ await this.fetchLogs()
431
+ this.rebuildUI()
432
+ break
433
+ case "-":
434
+ this.lineCount = Math.max(50, this.lineCount - 50)
435
+ await this.fetchLogs()
436
+ this.rebuildUI()
437
+ break
438
+ case "h":
439
+ this.mode = "saved"
440
+ this.rebuildUI()
441
+ break
442
+ case "q":
443
+ case "escape":
444
+ this.mode = "select"
445
+ this.selectedContainer = null
446
+ this.logContent = []
447
+ this.rebuildUI()
448
+ break
449
+ }
450
+ }
451
+
452
+ private async handleSavedModeKey(k: KeyEvent): Promise<void> {
453
+ switch (k.name) {
454
+ case "q":
455
+ case "escape":
456
+ this.mode = this.selectedContainer ? "logs" : "select"
457
+ this.rebuildUI()
458
+ break
459
+ }
460
+ }
461
+
462
+ private cleanup(): void {
463
+ if (this.keyHandler) {
464
+ this.renderer.keyInput.off("keypress", this.keyHandler)
465
+ this.keyHandler = null
466
+ }
467
+ }
468
+ }
@@ -23,6 +23,8 @@ import { JellyfinSetup } from "./JellyfinSetup"
23
23
  import { JellyseerrSetup } from "./JellyseerrSetup"
24
24
  import { SettingsScreen } from "./SettingsScreen"
25
25
  import { CloudflaredSetup } from "./CloudflaredSetup"
26
+ import { LogsViewer } from "./LogsViewer"
27
+ import { RecyclarrSetup } from "./RecyclarrSetup"
26
28
 
27
29
  type MenuItem = { name: string; description: string; action: () => void | Promise<void> }
28
30
 
@@ -69,6 +71,11 @@ export class MainMenu {
69
71
  description: "Start, stop, restart containers",
70
72
  action: () => this.app.navigateTo("containerControl"),
71
73
  })
74
+ items.push({
75
+ name: "📋 View Container Logs",
76
+ description: "Stream and save container logs",
77
+ action: () => this.showScreen(LogsViewer),
78
+ })
72
79
  items.push({
73
80
  name: "⚙️ Advanced Settings",
74
81
  description: "Customize ports, volumes, env",
@@ -112,6 +119,13 @@ export class MainMenu {
112
119
  action: () => this.showScreen(QBittorrentSetup),
113
120
  })
114
121
  }
122
+ if (this.isAppEnabled("recyclarr")) {
123
+ items.push({
124
+ name: "♻️ Recyclarr Setup",
125
+ description: "Configure TRaSH Guide profiles and run sync",
126
+ action: () => this.showScreen(RecyclarrSetup),
127
+ })
128
+ }
115
129
 
116
130
  // Full Auto Setup (always shown)
117
131
  items.push({
@@ -2,7 +2,6 @@
2
2
  * Quick Setup Wizard
3
3
  * First-time setup flow for easiarr
4
4
  */
5
- import { homedir } from "node:os"
6
5
 
7
6
  import type { CliRenderer, KeyEvent } from "@opentui/core"
8
7
  import {
@@ -22,6 +21,7 @@ import { ensureDirectoryStructure } from "../../structure/manager"
22
21
  import { SecretsEditor } from "./SecretsEditor"
23
22
  import { getApp } from "../../apps"
24
23
  import { ApplicationSelector } from "../components/ApplicationSelector"
24
+ import { isUnraid, getDefaultRootDir, getUnraidInfo } from "../../utils/unraid"
25
25
 
26
26
  type WizardStep = "welcome" | "apps" | "system" | "vpn" | "traefik" | "secrets" | "confirm"
27
27
 
@@ -44,7 +44,7 @@ export class QuickSetup {
44
44
  "easiarr",
45
45
  ])
46
46
 
47
- private rootDir: string = `${homedir()}/media`
47
+ private rootDir: string = getDefaultRootDir()
48
48
  private timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/London"
49
49
  private puid: string = process.getuid?.().toString() || "1000"
50
50
  private pgid: string = process.getgid?.().toString() || "1000"
@@ -58,6 +58,8 @@ export class QuickSetup {
58
58
  private traefikDomain: string = "CLOUDFLARE_DNS_ZONE"
59
59
  private traefikEntrypoint: string = "web"
60
60
  private traefikMiddlewares: string[] = []
61
+ // Log mount config
62
+ private logMount: boolean = false
61
63
 
62
64
  constructor(renderer: CliRenderer, container: BoxRenderable, app: App) {
63
65
  this.renderer = renderer
@@ -189,6 +191,18 @@ export class QuickSetup {
189
191
  })
190
192
  )
191
193
 
194
+ // Show Unraid detection if applicable
195
+ if (isUnraid()) {
196
+ const unraidInfo = getUnraidInfo()
197
+ content.add(
198
+ new TextRenderable(this.renderer, {
199
+ id: "unraid-detected",
200
+ content: ` 🟢 Unraid OS detected ${unraidInfo.hasComposeManager ? "(Compose Manager found)" : ""}`,
201
+ fg: "#ff8c00",
202
+ })
203
+ )
204
+ }
205
+
192
206
  // Spacer
193
207
  content.add(
194
208
  new TextRenderable(this.renderer, {
@@ -512,6 +526,38 @@ export class QuickSetup {
512
526
  const tzInput = createField("input-tz", "Timezone:", this.timezone, "Europe/London", 30)
513
527
  const umaskInput = createField("input-umask", "Umask:", this.umask, "002", 10)
514
528
 
529
+ // Log mount toggle
530
+ const logMountRow = new BoxRenderable(this.renderer, {
531
+ width: "100%",
532
+ height: 1,
533
+ flexDirection: "row",
534
+ marginBottom: 1,
535
+ })
536
+ logMountRow.add(
537
+ new TextRenderable(this.renderer, {
538
+ content: "Bind Logs:".padEnd(16),
539
+ fg: "#aaaaaa",
540
+ })
541
+ )
542
+ const logMountToggle = new SelectRenderable(this.renderer, {
543
+ id: "toggle-logmount",
544
+ width: 30,
545
+ height: 1,
546
+ options: [
547
+ {
548
+ name: this.logMount ? "✓ Enabled" : "○ Disabled",
549
+ description: "Bind-mount container logs to ${ROOT_DIR}/logs/",
550
+ },
551
+ ],
552
+ })
553
+ logMountToggle.on(SelectRenderableEvents.ITEM_SELECTED, () => {
554
+ this.logMount = !this.logMount
555
+ // Rebuild the step to update toggle state
556
+ this.renderStep()
557
+ })
558
+ logMountRow.add(logMountToggle)
559
+ formBox.add(logMountRow)
560
+
515
561
  content.add(new TextRenderable(this.renderer, { content: " " }))
516
562
 
517
563
  // Navigation Menu (Continue / Back)
@@ -524,7 +570,7 @@ export class QuickSetup {
524
570
  content.add(navMenu)
525
571
 
526
572
  // Focus management
527
- const inputs = [rootInput, puidInput, pgidInput, tzInput, umaskInput, navMenu]
573
+ const inputs = [rootInput, puidInput, pgidInput, tzInput, umaskInput, logMountToggle, navMenu]
528
574
  let focusIndex = 0
529
575
  inputs[0].focus()
530
576
 
@@ -1124,6 +1170,9 @@ export class QuickSetup {
1124
1170
  }
1125
1171
  }
1126
1172
 
1173
+ // Add logMount config
1174
+ config.logMount = this.logMount
1175
+
1127
1176
  // Save config
1128
1177
  await saveConfig(config)
1129
1178