From 681fd3ecfee2d22dc7df08b032f74efe980500ff Mon Sep 17 00:00:00 2001 From: AiAe Date: Wed, 3 Jun 2026 15:50:40 +0300 Subject: [PATCH 1/4] Running stack --- .../manifest.json | 26 ++ .../ui/docker/docker-running-stack-stop.html | 26 ++ src/actions/docker-running-stack-stop.ts | 332 ++++++++++++++++++ src/actions/docker-stack-start.ts | 83 +---- src/plugin.ts | 2 + src/utils/dockerCli.ts | 20 +- src/utils/stackLifecycle.ts | 130 +++++++ 7 files changed, 542 insertions(+), 77 deletions(-) create mode 100644 com.darkdragon14.elgato-docker.sdPlugin/ui/docker/docker-running-stack-stop.html create mode 100644 src/actions/docker-running-stack-stop.ts create mode 100644 src/utils/stackLifecycle.ts diff --git a/com.darkdragon14.elgato-docker.sdPlugin/manifest.json b/com.darkdragon14.elgato-docker.sdPlugin/manifest.json index b84cfbe..0efeae4 100644 --- a/com.darkdragon14.elgato-docker.sdPlugin/manifest.json +++ b/com.darkdragon14.elgato-docker.sdPlugin/manifest.json @@ -55,6 +55,32 @@ ], "UserTitleEnabled": true }, + { + "Name": "Running stack stop", + "UUID": "com.darkdragon14.elgato-docker.docker-running-stack-stop", + "Icon": "imgs/actions/docker/icon", + "Tooltip": "Automatically bind to a running stack and stop it.", + "PropertyInspectorPath": "ui/docker/docker-running-stack-stop.html", + "Controllers": [ + "Keypad" + ], + "States": [ + { + "Image": "imgs/actions/docker-running/key", + "TitleAlignment": "middle" + }, + { + "Image": "imgs/actions/docker-stopped/key", + "TitleAlignment": "middle" + }, + { + "Image": "imgs/actions/error/key", + "TitleAlignment": "middle", + "Title": "Please,\nlaunch\nDocker" + } + ], + "UserTitleEnabled": true + }, { "Name": "Containers Count", "UUID": "com.darkdragon14.elgato-docker.containers-count", diff --git a/com.darkdragon14.elgato-docker.sdPlugin/ui/docker/docker-running-stack-stop.html b/com.darkdragon14.elgato-docker.sdPlugin/ui/docker/docker-running-stack-stop.html new file mode 100644 index 0000000..03b3117 --- /dev/null +++ b/com.darkdragon14.elgato-docker.sdPlugin/ui/docker/docker-running-stack-stop.html @@ -0,0 +1,26 @@ + + + + Running Stack Stop Settings + + + + + + Connection + + + + + Notes + + Each visible key automatically binds to one running Compose or Swarm stack in this context. + + + diff --git a/src/actions/docker-running-stack-stop.ts b/src/actions/docker-running-stack-stop.ts new file mode 100644 index 0000000..15c1b4a --- /dev/null +++ b/src/actions/docker-running-stack-stop.ts @@ -0,0 +1,332 @@ +import streamDeck, { + action, + DidReceiveSettingsEvent, + KeyDownEvent, + SendToPluginEvent, + SingletonAction, + WillAppearEvent, + WillDisappearEvent, +} from "@elgato/streamdeck"; +import type { JsonObject } from "@elgato/utils"; + +import { CONTAINER_STATUS_RUNNING, DOCKER_START_ERROR_STATE } from "../constants/docker"; +import { subscribeContextHealth, unsubscribeContextHealth } from "../utils/contextHealth"; +import { getDockerContextsSnapshot } from "../utils/contextsStore"; +import { listContainers } from "../utils/dockerCli"; +import { listDockerContexts } from "../utils/dockerContext"; +import { getEffectiveContext } from "../utils/getEffectiveContext"; +import { pingDocker } from "../utils/pingDocker"; +import { stopStackLifecycle } from "../utils/stackLifecycle"; +import { getStacksSnapshot, subscribeStacks, unsubscribeStacks } from "../utils/stacksStore"; +import type { StackInfo } from "../utils/stacksStore"; + +type RunningStackStopSettings = { + contextName?: string; +}; + +type VisibleInstance = { + id: string; + action: any; + context?: string; + deviceId?: string; + row?: number; + column?: number; +}; + +const visibleByContext = new Map>(); +const assignedStackByInstance = new Map(); +const swarmDesiredByInstance = new Map>(); +const stackSlotByContext = new Map>(); + +function keyFor(context?: string): string { + return context || "__local__"; +} + +@action({ UUID: "com.darkdragon14.elgato-docker.docker-running-stack-stop" }) +export class DockerRunningStackStop extends SingletonAction { + private lastSettingsByContext: Map = new Map(); + private lastStateByContext: Map = new Map(); + private lastTitleByContext: Map = new Map(); + private updateIntervalsByContext: Map = new Map(); + private updatingByContext: Map = new Map(); + + override async onWillAppear(ev: WillAppearEvent): Promise { + const instanceId = (ev.action as any).id || (ev as any).context; + this.lastSettingsByContext.set(instanceId, ev.payload.settings || {}); + const context = await getEffectiveContext(ev.payload.settings); + this.registerVisibleInstance(instanceId, ev.action, context); + this.startUpdateLoop(context); + + subscribeContextHealth(context, instanceId, (up) => { + if (!up) { + this.applyIfChanged(ev.action, instanceId, "Please,\nlaunch\nDocker", DOCKER_START_ERROR_STATE); + return; + } + this.updateAssignments(context); + }); + + subscribeStacks(context, instanceId, (stacks) => { + this.updateAssignments(context, stacks); + }); + + const dockerIsUp = await pingDocker(ev, DOCKER_START_ERROR_STATE, context); + if (!dockerIsUp) return; + + await this.refreshAssignments(context); + } + + override onWillDisappear(ev: WillDisappearEvent): void { + const instanceId = (ev.action as any).id || (ev as any).context; + const context = getContextFromSettings(this.lastSettingsByContext.get(instanceId)); + this.unregisterVisibleInstance(instanceId, context); + unsubscribeContextHealth(context, instanceId); + unsubscribeStacks(context, instanceId); + assignedStackByInstance.delete(instanceId); + swarmDesiredByInstance.delete(instanceId); + this.lastSettingsByContext.delete(instanceId); + this.lastStateByContext.delete(instanceId); + this.lastTitleByContext.delete(instanceId); + this.stopUpdateLoopIfUnused(context); + this.refreshAssignments(context).catch(() => undefined); + } + + override async onDidReceiveSettings(ev: DidReceiveSettingsEvent): Promise { + const instanceId = (ev.action as any).id || (ev as any).context; + const previousContext = getContextFromSettings(this.lastSettingsByContext.get(instanceId)); + const nextSettings = ev.payload.settings || {}; + const nextContext = await getEffectiveContext(nextSettings); + + this.unregisterVisibleInstance(instanceId, previousContext); + unsubscribeContextHealth(previousContext, instanceId); + unsubscribeStacks(previousContext, instanceId); + + this.lastSettingsByContext.set(instanceId, nextSettings); + this.registerVisibleInstance(instanceId, ev.action, nextContext); + this.stopUpdateLoopIfUnused(previousContext); + this.startUpdateLoop(nextContext); + subscribeContextHealth(nextContext, instanceId, (up) => { + if (!up) { + this.applyIfChanged(ev.action, instanceId, "Please,\nlaunch\nDocker", DOCKER_START_ERROR_STATE); + return; + } + this.updateAssignments(nextContext); + }); + subscribeStacks(nextContext, instanceId, (stacks) => this.updateAssignments(nextContext, stacks)); + + await this.refreshAssignments(previousContext); + await this.refreshAssignments(nextContext); + } + + override async onSendToPlugin(ev: SendToPluginEvent): Promise { + if (ev.payload.event === "getDockerContexts") { + const snap = getDockerContextsSnapshot(); + const items = snap + ? Array.from(snap.values()).map((c) => ({ label: c.name, value: c.name })) + : (await listDockerContexts()).map((c) => ({ label: c.name, value: c.name })); + streamDeck.ui.sendToPropertyInspector({ event: "getDockerContexts", items }); + } + streamDeck.connect(); + } + + override async onKeyDown(ev: KeyDownEvent): Promise { + const instanceId = (ev.action as any).id || (ev as any).context; + const context = await getEffectiveContext(ev.payload.settings); + const dockerIsUp = await pingDocker(ev, DOCKER_START_ERROR_STATE, context); + if (!dockerIsUp) return; + + const stackName = assignedStackByInstance.get(instanceId); + if (!stackName) { + this.applyIfChanged(ev.action, instanceId, "Empty", 1); + return; + } + + const result = await stopStackLifecycle(stackName, context, (desired) => swarmDesiredByInstance.set(instanceId, desired)); + if (result === "not-found") { + this.applyIfChanged(ev.action, instanceId, "Not\nFound", 1); + return; + } + + await this.refreshAssignments(context); + } + + private registerVisibleInstance(id: string, actionRef: any, context?: string): void { + const key = keyFor(context); + let instances = visibleByContext.get(key); + if (!instances) { + instances = new Map(); + visibleByContext.set(key, instances); + } + instances.set(id, { + id, + action: actionRef, + context, + deviceId: actionRef?.device?.id, + row: actionRef?.coordinates?.row, + column: actionRef?.coordinates?.column, + }); + } + + private unregisterVisibleInstance(id: string, context?: string): void { + const key = keyFor(context); + const instances = visibleByContext.get(key); + if (!instances) return; + instances.delete(id); + if (instances.size === 0) visibleByContext.delete(key); + } + + private updateAssignments(context?: string, stacks = getStacksSnapshot(context)): void { + const contextKey = keyFor(context); + const instances = Array.from(visibleByContext.get(contextKey)?.values() || []).sort(compareInstancesByPosition); + const runningStacks = Array.from(stacks?.values() || []) + .filter((stack) => stack.running > 0) + .sort((a, b) => a.name.localeCompare(b.name)); + const visibleInstanceIds = new Set(instances.map((instance) => instance.id)); + const slots = this.getStackSlots(contextKey); + for (const [stackName, instanceId] of slots) { + if (!visibleInstanceIds.has(instanceId)) slots.delete(stackName); + } + + const assignedInstanceIds = new Set(); + for (const stack of runningStacks) { + const slottedInstanceId = slots.get(stack.name); + if (slottedInstanceId && visibleInstanceIds.has(slottedInstanceId) && !assignedInstanceIds.has(slottedInstanceId)) { + assignedInstanceIds.add(slottedInstanceId); + } + } + + for (const stack of runningStacks) { + const slottedInstanceId = slots.get(stack.name); + if (slottedInstanceId && assignedInstanceIds.has(slottedInstanceId)) continue; + + const nextInstance = instances.find((instance) => !assignedInstanceIds.has(instance.id)); + if (!nextInstance) continue; + + removeReservationsForInstance(slots, stack.name, nextInstance.id); + slots.set(stack.name, nextInstance.id); + assignedInstanceIds.add(nextInstance.id); + } + + const runningStackByInstance = new Map(); + for (const stack of runningStacks) { + const instanceId = slots.get(stack.name); + if (instanceId) runningStackByInstance.set(instanceId, stack); + } + + instances.forEach((instance) => { + const stack = runningStackByInstance.get(instance.id); + assignedStackByInstance.set(instance.id, stack?.name); + if (!stack) { + this.applyIfChanged(instance.action, instance.id, "Empty", 1); + return; + } + this.applyIfChanged(instance.action, instance.id, this.formatTitle(stack), 0); + }); + } + + private getStackSlots(contextKey: string): Map { + let slots = stackSlotByContext.get(contextKey); + if (!slots) { + slots = new Map(); + stackSlotByContext.set(contextKey, slots); + } + return slots; + } + + private async refreshAssignments(context?: string): Promise { + const contextKey = keyFor(context); + if (this.updatingByContext.get(contextKey)) return; + this.updatingByContext.set(contextKey, true); + try { + const stacks = await this.listRunningStacks(context); + this.updateAssignments(context, stacks); + } catch { + this.updateAssignments(context); + } finally { + this.updatingByContext.delete(contextKey); + } + } + + private async listRunningStacks(context?: string): Promise> { + const containers = await listContainers(true, context); + const stacks = new Map(); + for (const container of containers) { + const labels = container.labels || {}; + const name = labels["com.docker.compose.project"] || labels["com.docker.stack.namespace"]; + if (!name) continue; + + const existing = stacks.get(name) || { name, running: 0, total: 0 }; + existing.total += 1; + if (container.state === CONTAINER_STATUS_RUNNING) existing.running += 1; + stacks.set(name, existing); + } + for (const [name, stack] of stacks) { + if (stack.running === 0) stacks.delete(name); + } + return stacks; + } + + private startUpdateLoop(context?: string): void { + const contextKey = keyFor(context); + if (this.updateIntervalsByContext.has(contextKey)) return; + const handle = setInterval(() => { + this.refreshAssignments(context).catch(() => undefined); + }, 1500); + this.updateIntervalsByContext.set(contextKey, handle as any); + } + + private stopUpdateLoopIfUnused(context?: string): void { + const contextKey = keyFor(context); + if ((visibleByContext.get(contextKey)?.size || 0) > 0) return; + const handle = this.updateIntervalsByContext.get(contextKey); + if (!handle) return; + clearInterval(handle); + this.updateIntervalsByContext.delete(contextKey); + this.updatingByContext.delete(contextKey); + } + + private formatTitle(stack: StackInfo): string { + return stack.name.split("-").join("\n"); + } + + private applyIfChanged(actionRef: any, id: string, title: string, state: number): void { + const prevTitle = this.lastTitleByContext.get(id); + const prevState = this.lastStateByContext.get(id); + if (prevTitle !== title) { + actionRef.setTitle(title); + this.lastTitleByContext.set(id, title); + } + if (prevState !== state) { + actionRef.setState(state); + this.lastStateByContext.set(id, state); + } + } +} + +function getContextFromSettings(settings?: RunningStackStopSettings): string | undefined { + const contextName = (settings?.contextName ?? "").toString(); + if (!contextName || contextName === "default") return undefined; + return contextName; +} + +function compareInstancesByPosition(a: VisibleInstance, b: VisibleInstance): number { + const device = (a.deviceId || "").localeCompare(b.deviceId || ""); + if (device !== 0) return device; + + const aHasPosition = Number.isFinite(a.row) && Number.isFinite(a.column); + const bHasPosition = Number.isFinite(b.row) && Number.isFinite(b.column); + if (aHasPosition && bHasPosition) { + const row = (a.row as number) - (b.row as number); + if (row !== 0) return row; + const column = (a.column as number) - (b.column as number); + if (column !== 0) return column; + } + if (aHasPosition !== bHasPosition) return aHasPosition ? -1 : 1; + + return a.id.localeCompare(b.id); +} + +function removeReservationsForInstance(slots: Map, currentStackName: string, instanceId: string): void { + for (const [stackName, slottedInstanceId] of slots) { + if (stackName !== currentStackName && slottedInstanceId === instanceId) slots.delete(stackName); + } +} diff --git a/src/actions/docker-stack-start.ts b/src/actions/docker-stack-start.ts index 2df523a..0d7c736 100644 --- a/src/actions/docker-stack-start.ts +++ b/src/actions/docker-stack-start.ts @@ -16,15 +16,11 @@ import { containersByComposeProject, isSwarmStack, listComposeProjects, - listSwarmServicesInStack, - scaleSwarmService, - startContainer, - stopContainer, - waitContainer, } from "../utils/dockerCli"; import { listDockerContexts } from "../utils/dockerContext"; import { getEffectiveContext } from "../utils/getEffectiveContext"; import { pingDocker } from "../utils/pingDocker"; +import { toggleStackLifecycle } from "../utils/stackLifecycle"; import { getStacksSnapshot, subscribeStacks, unsubscribeStacks } from "../utils/stacksStore"; type DockerStackStartSettings = { @@ -165,77 +161,14 @@ export class DockerStackStart extends SingletonAction return; } - const containers = await containersByComposeProject(stackName, context); - let swarm = false; - // If no containers, still check if it's a swarm stack (scaled to 0) - try { - swarm = await isSwarmStack(stackName, context); - } catch {} - - if (!swarm && containers.length === 0) { + const result = await toggleStackLifecycle( + stackName, + context, + this.swarmDesiredByInstance.get(instanceId), + (desired) => this.swarmDesiredByInstance.set(instanceId, desired), + ); + if (result === "not-found") { ev.action.setTitle("Not\nFound"); - return; - } - - if (swarm) { - // Toggle by scaling services - const running = containers.some((c) => c.state === CONTAINER_STATUS_RUNNING); - if (running) { - // Scale all services to 0 and remember desired replicas - const services = await listSwarmServicesInStack(stackName, context); - const desired: Record = {}; - for (const s of services) { - if (s.mode?.toLowerCase() === "global") { - streamDeck.logger.warn(`Global service ${s.name} cannot be scaled; skipping.`); - continue; - } - const target = Number.isFinite(s.replicasDesired as any) ? (s.replicasDesired as number) : 1; - desired[s.name] = target; - try { - await scaleSwarmService(s.name, 0, context); - } catch (e: any) { - streamDeck.logger.warn(`Failed scaling service ${s.name} to 0: ${e?.message || e}`); - } - } - this.swarmDesiredByInstance.set(instanceId, desired); - } else { - // Scale back to previous desired replicas (default 1) - const remembered = this.swarmDesiredByInstance.get(instanceId) || {}; - const services = await listSwarmServicesInStack(stackName, context); - for (const s of services) { - if (s.mode?.toLowerCase() === "global") continue; - const target = - remembered[s.name] ?? (Number.isFinite(s.replicasDesired as any) ? (s.replicasDesired as number) : 1); - try { - await scaleSwarmService(s.name, Math.max(1, target), context); - } catch (e: any) { - streamDeck.logger.warn(`Failed scaling service ${s.name}: ${e?.message || e}`); - } - } - } - } else { - // Compose-style: start/stop each container - const allRunning = containers.every((c) => c.state === CONTAINER_STATUS_RUNNING); - if (allRunning) { - for (const c of containers) { - try { - await stopContainer(c.name, context).catch(() => {}); - await waitContainer(c.name, context).catch(() => {}); - } catch (e: any) { - streamDeck.logger.warn(`Failed stopping container: ${e?.message || e}`); - } - } - } else { - for (const c of containers) { - try { - if (c.state !== CONTAINER_STATUS_RUNNING) { - await startContainer(c.name, context).catch(() => {}); - } - } catch (e: any) { - streamDeck.logger.warn(`Failed starting container: ${e?.message || e}`); - } - } - } } } diff --git a/src/plugin.ts b/src/plugin.ts index 7834a70..9d2cc65 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,7 @@ import streamDeck from "@elgato/streamdeck"; import { ContainersCount } from "./actions/containers-count"; +import { DockerRunningStackStop } from "./actions/docker-running-stack-stop"; import { DockerRunOrRm } from "./actions/docker-run-or-rm"; import { DockerSelectToggle } from "./actions/docker-select-toggle"; import { DockerStackStart } from "./actions/docker-stack-start"; @@ -13,6 +14,7 @@ streamDeck.logger.setLevel("trace"); streamDeck.actions.registerAction(new DockerRunOrRm()); streamDeck.actions.registerAction(new DockerStart()); streamDeck.actions.registerAction(new DockerStackStart()); +streamDeck.actions.registerAction(new DockerRunningStackStop()); streamDeck.actions.registerAction(new ContainersCount()); streamDeck.actions.registerAction(new DockerSelectToggle()); diff --git a/src/utils/dockerCli.ts b/src/utils/dockerCli.ts index b98583a..f8106d7 100644 --- a/src/utils/dockerCli.ts +++ b/src/utils/dockerCli.ts @@ -153,15 +153,31 @@ export async function listContainers(all: boolean, context?: string, filters: st const [name, state, labelsJson] = line.split("\t"); let labels: Record | undefined; try { - labels = labelsJson ? JSON.parse(labelsJson) : undefined; + labels = labelsJson ? parseDockerLabels(JSON.parse(labelsJson)) : undefined; } catch { - labels = undefined; + labels = labelsJson ? parseDockerLabels(labelsJson) : undefined; } items.push({ name, state, labels }); } return items; } +function parseDockerLabels(value: unknown): Record | undefined { + if (!value) return undefined; + if (typeof value === "object" && !Array.isArray(value)) return value as Record; + if (typeof value !== "string") return undefined; + + const labels: Record = {}; + for (const item of value.split(",")) { + const separator = item.indexOf("="); + if (separator <= 0) continue; + const key = item.slice(0, separator); + const labelValue = item.slice(separator + 1); + labels[key] = labelValue; + } + return Object.keys(labels).length > 0 ? labels : undefined; +} + export async function getContainerState(name: string, context?: string): Promise { try { const out = await runDocker(["inspect", "-f", "{{.State.Status}}", name], context, { priority: "normal" }); diff --git a/src/utils/stackLifecycle.ts b/src/utils/stackLifecycle.ts new file mode 100644 index 0000000..9fea2b3 --- /dev/null +++ b/src/utils/stackLifecycle.ts @@ -0,0 +1,130 @@ +import streamDeck from "@elgato/streamdeck"; + +import { CONTAINER_STATUS_RUNNING } from "../constants/docker"; +import { + containersByComposeProject, + isSwarmStack, + listSwarmServicesInStack, + scaleSwarmService, + startContainer, + stopContainer, + waitContainer, +} from "./dockerCli"; + +export type StackToggleResult = "ok" | "not-found"; + +export async function toggleStackLifecycle( + stackName: string, + context: string | undefined, + rememberedSwarmDesired: Record | undefined, + rememberSwarmDesired: (desired: Record) => void, +): Promise { + const containers = await containersByComposeProject(stackName, context); + let swarm = false; + try { + swarm = await isSwarmStack(stackName, context); + } catch {} + + if (!swarm && containers.length === 0) return "not-found"; + + if (swarm) { + const running = containers.some((c) => c.state === CONTAINER_STATUS_RUNNING); + if (running) { + await stopSwarmStack(stackName, context, rememberSwarmDesired); + } else { + await startSwarmStack(stackName, context, rememberedSwarmDesired); + } + return "ok"; + } + + const allRunning = containers.every((c) => c.state === CONTAINER_STATUS_RUNNING); + if (allRunning) { + await stopComposeStack(containers, context); + } else { + for (const c of containers) { + try { + if (c.state !== CONTAINER_STATUS_RUNNING) { + await startContainer(c.name, context).catch(() => {}); + } + } catch (e: any) { + streamDeck.logger.warn(`Failed starting container: ${e?.message || e}`); + } + } + } + + return "ok"; +} + +export async function stopStackLifecycle( + stackName: string, + context: string | undefined, + rememberSwarmDesired: (desired: Record) => void, +): Promise { + const containers = await containersByComposeProject(stackName, context); + let swarm = false; + try { + swarm = await isSwarmStack(stackName, context); + } catch {} + + if (!swarm && containers.length === 0) return "not-found"; + + if (swarm) { + await stopSwarmStack(stackName, context, rememberSwarmDesired); + } else { + await stopComposeStack(containers, context); + } + + return "ok"; +} + +async function stopComposeStack(containers: { name: string }[], context?: string): Promise { + for (const c of containers) { + try { + await stopContainer(c.name, context).catch(() => {}); + await waitContainer(c.name, context).catch(() => {}); + } catch (e: any) { + streamDeck.logger.warn(`Failed stopping container: ${e?.message || e}`); + } + } +} + +async function stopSwarmStack( + stackName: string, + context: string | undefined, + rememberSwarmDesired: (desired: Record) => void, +): Promise { + const services = await listSwarmServicesInStack(stackName, context); + const desired: Record = {}; + for (const s of services) { + if (s.mode?.toLowerCase() === "global") { + streamDeck.logger.warn(`Global service ${s.name} cannot be scaled; skipping.`); + continue; + } + const target = Number.isFinite(s.replicasDesired as any) ? (s.replicasDesired as number) : 1; + desired[s.name] = target; + try { + await scaleSwarmService(s.name, 0, context); + } catch (e: any) { + streamDeck.logger.warn(`Failed scaling service ${s.name} to 0: ${e?.message || e}`); + } + } + rememberSwarmDesired(desired); +} + +async function startSwarmStack( + stackName: string, + context: string | undefined, + rememberedSwarmDesired: Record | undefined, +): Promise { + const remembered = rememberedSwarmDesired || {}; + const services = await listSwarmServicesInStack(stackName, context); + for (const s of services) { + if (s.mode?.toLowerCase() === "global") continue; + const target = remembered[s.name] ?? (Number.isFinite(s.replicasDesired as any) ? (s.replicasDesired as number) : 1); + try { + await scaleSwarmService(s.name, Math.max(1, target), context); + } catch (e: any) { + streamDeck.logger.warn(`Failed scaling service ${s.name}: ${e?.message || e}`); + } + } +} From c28e442707a1eba8e28fb102db1fa94cceff965f Mon Sep 17 00:00:00 2001 From: AiAe Date: Wed, 3 Jun 2026 16:42:43 +0300 Subject: [PATCH 2/4] Move try/catch to parseDockerLabels --- .gitignore | 4 +++- src/utils/dockerCli.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index dfdad5d..4153994 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ node_modules/ *.sdPlugin/bin *.sdPlugin/logs .vscode -*.streamDeckPlugin \ No newline at end of file +*.streamDeckPlugin +.npm-cache +.DS_Store \ No newline at end of file diff --git a/src/utils/dockerCli.ts b/src/utils/dockerCli.ts index f8106d7..879f684 100644 --- a/src/utils/dockerCli.ts +++ b/src/utils/dockerCli.ts @@ -151,12 +151,7 @@ export async function listContainers(all: boolean, context?: string, filters: st for (const line of out.split(/\r?\n/)) { if (!line.trim()) continue; const [name, state, labelsJson] = line.split("\t"); - let labels: Record | undefined; - try { - labels = labelsJson ? parseDockerLabels(JSON.parse(labelsJson)) : undefined; - } catch { - labels = labelsJson ? parseDockerLabels(labelsJson) : undefined; - } + const labels = parseDockerLabels(labelsJson); items.push({ name, state, labels }); } return items; @@ -164,6 +159,11 @@ export async function listContainers(all: boolean, context?: string, filters: st function parseDockerLabels(value: unknown): Record | undefined { if (!value) return undefined; + if (typeof value === "string") { + try { + return parseDockerLabels(JSON.parse(value)); + } catch {} + } if (typeof value === "object" && !Array.isArray(value)) return value as Record; if (typeof value !== "string") return undefined; From 2a7bbf45158069c783931ada8d2cd6756562b418 Mon Sep 17 00:00:00 2001 From: AiAe Date: Wed, 3 Jun 2026 16:46:52 +0300 Subject: [PATCH 3/4] Remove streamdeck.connect() from actions --- src/actions/containers-count.ts | 1 - src/actions/docker-run-or-rm.ts | 1 - src/actions/docker-running-stack-stop.ts | 1 - src/actions/docker-stack-start.ts | 1 - src/actions/docker-start.ts | 1 - 5 files changed, 5 deletions(-) diff --git a/src/actions/containers-count.ts b/src/actions/containers-count.ts index 1786c6e..10e7f6b 100644 --- a/src/actions/containers-count.ts +++ b/src/actions/containers-count.ts @@ -48,7 +48,6 @@ export class ContainersCount extends SingletonAction { : (await listDockerContexts()).map((c) => ({ label: c.name, value: c.name })); streamDeck.ui.sendToPropertyInspector({ event: "getDockerContexts", items }); } - streamDeck.connect(); } override async onWillAppear(ev: WillAppearEvent): Promise { diff --git a/src/actions/docker-run-or-rm.ts b/src/actions/docker-run-or-rm.ts index ad0be47..92939cf 100644 --- a/src/actions/docker-run-or-rm.ts +++ b/src/actions/docker-run-or-rm.ts @@ -91,7 +91,6 @@ export class DockerRunOrRm extends SingletonAction { : (await listDockerContexts()).map((c) => ({ label: c.name, value: c.name })); streamDeck.ui.sendToPropertyInspector({ event: "getDockerContexts", items }); } - streamDeck.connect(); } override async onKeyDown(ev: KeyDownEvent): Promise { diff --git a/src/actions/docker-running-stack-stop.ts b/src/actions/docker-running-stack-stop.ts index 15c1b4a..becfb95 100644 --- a/src/actions/docker-running-stack-stop.ts +++ b/src/actions/docker-running-stack-stop.ts @@ -125,7 +125,6 @@ export class DockerRunningStackStop extends SingletonAction ({ label: c.name, value: c.name })); streamDeck.ui.sendToPropertyInspector({ event: "getDockerContexts", items }); } - streamDeck.connect(); } override async onKeyDown(ev: KeyDownEvent): Promise { diff --git a/src/actions/docker-stack-start.ts b/src/actions/docker-stack-start.ts index 0d7c736..1835e10 100644 --- a/src/actions/docker-stack-start.ts +++ b/src/actions/docker-stack-start.ts @@ -122,7 +122,6 @@ export class DockerStackStart extends SingletonAction : (await listDockerContexts()).map((c) => ({ label: c.name, value: c.name })); streamDeck.ui.sendToPropertyInspector({ event: "getDockerContexts", items }); } - streamDeck.connect(); } override onDidReceiveSettings(ev: DidReceiveSettingsEvent): void { diff --git a/src/actions/docker-start.ts b/src/actions/docker-start.ts index 43e3f14..ead355c 100644 --- a/src/actions/docker-start.ts +++ b/src/actions/docker-start.ts @@ -124,7 +124,6 @@ export class DockerStart extends SingletonAction { : (await listDockerContexts()).map((c) => ({ label: c.name, value: c.name })); streamDeck.ui.sendToPropertyInspector({ event: "getDockerContexts", items }); } - streamDeck.connect(); } override onDidReceiveSettings(ev: DidReceiveSettingsEvent): void { From bdb1f2d624817c0b3a7624ddef33b794c47c8171 Mon Sep 17 00:00:00 2001 From: AiAe Date: Wed, 3 Jun 2026 16:50:31 +0300 Subject: [PATCH 4/4] Run prettier --- src/actions/docker-running-stack-stop.ts | 10 ++++++++-- src/actions/docker-stack-start.ts | 6 +----- src/plugin.ts | 2 +- src/utils/stackLifecycle.ts | 3 ++- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/actions/docker-running-stack-stop.ts b/src/actions/docker-running-stack-stop.ts index becfb95..07ef6cb 100644 --- a/src/actions/docker-running-stack-stop.ts +++ b/src/actions/docker-running-stack-stop.ts @@ -139,7 +139,9 @@ export class DockerRunningStackStop extends SingletonAction swarmDesiredByInstance.set(instanceId, desired)); + const result = await stopStackLifecycle(stackName, context, (desired) => + swarmDesiredByInstance.set(instanceId, desired), + ); if (result === "not-found") { this.applyIfChanged(ev.action, instanceId, "Not\nFound", 1); return; @@ -188,7 +190,11 @@ export class DockerRunningStackStop extends SingletonAction(); for (const stack of runningStacks) { const slottedInstanceId = slots.get(stack.name); - if (slottedInstanceId && visibleInstanceIds.has(slottedInstanceId) && !assignedInstanceIds.has(slottedInstanceId)) { + if ( + slottedInstanceId && + visibleInstanceIds.has(slottedInstanceId) && + !assignedInstanceIds.has(slottedInstanceId) + ) { assignedInstanceIds.add(slottedInstanceId); } } diff --git a/src/actions/docker-stack-start.ts b/src/actions/docker-stack-start.ts index 1835e10..3c353b9 100644 --- a/src/actions/docker-stack-start.ts +++ b/src/actions/docker-stack-start.ts @@ -12,11 +12,7 @@ import type { JsonObject } from "@elgato/utils"; import { CONTAINER_STATUS_RUNNING, DOCKER_START_ERROR_STATE } from "../constants/docker"; import { subscribeContextHealth, unsubscribeContextHealth } from "../utils/contextHealth"; import { getDockerContextsSnapshot } from "../utils/contextsStore"; -import { - containersByComposeProject, - isSwarmStack, - listComposeProjects, -} from "../utils/dockerCli"; +import { containersByComposeProject, isSwarmStack, listComposeProjects } from "../utils/dockerCli"; import { listDockerContexts } from "../utils/dockerContext"; import { getEffectiveContext } from "../utils/getEffectiveContext"; import { pingDocker } from "../utils/pingDocker"; diff --git a/src/plugin.ts b/src/plugin.ts index 9d2cc65..3848d08 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,8 +1,8 @@ import streamDeck from "@elgato/streamdeck"; import { ContainersCount } from "./actions/containers-count"; -import { DockerRunningStackStop } from "./actions/docker-running-stack-stop"; import { DockerRunOrRm } from "./actions/docker-run-or-rm"; +import { DockerRunningStackStop } from "./actions/docker-running-stack-stop"; import { DockerSelectToggle } from "./actions/docker-select-toggle"; import { DockerStackStart } from "./actions/docker-stack-start"; import { DockerStart } from "./actions/docker-start"; diff --git a/src/utils/stackLifecycle.ts b/src/utils/stackLifecycle.ts index 9fea2b3..db3d8d2 100644 --- a/src/utils/stackLifecycle.ts +++ b/src/utils/stackLifecycle.ts @@ -120,7 +120,8 @@ async function startSwarmStack( const services = await listSwarmServicesInStack(stackName, context); for (const s of services) { if (s.mode?.toLowerCase() === "global") continue; - const target = remembered[s.name] ?? (Number.isFinite(s.replicasDesired as any) ? (s.replicasDesired as number) : 1); + const target = + remembered[s.name] ?? (Number.isFinite(s.replicasDesired as any) ? (s.replicasDesired as number) : 1); try { await scaleSwarmService(s.name, Math.max(1, target), context); } catch (e: any) {