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..ea15f86 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-toggle", + "Name": "Docker Stack Select and Toggle", + "Tooltip": "Rotate to change stack and click to start/stop 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-toggle.ts b/src/actions/docker-stack-select-toggle.ts new file mode 100644 index 0000000..ddc09db --- /dev/null +++ b/src/actions/docker-stack-select-toggle.ts @@ -0,0 +1,236 @@ +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 { toggleStackLifecycle } from "../utils/stackLifecycle"; + +type DockerStackSelectToggleSettings = { + 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-toggle" }) +export class DockerStackSelectToggle 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 DockerStackSelectToggleSettings); + 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 DockerStackSelectToggleSettings); + 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 DockerStackSelectToggleSettings); + 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 toggleStackLifecycle(stackName, context, state.rememberedSwarmDesired, (desired) => { + state.rememberedSwarmDesired = desired; + }); + 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); + }, 10000); + } + + 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; + } +} + +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..b844eb3 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 { DockerStackSelectToggle } from "./actions/docker-stack-select-toggle"; 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 DockerStackSelectToggle()); // Finally, connect to the Stream Deck. streamDeck.connect();