@muhammedaksam/easiarr 0.6.2 → 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 +1 -1
- package/src/api/portainer-api.ts +111 -0
- package/src/docker/client.ts +135 -0
- package/src/ui/screens/ContainerControl.ts +509 -75
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/src/api/portainer-api.ts
CHANGED
|
@@ -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
|
}
|
package/src/docker/client.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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.
|
|
63
|
+
this.init()
|
|
33
64
|
}
|
|
34
65
|
|
|
35
|
-
private async
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
66
|
+
private async init(): Promise<void> {
|
|
67
|
+
await this.loadContainers()
|
|
68
|
+
this.buildUI()
|
|
69
|
+
this.attachKeyHandler()
|
|
70
|
+
}
|
|
39
71
|
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
const
|
|
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
|
|
86
|
+
const { container: page, content } = createPageLayout(this.renderer, {
|
|
48
87
|
title: "Container Control",
|
|
49
88
|
stepInfo: finalStepInfo,
|
|
50
89
|
footerHint: [
|
|
51
|
-
{ type: "key", key: "
|
|
52
|
-
{ type: "key", key: "
|
|
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
|
-
|
|
57
|
-
|
|
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: "
|
|
60
|
-
content: "
|
|
61
|
-
fg: "#
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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: `
|
|
82
|
-
content:
|
|
83
|
-
fg:
|
|
230
|
+
id: `port-${idx}`,
|
|
231
|
+
content: ` (${container.ports.split(",")[0] || ""})`,
|
|
232
|
+
fg: "#888888",
|
|
84
233
|
})
|
|
85
234
|
)
|
|
86
235
|
}
|
|
87
|
-
|
|
88
|
-
|
|
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-
|
|
91
|
-
content: "No
|
|
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: "
|
|
312
|
+
id: "bulk-menu",
|
|
99
313
|
width: "100%",
|
|
100
|
-
height:
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
menu.focus()
|
|
330
|
+
this.leftPanel.add(menu)
|
|
142
331
|
|
|
143
|
-
|
|
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
|
}
|