@muhammedaksam/easiarr 1.1.8 → 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.
- package/README.md +9 -2
- package/package.json +1 -1
- package/src/api/profilarr-api.ts +284 -0
- package/src/apps/registry.ts +92 -0
- package/src/compose/caddy-config.ts +129 -0
- package/src/compose/generator.ts +10 -1
- package/src/config/recyclarr-config.ts +179 -0
- package/src/config/schema.ts +19 -0
- package/src/docker/client.ts +16 -0
- package/src/structure/manager.ts +41 -1
- package/src/ui/screens/FullAutoSetup.ts +123 -1
- package/src/ui/screens/LogsViewer.ts +468 -0
- package/src/ui/screens/MainMenu.ts +14 -0
- package/src/ui/screens/QuickSetup.ts +52 -3
- package/src/ui/screens/RecyclarrSetup.ts +418 -0
- package/src/ui/screens/SettingsScreen.ts +35 -1
- package/src/utils/logs.ts +118 -0
- package/src/utils/unraid.ts +101 -0
|
@@ -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 =
|
|
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
|
|