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/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/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
new file mode 100644
index 0000000..07ef6cb
--- /dev/null
+++ b/src/actions/docker-running-stack-stop.ts
@@ -0,0 +1,337 @@
+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 });
+ }
+ }
+
+ 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