From fe1bb1974340965ab43a182a64bab330c379b3e4 Mon Sep 17 00:00:00 2001 From: AiAe Date: Wed, 3 Jun 2026 17:54:32 +0300 Subject: [PATCH 1/5] Implement feature --- .../layouts/full-canvas.json | 15 ++ .../manifest.json | 17 ++ src/actions/docker-stack-select-start.ts | 238 ++++++++++++++++++ src/plugin.ts | 2 + src/utils/stackLifecycle.ts | 31 +++ 5 files changed, 303 insertions(+) create mode 100644 com.darkdragon14.elgato-docker.sdPlugin/layouts/full-canvas.json create mode 100644 src/actions/docker-stack-select-start.ts diff --git a/com.darkdragon14.elgato-docker.sdPlugin/layouts/full-canvas.json b/com.darkdragon14.elgato-docker.sdPlugin/layouts/full-canvas.json new file mode 100644 index 0000000..4b0a36d --- /dev/null +++ b/com.darkdragon14.elgato-docker.sdPlugin/layouts/full-canvas.json @@ -0,0 +1,15 @@ +{ + "id": "full-canvas", + "items": [ + { + "key": "canvas", + "type": "pixmap", + "rect": [ + 0, + 0, + 200, + 100 + ] + } + ] +} diff --git a/com.darkdragon14.elgato-docker.sdPlugin/manifest.json b/com.darkdragon14.elgato-docker.sdPlugin/manifest.json index 0efeae4..b1da41a 100644 --- a/com.darkdragon14.elgato-docker.sdPlugin/manifest.json +++ b/com.darkdragon14.elgato-docker.sdPlugin/manifest.json @@ -145,6 +145,23 @@ "Image": "imgs/actions/docker-running/key" } ] + }, + { + "Icon": "imgs/actions/docker-running/key", + "UUID": "com.darkdragon14.elgato-docker.docker-stack-select-start", + "Name": "Docker Stack Select and Start", + "Tooltip": "Rotate to change stack and click to start it.", + "Controllers": [ + "Encoder" + ], + "Encoder": { + "layout": "layouts/full-canvas.json" + }, + "States": [ + { + "Image": "imgs/actions/docker-running/key" + } + ] } ], "Category": "elgato-docker", diff --git a/src/actions/docker-stack-select-start.ts b/src/actions/docker-stack-select-start.ts new file mode 100644 index 0000000..80db313 --- /dev/null +++ b/src/actions/docker-stack-select-start.ts @@ -0,0 +1,238 @@ +import streamDeck, { + action, + DialDownEvent, + DialRotateEvent, + SingletonAction, + WillAppearEvent, + WillDisappearEvent, +} from "@elgato/streamdeck"; + +import { CONTAINER_STATUS_RUNNING, DOCKER_START_ERROR_STATE } from "../constants/docker"; +import { containersByComposeProject, isSwarmStack, listComposeProjects } from "../utils/dockerCli"; +import { getEffectiveContext } from "../utils/getEffectiveContext"; +import { pingDockerForDials } from "../utils/pingDocker"; +import { startStackLifecycle } from "../utils/stackLifecycle"; + +type DockerStackSelectStartSettings = { + contextName?: string; + remoteHost?: string; +}; + +type StackDialState = { + stacks: string[]; + currentIndex: number; + updateInterval?: NodeJS.Timeout; + rememberedSwarmDesired?: Record; + updating?: boolean; +}; + +const RUNNING_COLOR = "#3fb950"; +const STOPPED_COLOR = "#f85149"; + +@action({ UUID: "com.darkdragon14.elgato-docker.docker-stack-select-start" }) +export class DockerStackSelectStart extends SingletonAction { + private stateByInstance: Map = new Map(); + + override async onWillAppear(ev: WillAppearEvent): Promise { + if (!ev.action.isDial()) return; + + const instanceId = this.getInstanceId(ev); + const context = await getEffectiveContext(ev.payload.settings as DockerStackSelectStartSettings); + const dockerIsUp = await pingDockerForDials(ev, DOCKER_START_ERROR_STATE, context); + const state = this.getState(instanceId); + + if (dockerIsUp) { + await this.updateStacksList(state, context); + this.clampIndex(state); + await this.updateStackFeedback(ev, context); + } + + this.startUpdateLoop(ev, context); + } + + override onWillDisappear(ev: WillDisappearEvent): void { + const instanceId = this.getInstanceId(ev); + const state = this.stateByInstance.get(instanceId); + if (state?.updateInterval) clearInterval(state.updateInterval); + this.stateByInstance.delete(instanceId); + } + + override async onDialRotate(ev: DialRotateEvent): Promise { + const instanceId = this.getInstanceId(ev); + const context = await getEffectiveContext(ev.payload.settings as DockerStackSelectStartSettings); + const dockerIsUp = await pingDockerForDials(ev, DOCKER_START_ERROR_STATE, context); + if (!dockerIsUp) return; + + const state = this.getState(instanceId); + if (state.stacks.length === 0) { + await this.updateStacksList(state, context); + } + if (state.stacks.length === 0) { + this.setFeedback(ev, "No Stack", STOPPED_COLOR); + return; + } + + const ticks = (ev.payload as any).ticks; + const direction = typeof ticks === "number" && ticks < 0 ? -1 : 1; + state.currentIndex = (state.currentIndex + direction + state.stacks.length) % state.stacks.length; + await this.updateStackFeedback(ev, context); + } + + override async onDialDown(ev: DialDownEvent): Promise { + const instanceId = this.getInstanceId(ev); + const context = await getEffectiveContext(ev.payload.settings as DockerStackSelectStartSettings); + const dockerIsUp = await pingDockerForDials(ev, DOCKER_START_ERROR_STATE, context); + if (!dockerIsUp) return; + + const state = this.getState(instanceId); + if (state.stacks.length === 0) { + await this.updateStacksList(state, context); + } + + const stackName = state.stacks[state.currentIndex]; + if (!stackName) { + this.setFeedback(ev, "No Stack", STOPPED_COLOR); + return; + } + + const result = await startStackLifecycle(stackName, context, state.rememberedSwarmDesired); + if (result === "not-found") { + this.setFeedback(ev, "Not Found", STOPPED_COLOR); + return; + } + + await this.updateStacksList(state, context); + this.selectStack(state, stackName); + await this.updateStackFeedback(ev, context); + } + + private startUpdateLoop(ev: any, context?: string): void { + const instanceId = this.getInstanceId(ev); + const state = this.getState(instanceId); + if (state.updateInterval) clearInterval(state.updateInterval); + state.updateInterval = setInterval(async () => { + await this.updateStackFeedback(ev, context); + }, 1000); + } + + private async updateStacksList(state: StackDialState, context?: string): Promise { + const selected = state.stacks[state.currentIndex]; + state.stacks = await listComposeProjects(context); + if (selected) this.selectStack(state, selected); + this.clampIndex(state); + } + + private async updateStackFeedback(ev: any, context?: string): Promise { + const instanceId = this.getInstanceId(ev); + const state = this.getState(instanceId); + if (state.updating) return; + state.updating = true; + try { + const dockerIsUp = await pingDockerForDials(ev, DOCKER_START_ERROR_STATE, context); + if (!dockerIsUp) return; + + if (state.stacks.length === 0) { + await this.updateStacksList(state, context); + } + + const stackName = state.stacks[state.currentIndex]; + if (!stackName) { + this.setFeedback(ev, "No Stack", STOPPED_COLOR); + return; + } + + const containers = await containersByComposeProject(stackName, context); + let swarm = false; + try { + swarm = await isSwarmStack(stackName, context); + } catch {} + + if (!swarm && containers.length === 0) { + this.setFeedback(ev, "Not Found", STOPPED_COLOR); + return; + } + + const running = containers.filter((container) => container.state === CONTAINER_STATUS_RUNNING).length; + const isRunning = swarm ? running > 0 : running === containers.length && containers.length > 0; + this.setFeedback(ev, stackName, isRunning ? RUNNING_COLOR : STOPPED_COLOR); + } catch (error: any) { + streamDeck.logger.error(`Failed updating stack dial feedback: ${error?.message || error}`); + } finally { + state.updating = false; + } + } + + private setFeedback(ev: any, title: string, statusColor: string): void { + ev.action.setFeedback({ + canvas: { + value: createDockerTile(statusColor, title), + }, + title: "", + }); + } + + private selectStack(state: StackDialState, stackName: string): void { + const index = state.stacks.indexOf(stackName); + state.currentIndex = index >= 0 ? index : 0; + } + + private clampIndex(state: StackDialState): void { + if (state.currentIndex < 0) state.currentIndex = 0; + if (state.currentIndex >= state.stacks.length) state.currentIndex = 0; + } + + private getState(instanceId: string): StackDialState { + let state = this.stateByInstance.get(instanceId); + if (!state) { + state = { stacks: [], currentIndex: 0 }; + this.stateByInstance.set(instanceId, state); + } + return state; + } + + private getInstanceId(ev: any): string { + return (ev.action as any).id || ev.context; + } + + private formatTitle(title: string): string { + return title.split("-").join("\n"); + } +} + +function createDockerTile(background: string, title: string): string { + const lines = formatCanvasTitle(title); + const lineHeight = lines.length > 1 ? 22 : 24; + const firstLineY = lines.length > 1 ? 58 : 66; + const titleNodes = lines + .map( + (line, index) => + `${escapeXml(line)}`, + ) + .join(""); + const svg = ` + + +docker +${titleNodes} +`; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; +} + +function formatCanvasTitle(title: string): string[] { + const normalized = title.replace(/\s+/g, " ").trim() || "No Stack"; + const parts = normalized.split("-").filter(Boolean); + if (parts.length > 1 && parts.every((part) => part.length <= 12)) { + return parts.slice(0, 2); + } + if (normalized.length <= 16) return [normalized]; + return [`${normalized.slice(0, 15)}...`]; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/plugin.ts b/src/plugin.ts index 3848d08..3d68942 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,6 +4,7 @@ import { ContainersCount } from "./actions/containers-count"; import { DockerRunOrRm } from "./actions/docker-run-or-rm"; import { DockerRunningStackStop } from "./actions/docker-running-stack-stop"; import { DockerSelectToggle } from "./actions/docker-select-toggle"; +import { DockerStackSelectStart } from "./actions/docker-stack-select-start"; import { DockerStackStart } from "./actions/docker-stack-start"; import { DockerStart } from "./actions/docker-start"; @@ -17,6 +18,7 @@ streamDeck.actions.registerAction(new DockerStackStart()); streamDeck.actions.registerAction(new DockerRunningStackStop()); streamDeck.actions.registerAction(new ContainersCount()); streamDeck.actions.registerAction(new DockerSelectToggle()); +streamDeck.actions.registerAction(new DockerStackSelectStart()); // Finally, connect to the Stream Deck. streamDeck.connect(); diff --git a/src/utils/stackLifecycle.ts b/src/utils/stackLifecycle.ts index db3d8d2..d91bef3 100644 --- a/src/utils/stackLifecycle.ts +++ b/src/utils/stackLifecycle.ts @@ -13,6 +13,37 @@ import { export type StackToggleResult = "ok" | "not-found"; +export async function startStackLifecycle( + stackName: string, + context: string | undefined, + rememberedSwarmDesired?: Record, +): 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 startSwarmStack(stackName, context, rememberedSwarmDesired); + return "ok"; + } + + 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 toggleStackLifecycle( stackName: string, context: string | undefined, From 9ba0050c2a65872bfba98434d7e64331f4316cb4 Mon Sep 17 00:00:00 2001 From: AiAe Date: Wed, 3 Jun 2026 22:33:05 +0300 Subject: [PATCH 2/5] Remove unused formatTitle --- src/actions/docker-stack-select-start.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/actions/docker-stack-select-start.ts b/src/actions/docker-stack-select-start.ts index 80db313..1d76b0f 100644 --- a/src/actions/docker-stack-select-start.ts +++ b/src/actions/docker-stack-select-start.ts @@ -193,10 +193,6 @@ export class DockerStackSelectStart extends SingletonAction Date: Wed, 3 Jun 2026 22:33:58 +0300 Subject: [PATCH 3/5] Change refresh to 10 sec --- src/actions/docker-stack-select-start.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/docker-stack-select-start.ts b/src/actions/docker-stack-select-start.ts index 1d76b0f..56c3b73 100644 --- a/src/actions/docker-stack-select-start.ts +++ b/src/actions/docker-stack-select-start.ts @@ -112,7 +112,7 @@ export class DockerStackSelectStart extends SingletonAction { await this.updateStackFeedback(ev, context); - }, 1000); + }, 10000); } private async updateStacksList(state: StackDialState, context?: string): Promise { From fcb4504892bda136a12ded2f4fc981206bafedf8 Mon Sep 17 00:00:00 2001 From: AiAe Date: Wed, 3 Jun 2026 22:35:46 +0300 Subject: [PATCH 4/5] Make the dial toggle to start/stop stack --- com.darkdragon14.elgato-docker.sdPlugin/manifest.json | 4 ++-- src/actions/docker-stack-select-start.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/com.darkdragon14.elgato-docker.sdPlugin/manifest.json b/com.darkdragon14.elgato-docker.sdPlugin/manifest.json index b1da41a..e4b3e32 100644 --- a/com.darkdragon14.elgato-docker.sdPlugin/manifest.json +++ b/com.darkdragon14.elgato-docker.sdPlugin/manifest.json @@ -149,8 +149,8 @@ { "Icon": "imgs/actions/docker-running/key", "UUID": "com.darkdragon14.elgato-docker.docker-stack-select-start", - "Name": "Docker Stack Select and Start", - "Tooltip": "Rotate to change stack and click to start it.", + "Name": "Docker Stack Select and Toggle", + "Tooltip": "Rotate to change stack and click to start/stop it.", "Controllers": [ "Encoder" ], diff --git a/src/actions/docker-stack-select-start.ts b/src/actions/docker-stack-select-start.ts index 56c3b73..1ca1e84 100644 --- a/src/actions/docker-stack-select-start.ts +++ b/src/actions/docker-stack-select-start.ts @@ -11,7 +11,7 @@ import { CONTAINER_STATUS_RUNNING, DOCKER_START_ERROR_STATE } from "../constants import { containersByComposeProject, isSwarmStack, listComposeProjects } from "../utils/dockerCli"; import { getEffectiveContext } from "../utils/getEffectiveContext"; import { pingDockerForDials } from "../utils/pingDocker"; -import { startStackLifecycle } from "../utils/stackLifecycle"; +import { toggleStackLifecycle } from "../utils/stackLifecycle"; type DockerStackSelectStartSettings = { contextName?: string; @@ -95,7 +95,9 @@ export class DockerStackSelectStart extends SingletonAction { + state.rememberedSwarmDesired = desired; + }); if (result === "not-found") { this.setFeedback(ev, "Not Found", STOPPED_COLOR); return; From 0e91881f5a7e12f00051c35f958642e1ccf11de5 Mon Sep 17 00:00:00 2001 From: AiAe Date: Wed, 3 Jun 2026 23:28:53 +0300 Subject: [PATCH 5/5] Rename files/functions + remove unused function --- .../manifest.json | 2 +- ...start.ts => docker-stack-select-toggle.ts} | 20 ++++++------ src/plugin.ts | 4 +-- src/utils/stackLifecycle.ts | 31 ------------------- 4 files changed, 13 insertions(+), 44 deletions(-) rename src/actions/{docker-stack-select-start.ts => docker-stack-select-toggle.ts} (94%) diff --git a/com.darkdragon14.elgato-docker.sdPlugin/manifest.json b/com.darkdragon14.elgato-docker.sdPlugin/manifest.json index e4b3e32..ea15f86 100644 --- a/com.darkdragon14.elgato-docker.sdPlugin/manifest.json +++ b/com.darkdragon14.elgato-docker.sdPlugin/manifest.json @@ -148,7 +148,7 @@ }, { "Icon": "imgs/actions/docker-running/key", - "UUID": "com.darkdragon14.elgato-docker.docker-stack-select-start", + "UUID": "com.darkdragon14.elgato-docker.docker-stack-select-toggle", "Name": "Docker Stack Select and Toggle", "Tooltip": "Rotate to change stack and click to start/stop it.", "Controllers": [ diff --git a/src/actions/docker-stack-select-start.ts b/src/actions/docker-stack-select-toggle.ts similarity index 94% rename from src/actions/docker-stack-select-start.ts rename to src/actions/docker-stack-select-toggle.ts index 1ca1e84..ddc09db 100644 --- a/src/actions/docker-stack-select-start.ts +++ b/src/actions/docker-stack-select-toggle.ts @@ -13,7 +13,7 @@ import { getEffectiveContext } from "../utils/getEffectiveContext"; import { pingDockerForDials } from "../utils/pingDocker"; import { toggleStackLifecycle } from "../utils/stackLifecycle"; -type DockerStackSelectStartSettings = { +type DockerStackSelectToggleSettings = { contextName?: string; remoteHost?: string; }; @@ -29,15 +29,15 @@ type StackDialState = { const RUNNING_COLOR = "#3fb950"; const STOPPED_COLOR = "#f85149"; -@action({ UUID: "com.darkdragon14.elgato-docker.docker-stack-select-start" }) -export class DockerStackSelectStart extends SingletonAction { +@action({ UUID: "com.darkdragon14.elgato-docker.docker-stack-select-toggle" }) +export class DockerStackSelectToggle extends SingletonAction { private stateByInstance: Map = new Map(); - override async onWillAppear(ev: WillAppearEvent): Promise { + override async onWillAppear(ev: WillAppearEvent): Promise { if (!ev.action.isDial()) return; const instanceId = this.getInstanceId(ev); - const context = await getEffectiveContext(ev.payload.settings as DockerStackSelectStartSettings); + const context = await getEffectiveContext(ev.payload.settings as DockerStackSelectToggleSettings); const dockerIsUp = await pingDockerForDials(ev, DOCKER_START_ERROR_STATE, context); const state = this.getState(instanceId); @@ -50,16 +50,16 @@ export class DockerStackSelectStart extends SingletonAction): void { + override onWillDisappear(ev: WillDisappearEvent): void { const instanceId = this.getInstanceId(ev); const state = this.stateByInstance.get(instanceId); if (state?.updateInterval) clearInterval(state.updateInterval); this.stateByInstance.delete(instanceId); } - override async onDialRotate(ev: DialRotateEvent): Promise { + override async onDialRotate(ev: DialRotateEvent): Promise { const instanceId = this.getInstanceId(ev); - const context = await getEffectiveContext(ev.payload.settings as DockerStackSelectStartSettings); + const context = await getEffectiveContext(ev.payload.settings as DockerStackSelectToggleSettings); const dockerIsUp = await pingDockerForDials(ev, DOCKER_START_ERROR_STATE, context); if (!dockerIsUp) return; @@ -78,9 +78,9 @@ export class DockerStackSelectStart extends SingletonAction): Promise { + override async onDialDown(ev: DialDownEvent): Promise { const instanceId = this.getInstanceId(ev); - const context = await getEffectiveContext(ev.payload.settings as DockerStackSelectStartSettings); + const context = await getEffectiveContext(ev.payload.settings as DockerStackSelectToggleSettings); const dockerIsUp = await pingDockerForDials(ev, DOCKER_START_ERROR_STATE, context); if (!dockerIsUp) return; diff --git a/src/plugin.ts b/src/plugin.ts index 3d68942..b844eb3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,7 +4,7 @@ import { ContainersCount } from "./actions/containers-count"; import { DockerRunOrRm } from "./actions/docker-run-or-rm"; import { DockerRunningStackStop } from "./actions/docker-running-stack-stop"; import { DockerSelectToggle } from "./actions/docker-select-toggle"; -import { DockerStackSelectStart } from "./actions/docker-stack-select-start"; +import { DockerStackSelectToggle } from "./actions/docker-stack-select-toggle"; import { DockerStackStart } from "./actions/docker-stack-start"; import { DockerStart } from "./actions/docker-start"; @@ -18,7 +18,7 @@ streamDeck.actions.registerAction(new DockerStackStart()); streamDeck.actions.registerAction(new DockerRunningStackStop()); streamDeck.actions.registerAction(new ContainersCount()); streamDeck.actions.registerAction(new DockerSelectToggle()); -streamDeck.actions.registerAction(new DockerStackSelectStart()); +streamDeck.actions.registerAction(new DockerStackSelectToggle()); // Finally, connect to the Stream Deck. streamDeck.connect(); diff --git a/src/utils/stackLifecycle.ts b/src/utils/stackLifecycle.ts index d91bef3..db3d8d2 100644 --- a/src/utils/stackLifecycle.ts +++ b/src/utils/stackLifecycle.ts @@ -13,37 +13,6 @@ import { export type StackToggleResult = "ok" | "not-found"; -export async function startStackLifecycle( - stackName: string, - context: string | undefined, - rememberedSwarmDesired?: Record, -): 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 startSwarmStack(stackName, context, rememberedSwarmDesired); - return "ok"; - } - - 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 toggleStackLifecycle( stackName: string, context: string | undefined,