@muhammedaksam/easiarr 0.6.1 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -228,4 +228,115 @@ export class PortainerApiClient {
228
228
  return false
229
229
  }
230
230
  }
231
+
232
+ // ==========================================
233
+ // Container Management Methods
234
+ // ==========================================
235
+
236
+ /**
237
+ * Get list of Docker endpoints
238
+ */
239
+ async getEndpoints(): Promise<PortainerEndpoint[]> {
240
+ return this.request<PortainerEndpoint[]>("/endpoints")
241
+ }
242
+
243
+ /**
244
+ * Get all containers for an endpoint
245
+ */
246
+ async getContainers(endpointId: number = 1): Promise<PortainerContainer[]> {
247
+ return this.request<PortainerContainer[]>(`/endpoints/${endpointId}/docker/containers/json?all=true`)
248
+ }
249
+
250
+ /**
251
+ * Start a container by ID
252
+ */
253
+ async startContainer(containerId: string, endpointId: number = 1): Promise<void> {
254
+ await this.request(`/endpoints/${endpointId}/docker/containers/${containerId}/start`, {
255
+ method: "POST",
256
+ })
257
+ }
258
+
259
+ /**
260
+ * Stop a container by ID
261
+ */
262
+ async stopContainer(containerId: string, endpointId: number = 1): Promise<void> {
263
+ await this.request(`/endpoints/${endpointId}/docker/containers/${containerId}/stop`, {
264
+ method: "POST",
265
+ })
266
+ }
267
+
268
+ /**
269
+ * Restart a container by ID
270
+ */
271
+ async restartContainer(containerId: string, endpointId: number = 1): Promise<void> {
272
+ await this.request(`/endpoints/${endpointId}/docker/containers/${containerId}/restart`, {
273
+ method: "POST",
274
+ })
275
+ }
276
+
277
+ /**
278
+ * Get container logs
279
+ */
280
+ async getContainerLogs(
281
+ containerId: string,
282
+ endpointId: number = 1,
283
+ options: { stdout?: boolean; stderr?: boolean; tail?: number } = {}
284
+ ): Promise<string> {
285
+ const { stdout = true, stderr = true, tail = 100 } = options
286
+ const params = new URLSearchParams({
287
+ stdout: String(stdout),
288
+ stderr: String(stderr),
289
+ tail: String(tail),
290
+ })
291
+ return this.request<string>(`/endpoints/${endpointId}/docker/containers/${containerId}/logs?${params}`)
292
+ }
293
+
294
+ /**
295
+ * Get container stats (CPU, Memory usage)
296
+ */
297
+ async getContainerStats(containerId: string, endpointId: number = 1): Promise<PortainerContainerStats> {
298
+ return this.request<PortainerContainerStats>(
299
+ `/endpoints/${endpointId}/docker/containers/${containerId}/stats?stream=false`
300
+ )
301
+ }
302
+ }
303
+
304
+ // ==========================================
305
+ // Additional Type Definitions
306
+ // ==========================================
307
+
308
+ export interface PortainerEndpoint {
309
+ Id: number
310
+ Name: string
311
+ Type: number
312
+ Status: number
313
+ URL: string
314
+ }
315
+
316
+ export interface PortainerContainer {
317
+ Id: string
318
+ Names: string[]
319
+ Image: string
320
+ State: string
321
+ Status: string
322
+ Ports: Array<{
323
+ IP?: string
324
+ PrivatePort: number
325
+ PublicPort?: number
326
+ Type: string
327
+ }>
328
+ Labels: Record<string, string>
329
+ Created: number
330
+ }
331
+
332
+ export interface PortainerContainerStats {
333
+ cpu_stats: {
334
+ cpu_usage: { total_usage: number }
335
+ system_cpu_usage: number
336
+ online_cpus: number
337
+ }
338
+ memory_stats: {
339
+ usage: number
340
+ limit: number
341
+ }
231
342
  }
@@ -142,6 +142,7 @@ export const APPS: Record<AppId, AppDefinition> = {
142
142
  enabledKey: "api_enabled",
143
143
  generateIfMissing: true,
144
144
  },
145
+ prowlarrCategoryIds: [7030], // Comics
145
146
  // Note: Mylar3 is NOT an *arr app - has different API format (?cmd=<endpoint>)
146
147
  // Root folder is configured via Web UI settings, not API
147
148
  },
@@ -127,3 +127,138 @@ export async function isDockerAvailable(): Promise<boolean> {
127
127
  return false
128
128
  }
129
129
  }
130
+
131
+ // ==========================================
132
+ // Individual Container Operations
133
+ // ==========================================
134
+
135
+ export interface ContainerDetails {
136
+ id: string
137
+ name: string
138
+ service: string
139
+ status: "running" | "stopped" | "exited" | "paused"
140
+ state: string
141
+ health?: string
142
+ ports: string
143
+ uptime?: string
144
+ image: string
145
+ createdAt: string
146
+ }
147
+
148
+ /**
149
+ * Start a specific container by service name
150
+ */
151
+ export async function startContainer(service: string): Promise<{ success: boolean; output: string }> {
152
+ try {
153
+ const composePath = getComposePath()
154
+ const result = await $`docker compose -f ${composePath} start ${service}`.text()
155
+ return { success: true, output: result }
156
+ } catch (error) {
157
+ return { success: false, output: String(error) }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Stop a specific container by service name
163
+ */
164
+ export async function stopContainer(service: string): Promise<{ success: boolean; output: string }> {
165
+ try {
166
+ const composePath = getComposePath()
167
+ const result = await $`docker compose -f ${composePath} stop ${service}`.text()
168
+ return { success: true, output: result }
169
+ } catch (error) {
170
+ return { success: false, output: String(error) }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Restart a specific container by service name
176
+ */
177
+ export async function restartContainer(service: string): Promise<{ success: boolean; output: string }> {
178
+ try {
179
+ const composePath = getComposePath()
180
+ const result = await $`docker compose -f ${composePath} restart ${service}`.text()
181
+ return { success: true, output: result }
182
+ } catch (error) {
183
+ return { success: false, output: String(error) }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Get detailed info for a specific container
189
+ */
190
+ export async function getContainerDetails(containerName: string): Promise<ContainerDetails | null> {
191
+ try {
192
+ const result =
193
+ await $`docker inspect ${containerName} --format '{"id":"{{.Id}}","name":"{{.Name}}","state":"{{.State.Status}}","health":"{{if .State.Health}}{{.State.Health.Status}}{{end}}","image":"{{.Config.Image}}","createdAt":"{{.Created}}"}'`.text()
194
+
195
+ if (!result.trim()) return null
196
+
197
+ const data = JSON.parse(result.trim())
198
+
199
+ // Get uptime from Status
200
+ const statusResult = await $`docker ps --filter "name=${containerName}" --format "{{.Status}}"`.text()
201
+
202
+ // Get port mappings
203
+ const portsResult = await $`docker port ${containerName} 2>/dev/null`.text()
204
+
205
+ return {
206
+ id: data.id.substring(0, 12),
207
+ name: data.name.replace(/^\//, ""),
208
+ service: containerName,
209
+ status: data.state === "running" ? "running" : data.state === "exited" ? "exited" : "stopped",
210
+ state: data.state,
211
+ health: data.health || undefined,
212
+ ports: portsResult.trim() || "-",
213
+ uptime: statusResult.trim() || undefined,
214
+ image: data.image,
215
+ createdAt: data.createdAt,
216
+ }
217
+ } catch {
218
+ return null
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Get container logs
224
+ */
225
+ export async function getContainerLogs(
226
+ service: string,
227
+ lines: number = 50
228
+ ): Promise<{ success: boolean; output: string }> {
229
+ try {
230
+ const composePath = getComposePath()
231
+ const result = await $`docker compose -f ${composePath} logs ${service} --tail ${lines} --no-color`.text()
232
+ return { success: true, output: result }
233
+ } catch (error) {
234
+ return { success: false, output: String(error) }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Pull latest image for a specific service
240
+ */
241
+ export async function pullServiceImage(service: string): Promise<{ success: boolean; output: string }> {
242
+ try {
243
+ const composePath = getComposePath()
244
+ const result = await $`docker compose -f ${composePath} pull ${service}`.text()
245
+ return { success: true, output: result }
246
+ } catch (error) {
247
+ return { success: false, output: String(error) }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Recreate a specific service (pull + up)
253
+ */
254
+ export async function recreateService(service: string): Promise<{ success: boolean; output: string }> {
255
+ try {
256
+ const composePath = getComposePath()
257
+ // Pull first, then recreate
258
+ await $`docker compose -f ${composePath} pull ${service}`.text()
259
+ const result = await $`docker compose -f ${composePath} up -d --force-recreate ${service}`.text()
260
+ return { success: true, output: result }
261
+ } catch (error) {
262
+ return { success: false, output: String(error) }
263
+ }
264
+ }
@@ -1,10 +1,18 @@
1
1
  /**
2
- * Container Control Screen
3
- * Start, stop, restart containers
2
+ * Container Control Screen (Modernized)
3
+ * Two-panel layout with container list and action panel
4
+ * Supports individual and bulk container operations
4
5
  */
5
6
 
6
- import type { RenderContext, CliRenderer } from "@opentui/core"
7
- import { BoxRenderable, TextRenderable, SelectRenderable, SelectRenderableEvents } from "@opentui/core"
7
+ import type { RenderContext, CliRenderer, KeyEvent } from "@opentui/core"
8
+ import {
9
+ BoxRenderable,
10
+ TextRenderable,
11
+ SelectRenderable,
12
+ SelectRenderableEvents,
13
+ TabSelectRenderable,
14
+ TabSelectRenderableEvents,
15
+ } from "@opentui/core"
8
16
  import type { App } from "../App"
9
17
  import type { EasiarrConfig } from "../../config/schema"
10
18
  import {
@@ -13,133 +21,559 @@ import {
13
21
  composeStop,
14
22
  composeRestart,
15
23
  getContainerStatuses,
16
- isDockerAvailable,
24
+ pullImages,
25
+ startContainer,
26
+ stopContainer,
27
+ restartContainer as restartSingleContainer,
28
+ getContainerLogs,
29
+ recreateService,
30
+ type ContainerStatus,
17
31
  } from "../../docker"
18
32
  import { createPageLayout } from "../components/PageLayout"
19
33
 
34
+ type Mode = "containers" | "bulk"
35
+
20
36
  export class ContainerControl {
21
- private renderer: RenderContext
37
+ private renderer: CliRenderer
22
38
  private container: BoxRenderable
23
39
  private app: App
24
40
  private config: EasiarrConfig
41
+ private keyHandler: ((k: KeyEvent) => void) | null = null
42
+
43
+ // UI Components
44
+ private page!: BoxRenderable
45
+ private modeTabs!: TabSelectRenderable
46
+ private leftPanel!: BoxRenderable
47
+ private rightPanel!: BoxRenderable
48
+ private statusText!: TextRenderable
49
+
50
+ // State
51
+ private mode: Mode = "containers"
52
+ private containers: ContainerStatus[] = []
53
+ private selectedIndex = 0
54
+ private isLoading = false
55
+ private statusMessage = ""
25
56
 
26
57
  constructor(renderer: RenderContext, container: BoxRenderable, app: App, config: EasiarrConfig) {
27
- this.renderer = renderer
58
+ this.renderer = renderer as CliRenderer
28
59
  this.container = container
29
60
  this.app = app
30
61
  this.config = config
31
62
 
32
- this.render()
63
+ this.init()
33
64
  }
34
65
 
35
- private async render(): Promise<void> {
36
- const statuses = await getContainerStatuses()
37
- const runningCount = statuses.filter((s) => s.status === "running").length
38
- const totalCount = statuses.length
66
+ private async init(): Promise<void> {
67
+ await this.loadContainers()
68
+ this.buildUI()
69
+ this.attachKeyHandler()
70
+ }
39
71
 
40
- // Status in header info?
41
- const statusText = `Status: ${runningCount}/${totalCount} Running`
72
+ private async loadContainers(): Promise<void> {
73
+ this.isLoading = true
74
+ this.containers = await getContainerStatuses()
75
+ this.isLoading = false
76
+ }
77
+
78
+ private buildUI(): void {
79
+ const runningCount = this.containers.filter((s) => s.status === "running").length
80
+ const totalCount = this.containers.length
81
+ const dockerOk = this.containers.length > 0 || !this.isLoading
42
82
 
43
- // Check Docker availability first to possibly change status
44
- const dockerOk = await isDockerAvailable()
45
- const finalStepInfo = dockerOk ? statusText : "Internal Error: Docker Unavailable"
83
+ const statusText = `${runningCount}/${totalCount} Running`
84
+ const finalStepInfo = dockerOk ? statusText : "Docker Unavailable"
46
85
 
47
- const { container: page, content } = createPageLayout(this.renderer as CliRenderer, {
86
+ const { container: page, content } = createPageLayout(this.renderer, {
48
87
  title: "Container Control",
49
88
  stepInfo: finalStepInfo,
50
89
  footerHint: [
51
- { type: "key", key: "Enter", value: "Select/Action" },
52
- { type: "key", key: "q", value: "Back" },
90
+ { type: "key", key: "Tab", value: "Switch Tab" },
91
+ { type: "key", key: "↑↓", value: "Navigate" },
92
+ { type: "key", key: "Enter", value: "Action" },
93
+ { type: "separator" },
94
+ { type: "key", key: "s", value: "Start" },
95
+ { type: "key", key: "x", value: "Stop" },
96
+ { type: "key", key: "r", value: "Restart" },
97
+ { type: "key", key: "l", value: "Logs" },
98
+ { type: "key", key: "u", value: "Update" },
53
99
  ],
54
100
  })
101
+ this.page = page
102
+
103
+ // Tab selector
104
+ this.modeTabs = new TabSelectRenderable(this.renderer, {
105
+ id: "mode-tabs",
106
+ width: "100%",
107
+ options: [
108
+ { name: "📦 Containers", value: "containers", description: "" },
109
+ { name: "⚡ Bulk Actions", value: "bulk", description: "" },
110
+ ],
111
+ tabWidth: 18,
112
+ showUnderline: false,
113
+ showDescription: false,
114
+ selectedBackgroundColor: "#4a9eff",
115
+ textColor: "#555555",
116
+ })
117
+
118
+ this.modeTabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index: number) => {
119
+ this.mode = index === 0 ? "containers" : "bulk"
120
+ this.updatePanels()
121
+ })
122
+
123
+ content.add(this.modeTabs)
124
+ content.add(new TextRenderable(this.renderer, { content: " " }))
125
+
126
+ // Two-panel layout
127
+ const panelRow = new BoxRenderable(this.renderer, {
128
+ width: "100%",
129
+ flexGrow: 1,
130
+ flexDirection: "row",
131
+ })
132
+
133
+ // Left panel - Container list or Bulk options
134
+ this.leftPanel = new BoxRenderable(this.renderer, {
135
+ width: "60%",
136
+ height: "100%",
137
+ flexDirection: "column",
138
+ border: true,
139
+ borderStyle: "single",
140
+ borderColor: "#4a9eff",
141
+ title: " Containers ",
142
+ padding: 1,
143
+ })
144
+
145
+ // Right panel - Actions or Info
146
+ this.rightPanel = new BoxRenderable(this.renderer, {
147
+ width: "40%",
148
+ height: "100%",
149
+ flexDirection: "column",
150
+ border: true,
151
+ borderStyle: "single",
152
+ borderColor: "#666666",
153
+ title: " Actions ",
154
+ padding: 1,
155
+ })
156
+
157
+ panelRow.add(this.leftPanel)
158
+ panelRow.add(this.rightPanel)
159
+ content.add(panelRow)
160
+
161
+ // Status bar
162
+ this.statusText = new TextRenderable(this.renderer, {
163
+ id: "status-bar",
164
+ content: "",
165
+ fg: "#f1fa8c",
166
+ })
167
+ content.add(this.statusText)
168
+
169
+ this.container.add(page)
170
+ this.updatePanels()
171
+ }
172
+
173
+ private clearPanel(panel: BoxRenderable): void {
174
+ const children = [...panel.getChildren()]
175
+ for (const child of children) {
176
+ if (child.id) panel.remove(child.id)
177
+ }
178
+ }
179
+
180
+ private updatePanels(): void {
181
+ if (this.mode === "containers") {
182
+ this.renderContainerList()
183
+ this.renderContainerActions()
184
+ } else {
185
+ this.renderBulkOptions()
186
+ this.renderBulkInfo()
187
+ }
188
+ }
55
189
 
56
- if (!dockerOk) {
57
- content.add(
190
+ private renderContainerList(): void {
191
+ this.clearPanel(this.leftPanel)
192
+ this.leftPanel.title = " Containers "
193
+
194
+ if (this.containers.length === 0) {
195
+ this.leftPanel.add(
58
196
  new TextRenderable(this.renderer, {
59
- id: "docker-error",
60
- content: " Docker is not available! Please check your Docker daemon.",
61
- fg: "#ff6666",
197
+ id: "no-containers",
198
+ content: "No containers found.\nRun 'Start All' in Bulk Actions tab.",
199
+ fg: "#888888",
62
200
  })
63
201
  )
202
+ return
64
203
  }
65
204
 
66
- // Spacer
67
- content.add(
68
- new TextRenderable(this.renderer, {
69
- id: "spacer",
70
- content: "",
205
+ // Render container list with selection highlight
206
+ this.containers.forEach((container, idx) => {
207
+ const isSelected = idx === this.selectedIndex
208
+ const icon = container.status === "running" ? "🟢" : "🔴"
209
+ const prefix = isSelected ? "▶ " : " "
210
+
211
+ const row = new BoxRenderable(this.renderer, {
212
+ id: `container-row-${idx}`,
213
+ width: "100%",
214
+ flexDirection: "row",
215
+ backgroundColor: isSelected ? "#2a2a4a" : undefined,
71
216
  })
72
- )
73
217
 
74
- // Show container list
75
- if (statuses.length > 0) {
76
- for (const status of statuses.slice(0, 12)) {
77
- // Show a few more since we have space
78
- const icon = status.status === "running" ? "🟢" : "🔴"
79
- content.add(
218
+ row.add(
219
+ new TextRenderable(this.renderer, {
220
+ id: `container-${idx}`,
221
+ content: `${prefix}${icon} ${container.name}`,
222
+ fg: isSelected ? "#ffffff" : container.status === "running" ? "#50fa7b" : "#666666",
223
+ })
224
+ )
225
+
226
+ // Show port if available
227
+ if (container.ports) {
228
+ row.add(
80
229
  new TextRenderable(this.renderer, {
81
- id: `status-${status.name}`,
82
- content: `${icon} ${status.name}`,
83
- fg: status.status === "running" ? "#00cc66" : "#666666",
230
+ id: `port-${idx}`,
231
+ content: ` (${container.ports.split(",")[0] || ""})`,
232
+ fg: "#888888",
84
233
  })
85
234
  )
86
235
  }
87
- } else if (dockerOk) {
88
- content.add(
236
+
237
+ this.leftPanel.add(row)
238
+ })
239
+ }
240
+
241
+ private renderContainerActions(): void {
242
+ this.clearPanel(this.rightPanel)
243
+ this.rightPanel.title = " Actions "
244
+
245
+ const selected = this.containers[this.selectedIndex]
246
+ if (!selected) {
247
+ this.rightPanel.add(
89
248
  new TextRenderable(this.renderer, {
90
- id: "no-containers",
91
- content: "No containers found. Run 'Start All' first.",
249
+ id: "no-selection",
250
+ content: "No container selected",
92
251
  fg: "#888888",
93
252
  })
94
253
  )
254
+ return
95
255
  }
96
256
 
257
+ // Container info header
258
+ this.rightPanel.add(
259
+ new TextRenderable(this.renderer, {
260
+ id: "selected-name",
261
+ content: `📦 ${selected.name}`,
262
+ fg: "#4a9eff",
263
+ })
264
+ )
265
+ this.rightPanel.add(
266
+ new TextRenderable(this.renderer, {
267
+ id: "selected-status",
268
+ content: `Status: ${selected.status === "running" ? "🟢 Running" : "🔴 Stopped"}`,
269
+ fg: selected.status === "running" ? "#50fa7b" : "#ff5555",
270
+ })
271
+ )
272
+ this.rightPanel.add(new TextRenderable(this.renderer, { content: "" }))
273
+
274
+ // Action buttons based on state
275
+ const actions =
276
+ selected.status === "running"
277
+ ? [
278
+ { key: "x", label: "Stop Container", action: "stop" },
279
+ { key: "r", label: "Restart Container", action: "restart" },
280
+ { key: "l", label: "View Logs", action: "logs" },
281
+ { key: "u", label: "Update (Pull + Recreate)", action: "update" },
282
+ ]
283
+ : [
284
+ { key: "s", label: "Start Container", action: "start" },
285
+ { key: "u", label: "Update (Pull + Recreate)", action: "update" },
286
+ ]
287
+
288
+ this.rightPanel.add(
289
+ new TextRenderable(this.renderer, {
290
+ id: "actions-header",
291
+ content: "Keyboard Shortcuts:",
292
+ fg: "#888888",
293
+ })
294
+ )
295
+
296
+ actions.forEach(({ key, label }, idx) => {
297
+ this.rightPanel.add(
298
+ new TextRenderable(this.renderer, {
299
+ id: `action-${idx}`,
300
+ content: ` [${key}] ${label}`,
301
+ fg: "#f8f8f2",
302
+ })
303
+ )
304
+ })
305
+ }
306
+
307
+ private renderBulkOptions(): void {
308
+ this.clearPanel(this.leftPanel)
309
+ this.leftPanel.title = " Bulk Actions "
310
+
97
311
  const menu = new SelectRenderable(this.renderer, {
98
- id: "container-menu",
312
+ id: "bulk-menu",
99
313
  width: "100%",
100
- height: 8, // Fixed height for actions at bottom
314
+ height: 10,
101
315
  options: [
102
316
  { name: "▶ Start All", description: "docker compose up -d" },
103
317
  { name: "⏹ Stop All", description: "docker compose stop" },
104
318
  { name: "🔄 Restart All", description: "docker compose restart" },
105
319
  { name: "⬇ Down (Remove)", description: "docker compose down" },
320
+ { name: "📥 Pull Updates", description: "docker compose pull" },
106
321
  { name: "🔃 Refresh Status", description: "Update container list" },
107
322
  { name: "◀ Back to Main Menu", description: "" },
108
323
  ],
109
324
  })
110
325
 
111
326
  menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
112
- switch (index) {
113
- case 0:
114
- await composeUp()
115
- break
116
- case 1:
117
- await composeStop()
118
- break
119
- case 2:
120
- await composeRestart()
121
- break
122
- case 3:
123
- await composeDown()
124
- break
125
- case 4:
126
- // Refresh
127
- break
128
- case 5:
129
- this.app.navigateTo("main")
130
- return
131
- }
132
- // Refresh view - clear all children
133
- const children = this.container.getChildren()
134
- for (const child of children) {
135
- this.container.remove(child.id)
136
- }
137
- this.render()
327
+ await this.executeBulkAction(index)
138
328
  })
139
329
 
140
- content.add(menu)
141
- menu.focus()
330
+ this.leftPanel.add(menu)
142
331
 
143
- this.container.add(page)
332
+ // Focus the menu when in bulk mode
333
+ if (this.mode === "bulk") {
334
+ setTimeout(() => menu.focus(), 10)
335
+ }
336
+ }
337
+
338
+ private renderBulkInfo(): void {
339
+ this.clearPanel(this.rightPanel)
340
+ this.rightPanel.title = " Status "
341
+
342
+ const runningCount = this.containers.filter((c) => c.status === "running").length
343
+ const stoppedCount = this.containers.length - runningCount
344
+
345
+ this.rightPanel.add(
346
+ new TextRenderable(this.renderer, {
347
+ id: "total-containers",
348
+ content: `Total: ${this.containers.length} containers`,
349
+ fg: "#f8f8f2",
350
+ })
351
+ )
352
+ this.rightPanel.add(
353
+ new TextRenderable(this.renderer, {
354
+ id: "running-count",
355
+ content: `🟢 Running: ${runningCount}`,
356
+ fg: "#50fa7b",
357
+ })
358
+ )
359
+ this.rightPanel.add(
360
+ new TextRenderable(this.renderer, {
361
+ id: "stopped-count",
362
+ content: `🔴 Stopped: ${stoppedCount}`,
363
+ fg: stoppedCount > 0 ? "#ff5555" : "#666666",
364
+ })
365
+ )
366
+ this.rightPanel.add(new TextRenderable(this.renderer, { content: "" }))
367
+
368
+ // List running containers
369
+ if (runningCount > 0) {
370
+ this.rightPanel.add(
371
+ new TextRenderable(this.renderer, {
372
+ id: "running-header",
373
+ content: "Running:",
374
+ fg: "#888888",
375
+ })
376
+ )
377
+ this.containers
378
+ .filter((c) => c.status === "running")
379
+ .slice(0, 8)
380
+ .forEach((c, idx) => {
381
+ this.rightPanel.add(
382
+ new TextRenderable(this.renderer, {
383
+ id: `running-${idx}`,
384
+ content: ` • ${c.name}`,
385
+ fg: "#50fa7b",
386
+ })
387
+ )
388
+ })
389
+ }
390
+ }
391
+
392
+ private async executeBulkAction(index: number): Promise<void> {
393
+ this.setStatus("Executing...", "#f1fa8c")
394
+
395
+ switch (index) {
396
+ case 0:
397
+ this.setStatus("Starting all containers...", "#f1fa8c")
398
+ await composeUp()
399
+ this.setStatus("✓ All containers started", "#50fa7b")
400
+ break
401
+ case 1:
402
+ this.setStatus("Stopping all containers...", "#f1fa8c")
403
+ await composeStop()
404
+ this.setStatus("✓ All containers stopped", "#50fa7b")
405
+ break
406
+ case 2:
407
+ this.setStatus("Restarting all containers...", "#f1fa8c")
408
+ await composeRestart()
409
+ this.setStatus("✓ All containers restarted", "#50fa7b")
410
+ break
411
+ case 3:
412
+ this.setStatus("Removing containers...", "#f1fa8c")
413
+ await composeDown()
414
+ this.setStatus("✓ Containers removed", "#50fa7b")
415
+ break
416
+ case 4:
417
+ this.setStatus("Pulling latest images...", "#f1fa8c")
418
+ await pullImages()
419
+ this.setStatus("✓ Images updated", "#50fa7b")
420
+ break
421
+ case 5:
422
+ this.setStatus("Refreshing...", "#f1fa8c")
423
+ break
424
+ case 6:
425
+ this.cleanup()
426
+ this.app.navigateTo("main")
427
+ return
428
+ }
429
+
430
+ await this.loadContainers()
431
+ this.updatePanels()
432
+ }
433
+
434
+ private async executeContainerAction(action: string): Promise<void> {
435
+ const selected = this.containers[this.selectedIndex]
436
+ if (!selected) return
437
+
438
+ const serviceName = selected.name
439
+
440
+ switch (action) {
441
+ case "start":
442
+ this.setStatus(`Starting ${serviceName}...`, "#f1fa8c")
443
+ await startContainer(serviceName)
444
+ this.setStatus(`✓ ${serviceName} started`, "#50fa7b")
445
+ break
446
+ case "stop":
447
+ this.setStatus(`Stopping ${serviceName}...`, "#f1fa8c")
448
+ await stopContainer(serviceName)
449
+ this.setStatus(`✓ ${serviceName} stopped`, "#50fa7b")
450
+ break
451
+ case "restart":
452
+ this.setStatus(`Restarting ${serviceName}...`, "#f1fa8c")
453
+ await restartSingleContainer(serviceName)
454
+ this.setStatus(`✓ ${serviceName} restarted`, "#50fa7b")
455
+ break
456
+ case "update":
457
+ this.setStatus(`Updating ${serviceName}...`, "#f1fa8c")
458
+ await recreateService(serviceName)
459
+ this.setStatus(`✓ ${serviceName} updated`, "#50fa7b")
460
+ break
461
+ case "logs":
462
+ await this.showLogs(serviceName)
463
+ return // Don't refresh after logs
464
+ }
465
+
466
+ await this.loadContainers()
467
+ this.updatePanels()
468
+ }
469
+
470
+ private async showLogs(serviceName: string): Promise<void> {
471
+ this.setStatus(`Fetching logs for ${serviceName}...`, "#f1fa8c")
472
+ const result = await getContainerLogs(serviceName, 25)
473
+
474
+ this.clearPanel(this.rightPanel)
475
+ this.rightPanel.title = ` Logs: ${serviceName} `
476
+
477
+ if (!result.success) {
478
+ this.rightPanel.add(
479
+ new TextRenderable(this.renderer, {
480
+ id: "logs-error",
481
+ content: "Failed to fetch logs",
482
+ fg: "#ff5555",
483
+ })
484
+ )
485
+ return
486
+ }
487
+
488
+ // Display logs (truncated for UI)
489
+ const logLines = result.output.split("\n").slice(-20)
490
+ logLines.forEach((line, idx) => {
491
+ this.rightPanel.add(
492
+ new TextRenderable(this.renderer, {
493
+ id: `log-${idx}`,
494
+ content: line.substring(0, 60),
495
+ fg: "#888888",
496
+ })
497
+ )
498
+ })
499
+
500
+ this.setStatus("Press any key to close logs", "#4a9eff")
501
+ }
502
+
503
+ private setStatus(message: string, color: string): void {
504
+ this.statusMessage = message
505
+ if (this.statusText) {
506
+ this.statusText.content = message
507
+ this.statusText.fg = color
508
+ }
509
+ }
510
+
511
+ private attachKeyHandler(): void {
512
+ this.keyHandler = (k: KeyEvent) => {
513
+ if (this.mode === "containers") {
514
+ this.handleContainerModeKey(k)
515
+ } else {
516
+ this.handleBulkModeKey(k)
517
+ }
518
+ }
519
+ this.renderer.keyInput.on("keypress", this.keyHandler)
520
+ }
521
+
522
+ private handleContainerModeKey(k: KeyEvent): void {
523
+ const maxIndex = this.containers.length - 1
524
+
525
+ switch (k.name) {
526
+ case "up":
527
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1)
528
+ this.updatePanels()
529
+ break
530
+ case "down":
531
+ this.selectedIndex = Math.min(maxIndex, this.selectedIndex + 1)
532
+ this.updatePanels()
533
+ break
534
+ case "tab":
535
+ this.mode = "bulk"
536
+ this.modeTabs.setSelectedIndex(1)
537
+ this.updatePanels()
538
+ break
539
+ case "s":
540
+ this.executeContainerAction("start")
541
+ break
542
+ case "x":
543
+ this.executeContainerAction("stop")
544
+ break
545
+ case "r":
546
+ this.executeContainerAction("restart")
547
+ break
548
+ case "l":
549
+ this.executeContainerAction("logs")
550
+ break
551
+ case "u":
552
+ this.executeContainerAction("update")
553
+ break
554
+ case "q":
555
+ case "escape":
556
+ this.cleanup()
557
+ this.app.navigateTo("main")
558
+ break
559
+ }
560
+ }
561
+
562
+ private handleBulkModeKey(k: KeyEvent): void {
563
+ if (k.name === "tab") {
564
+ this.mode = "containers"
565
+ this.modeTabs.setSelectedIndex(0)
566
+ this.updatePanels()
567
+ } else if (k.name === "q" || k.name === "escape") {
568
+ this.cleanup()
569
+ this.app.navigateTo("main")
570
+ }
571
+ }
572
+
573
+ private cleanup(): void {
574
+ if (this.keyHandler) {
575
+ this.renderer.keyInput.off("keypress", this.keyHandler)
576
+ this.keyHandler = null
577
+ }
144
578
  }
145
579
  }