diff --git a/.env.example b/.env.example
index fb8babe..635e977 100644
--- a/.env.example
+++ b/.env.example
@@ -24,6 +24,10 @@ COMMAND_TIMEOUT_SECS=300
STATUS_PANEL_USERNAME=admin
STATUS_PANEL_PASSWORD=admin
+# HTTP bind address. Defaults to 127.0.0.1 (loopback) for bare-metal safety.
+# In containers with port mapping, must be 0.0.0.0 to be reachable from the host.
+STATUS_PANEL_BIND=0.0.0.0
+
# Backup signer / verification
DEPLOYMENT_HASH=replace-with-secret
TRYDIRECT_IP=127.0.0.1
diff --git a/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json b/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json
new file mode 100644
index 0000000..7deb31d
--- /dev/null
+++ b/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json
@@ -0,0 +1,56 @@
+{
+ "version": 1,
+ "selector": {
+ "mode": "remote",
+ "selector_kind": "app",
+ "selector": "status-panel-web",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "container": null
+ },
+ "protocols_requested": [
+ "html_forms"
+ ],
+ "capture_samples": true,
+ "cached_at": "2026-05-22T07:55:49.388248+00:00",
+ "report": {
+ "type": "probe_endpoints",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "app_code": "status-panel-web",
+ "protocols_detected": [
+ "html_forms"
+ ],
+ "protocols_requested": [
+ "html_forms"
+ ],
+ "containers": [],
+ "endpoints": [],
+ "resources": [],
+ "forms": [
+ {
+ "container": "project-status-panel-web-1",
+ "id": "form_contact",
+ "action": "",
+ "method": "POST",
+ "fields": [
+ "name",
+ "email",
+ "subject",
+ "message"
+ ]
+ }
+ ],
+ "probe_attempts": [
+ {
+ "scope": "remote_app",
+ "selector": "status-panel-web",
+ "container": null,
+ "protocols": [
+ "html_forms"
+ ],
+ "outcome": "detected"
+ }
+ ],
+ "target_kind": "html_form",
+ "probed_at": "2026-05-22T07:55:48Z"
+ }
+}
\ No newline at end of file
diff --git a/.stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json b/.stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json
new file mode 100644
index 0000000..f3f9a79
--- /dev/null
+++ b/.stacker/pipe-scan-cache/abbfec54b2192c37e34aa64d9bdd8488b78a69d94f6735b7a874423724673f44.json
@@ -0,0 +1,62 @@
+{
+ "version": 1,
+ "selector": {
+ "mode": "remote",
+ "selector_kind": "app",
+ "selector": "status-panel-web",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "container": null
+ },
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "capture_samples": true,
+ "cached_at": "2026-05-22T07:55:51.452961+00:00",
+ "report": {
+ "type": "probe_endpoints",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "app_code": "status-panel-web",
+ "protocols_detected": [
+ "html_forms"
+ ],
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "containers": [],
+ "endpoints": [],
+ "resources": [],
+ "forms": [
+ {
+ "container": "project-status-panel-web-1",
+ "id": "form_contact",
+ "action": "",
+ "method": "POST",
+ "fields": [
+ "name",
+ "email",
+ "subject",
+ "message"
+ ]
+ }
+ ],
+ "probe_attempts": [
+ {
+ "scope": "remote_app",
+ "selector": "status-panel-web",
+ "container": null,
+ "protocols": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "outcome": "detected"
+ }
+ ],
+ "target_kind": "html_form",
+ "probed_at": "2026-05-22T07:55:50Z"
+ }
+}
\ No newline at end of file
diff --git a/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json b/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json
new file mode 100644
index 0000000..f3f9a79
--- /dev/null
+++ b/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json
@@ -0,0 +1,62 @@
+{
+ "version": 1,
+ "selector": {
+ "mode": "remote",
+ "selector_kind": "app",
+ "selector": "status-panel-web",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "container": null
+ },
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "capture_samples": true,
+ "cached_at": "2026-05-22T07:55:51.452961+00:00",
+ "report": {
+ "type": "probe_endpoints",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "app_code": "status-panel-web",
+ "protocols_detected": [
+ "html_forms"
+ ],
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "containers": [],
+ "endpoints": [],
+ "resources": [],
+ "forms": [
+ {
+ "container": "project-status-panel-web-1",
+ "id": "form_contact",
+ "action": "",
+ "method": "POST",
+ "fields": [
+ "name",
+ "email",
+ "subject",
+ "message"
+ ]
+ }
+ ],
+ "probe_attempts": [
+ {
+ "scope": "remote_app",
+ "selector": "status-panel-web",
+ "container": null,
+ "protocols": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "outcome": "detected"
+ }
+ ],
+ "target_kind": "html_form",
+ "probed_at": "2026-05-22T07:55:50Z"
+ }
+}
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 4aa2128..af0ef84 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3724,7 +3724,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "status-panel"
-version = "0.1.9"
+version = "0.2.0"
dependencies = [
"anyhow",
"assert_cmd",
diff --git a/Cargo.toml b/Cargo.toml
index 51afb29..4845b74 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "status-panel"
-version = "0.1.9"
+version = "0.2.0"
edition = "2021"
[features]
diff --git a/README.md b/README.md
index 201032f..cc7b884 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,10 @@
-# Status Panel
**A lightweight infrastructure agent for server and container management.**
-Monitors health, collects metrics, manages Docker containers, and executes commands — all from a single statically-linked binary.
+Monitors health, collects metrics, manages Docker containers, and executes commands - all from a single statically-linked binary.
[](https://github.com/trydirect/status/actions/workflows/ci.yml)
[](https://hub.docker.com/r/trydirect/status)
@@ -172,7 +171,12 @@ The local `/api/v1/commands/*` endpoints are the agent's own Axum API surface. W
| `POST` | `/api/self/update/deploy` | Deploy prepared binary |
| `POST` | `/api/self/update/rollback` | Restore previous version |
-### Stacker Commands (Remote)
+
+### Integrations
+
+[Stacker](https://stacker.my)
+
+Server Commands (Remote)
The agent accepts signed commands from the Stacker dashboard covering the full lifecycle:
diff --git a/development.md.bak b/development.md.bak
new file mode 100644
index 0000000..76db356
--- /dev/null
+++ b/development.md.bak
@@ -0,0 +1,21 @@
+## Docker buildx quick reference
+
+Use this to publish the same multi-platform image variants that CI builds for the
+`dev` branch:
+
+```bash
+docker buildx build \
+ --platform linux/amd64,linux/arm64 \
+ --build-context stacker=../stacker \
+ -f Dockerfile.prod \
+ -t trydirect/status:unstable \
+ -t trydirect/status:latest \
+ --push \
+ .
+```
+
+This requires a sibling checkout at `../stacker` because `Cargo.toml` includes
+local path dependencies from that repository.
+
+If you only want to validate the multi-platform build locally without pushing,
+replace `--push` with `--output=type=oci,dest=./status-multiarch.tar`.
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 848d6dc..24cd5e3 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -2,14 +2,14 @@ version: '2.2'
services:
statuspanel:
- image: status
+ image: trydirect/status:dev
container_name: statuspanel
ports:
- "5000:5000"
volumes:
- - .:/app
+ #- .:/app
- /var/run/docker.sock:/var/run/docker.sock
- - /data/encrypted:/data/encrypted
+ #- /data/encrypted:/data/encrypted
# Mount docker CLI from host for deploy_app/remove_app commands
- /usr/bin/docker:/usr/bin/docker:ro
- /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
diff --git a/docker-compose.yml b/docker-compose.yml
index 0d8ea9a..e4f6d85 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,6 +17,8 @@ services:
environment:
- NGINX_CONTAINER=nginx
- COMPOSE_AGENT_ENABLED=true
+ # Container port mapping requires binding all interfaces.
+ - STATUS_PANEL_BIND=0.0.0.0
# Default: serve with UI
command: ["serve", "--port", "5000", "--with-ui"]
diff --git a/src/agent/docker.rs b/src/agent/docker.rs
index e430508..ba22542 100644
--- a/src/agent/docker.rs
+++ b/src/agent/docker.rs
@@ -315,7 +315,7 @@ fn calc_network(stats: &ContainerStatsResponse) -> (u64, u64) {
if let Some(networks) = &stats.networks {
let mut rx = 0u64;
let mut tx = 0u64;
- for (_iface, data) in networks.iter() {
+ for data in networks.values() {
rx = rx.saturating_add(data.rx_bytes.unwrap_or(0));
tx = tx.saturating_add(data.tx_bytes.unwrap_or(0));
}
diff --git a/src/security/audit_log.rs b/src/security/audit_log.rs
index 68ca24b..f78c9dd 100644
--- a/src/security/audit_log.rs
+++ b/src/security/audit_log.rs
@@ -46,6 +46,10 @@ impl AuditLogger {
info!(target: "audit", event = "token_rotated", agent_id, request_id = request_id.unwrap_or(""));
}
+ pub fn token_self_issued(&self, deployment_hash: &str, reason: &str) {
+ warn!(target: "audit", event = "token_self_issued", deployment_hash, reason);
+ }
+
pub fn internal_error(
&self,
agent_id: Option<&str>,
diff --git a/src/security/token_provider.rs b/src/security/token_provider.rs
index 61f8bfc..609c991 100644
--- a/src/security/token_provider.rs
+++ b/src/security/token_provider.rs
@@ -108,6 +108,28 @@ impl TokenProvider {
return Ok(true);
}
+ // Strategy 3: self-issue a new token and write it to Vault.
+ // Stacker validates by reading from the same Vault KV path, so a token
+ // the agent writes there is immediately accepted on the next poll.
+ if let Some(vault) = &self.vault_client {
+ let new_token = generate_secure_token();
+ match vault
+ .store_agent_token(&self.deployment_hash, &new_token, None)
+ .await
+ {
+ Ok(()) => {
+ let mut token = self.token.write().await;
+ *token = new_token;
+ super::audit_log::AuditLogger::new()
+ .token_self_issued(&self.deployment_hash, "primary_strategies_failed");
+ return Ok(true);
+ }
+ Err(e) => {
+ warn!(error = %e, "Vault self-issue failed; token unchanged");
+ }
+ }
+ }
+
debug!("No new token available after refresh attempt");
Ok(false)
}
@@ -121,6 +143,14 @@ impl TokenProvider {
}
}
+fn generate_secure_token() -> String {
+ use ring::rand::{SecureRandom, SystemRandom};
+ let rng = SystemRandom::new();
+ let mut bytes = [0u8; 32];
+ rng.fill(&mut bytes).expect("CSPRNG failure");
+ bytes.iter().map(|b| format!("{:02x}", b)).collect()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -190,4 +220,100 @@ mod tests {
tp2.swap("b".into()).await;
assert_eq!(tp.get().await, "b");
}
+
+ #[tokio::test]
+ async fn refresh_self_issues_token_when_vault_has_no_entry() {
+ use crate::security::vault_client::VaultClient;
+ use mockito::Server;
+
+ let _guard = env_lock().lock().unwrap();
+
+ let mut server = Server::new_async().await;
+
+ // Strategy 1: Vault read returns 404 (KV entry was deleted)
+ let read = server
+ .mock("GET", "/v1/status_panel/dep-abc/status_panel_token")
+ .with_status(404)
+ .with_body(r#"{"errors":[]}"#)
+ .expect(1)
+ .create_async()
+ .await;
+
+ // Strategy 3: Vault write accepted (must be called exactly once)
+ let write = server
+ .mock("POST", "/v1/status_panel/dep-abc/status_panel_token")
+ .with_status(200)
+ .with_body("{}")
+ .expect(1)
+ .create_async()
+ .await;
+
+ let _addr = EnvGuard::set("VAULT_ADDRESS", &server.url());
+ let _tok = EnvGuard::set("VAULT_TOKEN", "vault-root");
+ let _prefix = EnvGuard::set("VAULT_AGENT_PATH_PREFIX", "status_panel");
+ // Strategy 2 is a no-op: env token matches current
+ let _agent = EnvGuard::set("AGENT_TOKEN", "stale-token");
+
+ let vault = VaultClient::from_env().unwrap().unwrap();
+ let tp = TokenProvider::new("stale-token".into(), Some(vault), "dep-abc".into());
+
+ let changed = tp.refresh().await.unwrap();
+ assert!(changed, "expected self-issued token to be applied");
+
+ let new_token = tp.get().await;
+ assert_ne!(new_token, "stale-token");
+ assert_eq!(new_token.len(), 64, "expected 32-byte hex token");
+ assert!(new_token.chars().all(|c| c.is_ascii_hexdigit()));
+
+ read.assert_async().await;
+ write.assert_async().await;
+ }
+
+ #[tokio::test]
+ async fn refresh_keeps_token_when_vault_write_forbidden() {
+ use crate::security::vault_client::VaultClient;
+ use mockito::Server;
+
+ let _guard = env_lock().lock().unwrap();
+
+ let mut server = Server::new_async().await;
+
+ // Strategy 1: Vault read returns 404
+ let _read = server
+ .mock("GET", "/v1/status_panel/dep-xyz/status_panel_token")
+ .with_status(404)
+ .with_body(r#"{"errors":[]}"#)
+ .create_async()
+ .await;
+
+ // Strategy 3: Vault write rejected (agent lacks write capability)
+ let write = server
+ .mock("POST", "/v1/status_panel/dep-xyz/status_panel_token")
+ .with_status(403)
+ .with_body(r#"{"errors":["permission denied"]}"#)
+ .expect(1)
+ .create_async()
+ .await;
+
+ let _addr = EnvGuard::set("VAULT_ADDRESS", &server.url());
+ let _tok = EnvGuard::set("VAULT_TOKEN", "vault-root");
+ let _prefix = EnvGuard::set("VAULT_AGENT_PATH_PREFIX", "status_panel");
+ let _agent = EnvGuard::set("AGENT_TOKEN", "stale-token");
+
+ let vault = VaultClient::from_env().unwrap().unwrap();
+ let tp = TokenProvider::new("stale-token".into(), Some(vault), "dep-xyz".into());
+
+ let changed = tp.refresh().await.unwrap();
+ assert!(
+ !changed,
+ "Strategy 3 must not claim success on write failure"
+ );
+ assert_eq!(
+ tp.get().await,
+ "stale-token",
+ "token must not change on failure"
+ );
+
+ write.assert_async().await;
+ }
}
diff --git a/src/security/vault_client.rs b/src/security/vault_client.rs
index 505d4e8..5bdf798 100644
--- a/src/security/vault_client.rs
+++ b/src/security/vault_client.rs
@@ -49,7 +49,7 @@
use anyhow::{Context, Result};
use async_trait::async_trait;
-use reqwest::{Client, StatusCode};
+use reqwest::{Client, Identity, StatusCode};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
@@ -369,13 +369,22 @@ trait VaultTransport: Send + Sync {
#[derive(Debug, Default)]
struct ReqwestVaultTransport;
+impl ReqwestVaultTransport {
+ fn build_client() -> Result {
+ let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10));
+
+ if let Some(identity) = load_mtls_identity() {
+ builder = builder.identity(identity);
+ }
+
+ builder.build().context("creating HTTP client")
+ }
+}
+
#[async_trait]
impl VaultTransport for ReqwestVaultTransport {
async fn get(&self, url: &str, token: &str) -> Result {
- let response = Client::builder()
- .timeout(std::time::Duration::from_secs(10))
- .build()
- .context("creating HTTP client")?
+ let response = Self::build_client()?
.get(url)
.header("X-Vault-Token", token)
.send()
@@ -393,10 +402,7 @@ impl VaultTransport for ReqwestVaultTransport {
token: &str,
payload: &serde_json::Value,
) -> Result {
- let response = Client::builder()
- .timeout(std::time::Duration::from_secs(10))
- .build()
- .context("creating HTTP client")?
+ let response = Self::build_client()?
.post(url)
.header("X-Vault-Token", token)
.json(payload)
@@ -410,6 +416,37 @@ impl VaultTransport for ReqwestVaultTransport {
}
}
+/// Load mTLS client certificate identity from environment variables.
+///
+/// Supports two modes:
+/// 1. Inline PEM via `VAULT_CLIENT_CERT` and `VAULT_CLIENT_KEY` env vars
+/// 2. File paths via `VAULT_CLIENT_CERT_PATH` and `VAULT_CLIENT_KEY_PATH` env vars
+///
+/// Returns `None` if neither set (mTLS is optional — degradation path).
+fn load_mtls_identity() -> Option {
+ let cert_pem = std::env::var("VAULT_CLIENT_CERT").ok().or_else(|| {
+ let path = std::env::var("VAULT_CLIENT_CERT_PATH").ok()?;
+ std::fs::read_to_string(path).ok()
+ })?;
+
+ let key_pem = std::env::var("VAULT_CLIENT_KEY").ok().or_else(|| {
+ let path = std::env::var("VAULT_CLIENT_KEY_PATH").ok()?;
+ std::fs::read_to_string(path).ok()
+ })?;
+
+ let identity_pem = format!("{}\n{}", cert_pem, key_pem);
+ match Identity::from_pem(identity_pem.as_bytes()) {
+ Ok(identity) => {
+ debug!("mTLS client identity loaded for Vault connections");
+ Some(identity)
+ }
+ Err(e) => {
+ warn!("Failed to load mTLS client identity: {}", e);
+ None
+ }
+ }
+}
+
// =============================================================================
// Vault Client Implementation
// =============================================================================
@@ -495,10 +532,14 @@ impl VaultClient {
// Configure HTTP client with security-conscious defaults:
// - 10 second timeout prevents resource exhaustion from hanging connections
// - TLS certificate validation enabled by default (reqwest behavior)
- let http_client = Client::builder()
- .timeout(std::time::Duration::from_secs(10))
- .build()
- .context("creating HTTP client")?;
+ // - mTLS client identity loaded from VAULT_CLIENT_CERT / VAULT_CLIENT_KEY
+ let mut builder = Client::builder().timeout(std::time::Duration::from_secs(10));
+
+ if let Some(identity) = load_mtls_identity() {
+ builder = builder.identity(identity);
+ }
+
+ let http_client = builder.build().context("creating HTTP client")?;
// Note: We log the base_url but NEVER log the token
debug!("Vault client initialized with base_url={}", base);
diff --git a/stacker/stacker/src/cli/stacker_client.rs b/stacker/stacker/src/cli/stacker_client.rs
index 8359f8c..fa6c46e 100644
--- a/stacker/stacker/src/cli/stacker_client.rs
+++ b/stacker/stacker/src/cli/stacker_client.rs
@@ -25,7 +25,7 @@ pub const DEFAULT_STACKER_URL: &str = "https://stacker.try.direct";
/// The Install Service Ansible role uses this to configure the agent's VAULT_ADDRESS
/// environment variable on the remote server. Must be a publicly reachable address
/// (not a Docker-internal IP) so deployed agents can connect to Vault.
-pub const DEFAULT_VAULT_URL: &str = "https://vault.try.direct";
+pub const DEFAULT_VAULT_URL: &str = "https://vault.try.direct:8443";
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Response types (matching Stacker server JSON envelope)
diff --git a/web/.stacker/.gitignore b/web/.stacker/.gitignore
new file mode 100644
index 0000000..9643030
--- /dev/null
+++ b/web/.stacker/.gitignore
@@ -0,0 +1,4 @@
+# Deployment lock files contain environment-specific metadata (server IPs, emails, etc.)
+# and should not be committed to source control
+deployment-*.lock
+deployment-*.lock.back
diff --git a/web/.stacker/Dockerfile b/web/.stacker/Dockerfile
new file mode 100644
index 0000000..57986f9
--- /dev/null
+++ b/web/.stacker/Dockerfile
@@ -0,0 +1,15 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+ENV NEXT_TELEMETRY_DISABLED=1
+
+COPY package*.json ./
+COPY . .
+
+RUN npm ci
+RUN npm run build
+
+EXPOSE 3000
+
+CMD ["npm", "run", "start"]
diff --git a/web/.stacker/active-target b/web/.stacker/active-target
new file mode 100644
index 0000000..ac2564f
--- /dev/null
+++ b/web/.stacker/active-target
@@ -0,0 +1 @@
+cloud
\ No newline at end of file
diff --git a/web/.stacker/deploy/production/config-bundle.manifest.json b/web/.stacker/deploy/production/config-bundle.manifest.json
new file mode 100644
index 0000000..43942e1
--- /dev/null
+++ b/web/.stacker/deploy/production/config-bundle.manifest.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "environment": "production",
+ "files": [
+ {
+ "source_path": ".env",
+ "destination_path": ".env",
+ "mode": "0644",
+ "size": 215,
+ "sha256": "fac5fd4c950c34233bcf7d4ad1750539c2234290846630e4e6edd35029a44bc6"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/web/.stacker/deploy/production/config-bundle.tar.zst b/web/.stacker/deploy/production/config-bundle.tar.zst
new file mode 100644
index 0000000..b4c25b3
Binary files /dev/null and b/web/.stacker/deploy/production/config-bundle.tar.zst differ
diff --git a/web/.stacker/deploy/production/docker-compose.remote.yml b/web/.stacker/deploy/production/docker-compose.remote.yml
new file mode 100644
index 0000000..b80022d
--- /dev/null
+++ b/web/.stacker/deploy/production/docker-compose.remote.yml
@@ -0,0 +1,26 @@
+services:
+ status-panel-web:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: trydirect/status-panel-web:latest
+ container_name: status-panel-web
+ ports:
+ - "3000:3000"
+ env_file:
+ - .env
+ environment:
+ NODE_ENV: production
+ NEXT_PUBLIC_SITE_URL: https://status.stacker.my
+ restart: unless-stopped
+ smtp:
+ image: trydirect/smtp
+ ports:
+ - "1025:1025"
+ - "8025:8025"
+ volumes:
+ - smtp_data:/data
+ restart: unless-stopped
+volumes:
+ smtp_data:
+ name: smtp_data
diff --git a/web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json b/web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json
new file mode 100644
index 0000000..4b5e622
--- /dev/null
+++ b/web/.stacker/pipe-scan-cache/018204ecd7ba2b8104c873d668248bd3ac6156202bebe6a8aa38fa20e656cd35.json
@@ -0,0 +1,56 @@
+{
+ "version": 1,
+ "selector": {
+ "mode": "remote",
+ "selector_kind": "app",
+ "selector": "status-panel-web",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "container": null
+ },
+ "protocols_requested": [
+ "html_forms"
+ ],
+ "capture_samples": true,
+ "cached_at": "2026-05-21T17:52:06.693176+00:00",
+ "report": {
+ "type": "probe_endpoints",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "app_code": "status-panel-web",
+ "protocols_detected": [
+ "html_forms"
+ ],
+ "protocols_requested": [
+ "html_forms"
+ ],
+ "containers": [],
+ "endpoints": [],
+ "resources": [],
+ "forms": [
+ {
+ "container": "project-status-panel-web-1",
+ "id": "form_contact",
+ "action": "",
+ "method": "POST",
+ "fields": [
+ "name",
+ "email",
+ "subject",
+ "message"
+ ]
+ }
+ ],
+ "probe_attempts": [
+ {
+ "scope": "remote_app",
+ "selector": "status-panel-web",
+ "container": null,
+ "protocols": [
+ "html_forms"
+ ],
+ "outcome": "detected"
+ }
+ ],
+ "target_kind": "html_form",
+ "probed_at": "2026-05-21T17:52:06Z"
+ }
+}
\ No newline at end of file
diff --git a/web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json b/web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json
new file mode 100644
index 0000000..dabf80f
--- /dev/null
+++ b/web/.stacker/pipe-scan-cache/93b072a1bd79a89f53adfb683923a455cb462df04e62db8c92bfdd7d53dc53b3.json
@@ -0,0 +1,47 @@
+{
+ "version": 1,
+ "selector": {
+ "mode": "local",
+ "selector_kind": "containers",
+ "selector": "status-panel-web2",
+ "deployment_hash": null,
+ "container": null
+ },
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "capture_samples": true,
+ "cached_at": "2026-05-22T09:21:38.787954+00:00",
+ "report": {
+ "type": "probe_endpoints",
+ "deployment_hash": "local",
+ "app_code": "status-panel-web2",
+ "protocols_detected": [],
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "containers": [],
+ "endpoints": [],
+ "resources": [],
+ "forms": [],
+ "probe_attempts": [
+ {
+ "scope": "local_selector",
+ "selector": "status-panel-web2",
+ "container": null,
+ "protocols": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "outcome": "empty"
+ }
+ ],
+ "target_kind": "unknown",
+ "probed_at": "2026-05-22T09:21:38.787581+00:00"
+ }
+}
\ No newline at end of file
diff --git a/web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json b/web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json
new file mode 100644
index 0000000..34fea0a
--- /dev/null
+++ b/web/.stacker/pipe-scan-cache/fde369cb41907befb08176c9a38443049b23994e2c8a523c95f9a6ceb632840e.json
@@ -0,0 +1,80 @@
+{
+ "version": 1,
+ "selector": {
+ "mode": "local",
+ "selector_kind": "containers",
+ "selector": "status-panel-web",
+ "deployment_hash": null,
+ "container": null
+ },
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "capture_samples": true,
+ "cached_at": "2026-05-22T08:17:44.994249+00:00",
+ "report": {
+ "type": "probe_endpoints",
+ "deployment_hash": "local",
+ "app_code": "status-panel-web",
+ "protocols_detected": [
+ "html_forms"
+ ],
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "containers": [
+ {
+ "name": "status-panel-web",
+ "image": "trydirect/status-panel-web:latest",
+ "network": "web_default",
+ "ports": [
+ "3000->3000/tcp",
+ "3000/tcp"
+ ],
+ "addresses": [
+ "172.20.0.3:3000",
+ "172.20.0.3:3000"
+ ]
+ }
+ ],
+ "endpoints": [],
+ "resources": [],
+ "forms": [
+ {
+ "container": "status-panel-web",
+ "id": "status-panel-web/contact",
+ "action": "/contact",
+ "method": "POST",
+ "fields": [
+ "$ACTION_REF_1",
+ "$ACTION_1:0",
+ "$ACTION_1:1",
+ "$ACTION_KEY",
+ "name",
+ "email",
+ "subject",
+ "message"
+ ]
+ }
+ ],
+ "probe_attempts": [
+ {
+ "scope": "local_container",
+ "selector": "status-panel-web",
+ "container": "status-panel-web",
+ "protocols": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "outcome": "detected"
+ }
+ ],
+ "target_kind": "html_form",
+ "probed_at": "2026-05-22T08:17:44.990008+00:00"
+ }
+}
\ No newline at end of file
diff --git a/web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json b/web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json
new file mode 100644
index 0000000..34fea0a
--- /dev/null
+++ b/web/.stacker/pipe-scan-cache/latest-18c84187ae0a8a25aba883fba3f0136c2ca24a8f4a7b416c9628be021b93b7a3.json
@@ -0,0 +1,80 @@
+{
+ "version": 1,
+ "selector": {
+ "mode": "local",
+ "selector_kind": "containers",
+ "selector": "status-panel-web",
+ "deployment_hash": null,
+ "container": null
+ },
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "capture_samples": true,
+ "cached_at": "2026-05-22T08:17:44.994249+00:00",
+ "report": {
+ "type": "probe_endpoints",
+ "deployment_hash": "local",
+ "app_code": "status-panel-web",
+ "protocols_detected": [
+ "html_forms"
+ ],
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "containers": [
+ {
+ "name": "status-panel-web",
+ "image": "trydirect/status-panel-web:latest",
+ "network": "web_default",
+ "ports": [
+ "3000->3000/tcp",
+ "3000/tcp"
+ ],
+ "addresses": [
+ "172.20.0.3:3000",
+ "172.20.0.3:3000"
+ ]
+ }
+ ],
+ "endpoints": [],
+ "resources": [],
+ "forms": [
+ {
+ "container": "status-panel-web",
+ "id": "status-panel-web/contact",
+ "action": "/contact",
+ "method": "POST",
+ "fields": [
+ "$ACTION_REF_1",
+ "$ACTION_1:0",
+ "$ACTION_1:1",
+ "$ACTION_KEY",
+ "name",
+ "email",
+ "subject",
+ "message"
+ ]
+ }
+ ],
+ "probe_attempts": [
+ {
+ "scope": "local_container",
+ "selector": "status-panel-web",
+ "container": "status-panel-web",
+ "protocols": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "outcome": "detected"
+ }
+ ],
+ "target_kind": "html_form",
+ "probed_at": "2026-05-22T08:17:44.990008+00:00"
+ }
+}
\ No newline at end of file
diff --git a/web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json b/web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json
new file mode 100644
index 0000000..dabf80f
--- /dev/null
+++ b/web/.stacker/pipe-scan-cache/latest-b436c2a84c88a5d6edb9c31ced9c7eb8f3d5704623b89cfb49eb82069e81bd25.json
@@ -0,0 +1,47 @@
+{
+ "version": 1,
+ "selector": {
+ "mode": "local",
+ "selector_kind": "containers",
+ "selector": "status-panel-web2",
+ "deployment_hash": null,
+ "container": null
+ },
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "capture_samples": true,
+ "cached_at": "2026-05-22T09:21:38.787954+00:00",
+ "report": {
+ "type": "probe_endpoints",
+ "deployment_hash": "local",
+ "app_code": "status-panel-web2",
+ "protocols_detected": [],
+ "protocols_requested": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "containers": [],
+ "endpoints": [],
+ "resources": [],
+ "forms": [],
+ "probe_attempts": [
+ {
+ "scope": "local_selector",
+ "selector": "status-panel-web2",
+ "container": null,
+ "protocols": [
+ "html_forms",
+ "openapi",
+ "rest"
+ ],
+ "outcome": "empty"
+ }
+ ],
+ "target_kind": "unknown",
+ "probed_at": "2026-05-22T09:21:38.787581+00:00"
+ }
+}
\ No newline at end of file
diff --git a/web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json b/web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json
new file mode 100644
index 0000000..4b5e622
--- /dev/null
+++ b/web/.stacker/pipe-scan-cache/latest-c6a71384dbc47f68c3c3ef7bce255c99ef733b27339c9375cdbe499e3a0c8b7a.json
@@ -0,0 +1,56 @@
+{
+ "version": 1,
+ "selector": {
+ "mode": "remote",
+ "selector_kind": "app",
+ "selector": "status-panel-web",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "container": null
+ },
+ "protocols_requested": [
+ "html_forms"
+ ],
+ "capture_samples": true,
+ "cached_at": "2026-05-21T17:52:06.693176+00:00",
+ "report": {
+ "type": "probe_endpoints",
+ "deployment_hash": "deployment_a631cf66-a224-440b-9871-12b63548671c",
+ "app_code": "status-panel-web",
+ "protocols_detected": [
+ "html_forms"
+ ],
+ "protocols_requested": [
+ "html_forms"
+ ],
+ "containers": [],
+ "endpoints": [],
+ "resources": [],
+ "forms": [
+ {
+ "container": "project-status-panel-web-1",
+ "id": "form_contact",
+ "action": "",
+ "method": "POST",
+ "fields": [
+ "name",
+ "email",
+ "subject",
+ "message"
+ ]
+ }
+ ],
+ "probe_attempts": [
+ {
+ "scope": "remote_app",
+ "selector": "status-panel-web",
+ "container": null,
+ "protocols": [
+ "html_forms"
+ ],
+ "outcome": "detected"
+ }
+ ],
+ "target_kind": "html_form",
+ "probed_at": "2026-05-21T17:52:06Z"
+ }
+}
\ No newline at end of file
diff --git a/web/.stacker/pipes/status-panel-web-to-smtp-3.json b/web/.stacker/pipes/status-panel-web-to-smtp-3.json
new file mode 100644
index 0000000..5e1212d
--- /dev/null
+++ b/web/.stacker/pipes/status-panel-web-to-smtp-3.json
@@ -0,0 +1,98 @@
+{
+ "schema_version": 1,
+ "id": "status-panel-web-to-smtp-3",
+ "name": "status-panel-web-to-smtp-3",
+ "created_at": "2026-05-22T09:24:50.473437+00:00",
+ "updated_at": "2026-05-23T10:51:20.716349+00:00",
+ "status": "active",
+ "source": {
+ "selector": "status-panel-web",
+ "container": "status-panel-web",
+ "method": "POST",
+ "path": "/contact",
+ "fields": [
+ "$ACTION_REF_1",
+ "$ACTION_1:0",
+ "$ACTION_1:1",
+ "$ACTION_KEY",
+ "name",
+ "email",
+ "subject",
+ "message"
+ ]
+ },
+ "target": {
+ "selector": "smtp",
+ "adapter": {
+ "code": "smtp",
+ "role": "target",
+ "config": {
+ "from": "info@stacker.my",
+ "host": "smtp",
+ "port": 25,
+ "tls": false,
+ "to": [
+ "info@optimum-web.com"
+ ]
+ }
+ },
+ "method": "SEND",
+ "path": "adapter:smtp",
+ "fields": [
+ "from_email",
+ "reply_to_email",
+ "subject",
+ "body_text",
+ "body_html"
+ ]
+ },
+ "template": {
+ "description": "POST /contact → SEND adapter:smtp",
+ "source_app_type": "status-panel-web",
+ "source_endpoint": {
+ "method": "POST",
+ "path": "/contact"
+ },
+ "target_app_type": "smtp",
+ "target_endpoint": {
+ "adapter": "smtp",
+ "display_name": "SMTP target",
+ "mode": "adapter"
+ },
+ "field_mapping": {},
+ "config": {
+ "retry_count": 3
+ },
+ "is_public": false
+ },
+ "instance": {
+ "source_container": "status-panel-web",
+ "target_adapter": {
+ "code": "smtp",
+ "role": "target",
+ "config": {
+ "from": "info@stacker.my",
+ "host": "smtp",
+ "port": 25,
+ "tls": false,
+ "to": [
+ "info@optimum-web.com"
+ ]
+ }
+ },
+ "trigger_count": 1,
+ "error_count": 11,
+ "last_triggered_at": "2026-05-22T10:16:28.090543+00:00"
+ },
+ "promotion": {
+ "last_deployment_hash": "deployment_d6804c40-2ace-4dd5-bbfe-428cce3c50f8",
+ "remote_template_id": "a36e2837-ec76-4d5e-ba27-1f4736d4cc89",
+ "remote_instance_id": "4922167c-7cb7-45c1-9c2b-6207c936d9bc",
+ "promoted_at": "2026-05-23T10:51:20.716349+00:00"
+ },
+ "diagnostics": {
+ "notes": [
+ "source discovery: cached result (protocols: html_forms,openapi,rest, capture_samples: true)"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/web/TODO.md b/web/TODO.md
new file mode 100644
index 0000000..3e4bf7b
--- /dev/null
+++ b/web/TODO.md
@@ -0,0 +1,75 @@
+# TODO
+
+## MCP coverage gaps from the Status website scenario
+
+Use this table to complete MCP parity and then re-check the full onboarding
+scenario end to end.
+
+| Scenario step | MCP coverage |
+|---|---|
+| `npm install`, `npm run build` | No - local workstation command, outside Stacker MCP |
+| `docker build`, `docker run`, local health check | No - local Docker/runtime validation |
+| `stacker login` | No - authentication/bootstrap flow, not an MCP tool |
+| `stacker init` generating `stacker.yml` and `.stacker/Dockerfile` | No direct equivalent |
+| `.env` bootstrap from `.env.example` | Gap - no MCP/local-bootstrap tool |
+| `stacker config setup ai` | Gap - no MCP tool for local AI config setup |
+| Parse/discover compose services | Yes - `discover_stack_services` |
+| Create project/app records | Yes - `create_project`, `create_project_app` |
+| Validate server-side stack config | Partial - `validate_stack_config`, but not the same as local `stacker config validate` |
+| `stacker config fix`, `show`, `inventory`, `diff`, `check`, `promote` | Gap - no MCP parity for local config workflows |
+| Deploy project/app | Yes - `start_deployment`, `initiate_deployment`, `deploy_app`, deployment plan tools |
+| Paused deploy troubleshooting | Yes - `diagnose_deployment` now returns MCP tool sequence, safe AI context rules, and `stacker-cli` recovery commands |
+| Agent status | Yes - `get_agent_status` |
+| Logs/health/containers | Yes - `get_container_logs`, `get_container_health`, `list_containers` |
+| Proxy/NPM setup | Yes - `configure_proxy`, `configure_proxy_agent`, `list_proxies`, `delete_proxy` |
+| Remote service secrets | Yes - `list_remote_secret_targets`, `set_remote_service_secret`, and related remote secret tools |
+| Private registry auth setup | Gap - no MCP/local workflow for deploy registry credentials |
+| Cloud provider firewall commands | Partial/gap - MCP has target/server firewall tools, but not the same cloud-provider firewall list/add flow |
+| `stacker agent install` / managed runtime refresh | Gap - no MCP tool found for agent install/refresh |
+| Pipes | Gap - no MCP tools found for `pipe scan/create/activate/trigger/history` |
+
+## Follow-up MCP work
+
+1. Add a local/bootstrap MCP or companion workflow for `stacker init` parity.
+2. Add MCP parity for local config workflows: inventory, diff, promote, check,
+ and local `stacker config validate`.
+3. Add cloud-provider firewall MCP tools that match `stacker cloud firewall`
+ list/add behavior.
+4. Add an agent install/refresh MCP tool for the Status Panel and managed
+ runtime features.
+5. Add pipe management MCP tools for scan, create, activate, trigger, and
+ history.
+6. Re-run the Status website onboarding story and update this table after each
+ gap is closed.
+
+## Stacker onboarding UX gaps found during the walkthrough
+
+These are places where the walkthrough required manual editing or manual
+diagnosis that Stacker should handle directly.
+
+| Gap | Better Stacker behavior |
+|---|---|
+| Missing `.env` required by `docker-compose.yml` | `stacker init` or `stacker deploy` should detect `env_file: .env`, offer to create `.env` from `.env.example`, and apply safe permissions |
+| `--key` / `--key-id` still entered the cloud-selection path when `deploy.cloud` was missing | CLI cloud overrides should populate cloud config in memory before any prompt or remote lookup |
+| Generated config had nullable structural fields | `stacker init` should emit compact, validation-clean YAML by default |
+| Existing config still has nullable structural fields | `stacker config fix` should remove null structural fields without hand-editing YAML |
+| Private image registry auth required explanation | `stacker deploy` should detect likely private-image pull risk and prompt for registry auth source or show exact env/config options |
+| AI configuration required manual YAML editing | Add `stacker config setup ai` or `stacker ai configure --provider ollama --endpoint ... --model ...` |
+| User-facing API errors exposed raw route/body details | Hide endpoints and raw bodies by default; show details only with `DEBUG=true`, `STACKER_DEBUG=true`, or `RUST_LOG=debug` |
+
+## Completed Stacker fixes during this walkthrough
+
+| Fix | Status |
+|---|---|
+| Compact `stacker init` output for future generated configs | Implemented in Stacker repo by background agent |
+| Debug-gated Stacker API route/body errors | Implemented in Stacker repo by background agent |
+| Missing `.env` referenced by compose/config | Implemented in Stacker repo: deploy copies from `.env.example` with restrictive permissions or returns actionable guidance |
+| `--key` / `--key-id` cloud deploy overrides | Implemented in Stacker repo: resolved through the logged-in Stacker API before prompt selection |
+| Non-interactive cloud selection | Implemented in Stacker repo: skips hanging prompts and tells the user to pass `--key`, `--key-id`, or configure cloud defaults |
+| Existing config with nullable structural fields | Implemented in Stacker repo: validation suggests `stacker config fix`; fix removes empty structural path fields |
+| Private image registry auth guidance | Implemented in Stacker repo: deploy prints concise credential guidance when needed |
+| AI configuration without manual YAML edits | Implemented in Stacker repo: `stacker config setup ai` |
+| Hetzner location/datacenter mismatch during cloud provisioning | Implemented in Stacker repo: deploy preserves the requested size and normalizes Hetzner locations such as `nbg1` before publishing installer payloads |
+| Remote `.env` path mismatch during cloud install | Implemented in Stacker repo: config bundles now keep compose file references project-relative so copied files and Docker Compose paths match |
+| Remote `.env` not materialized by installer | Implemented in Stacker repo: deploy-time config files are mirrored into installer runtime-file metadata before deployment |
+| Paused deployment recovery path was tribal knowledge | Documented in `docs/recover-paused-deployment.md`: inspect status, use backup SSH key, classify failure, apply temporary fixes, ask AI safely, and redeploy |
diff --git a/web/docker-compose.yml.bak b/web/docker-compose.yml.bak
new file mode 100644
index 0000000..26e2a39
--- /dev/null
+++ b/web/docker-compose.yml.bak
@@ -0,0 +1,15 @@
+services:
+ status-panel-web:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: trydirect/status-panel-web:latest
+ container_name: status-panel-web
+ ports:
+ - "3000:3000"
+ env_file:
+ - .env
+ environment:
+ NODE_ENV: production
+ NEXT_PUBLIC_SITE_URL: https://status.stacker.my
+ restart: unless-stopped
diff --git a/web/stacker.yml.bak b/web/stacker.yml.bak
new file mode 100644
index 0000000..49a195f
--- /dev/null
+++ b/web/stacker.yml.bak
@@ -0,0 +1,84 @@
+name: web
+version: 0.1.0
+organization: null
+project:
+ identity: web
+app:
+ type: node
+ path: .
+ dockerfile: null
+ image: null
+ build: null
+ ports: []
+ volumes: []
+ environment: {}
+services:
+- name: status-panel-web
+ image: trydirect/status-panel-web:0.1.0
+ ports:
+ - 3000:3000
+ environment:
+ NEXT_PUBLIC_SITE_URL: https://status.stacker.my
+ NODE_ENV: production
+ volumes: []
+ depends_on: []
+- name: smtp
+ image: trydirect/smtp
+ ports:
+ - 127.0.0.1:1025:25
+ - 8025:8025
+ environment: {}
+ volumes:
+ - smtp_data:/data
+ depends_on: []
+proxy:
+ type: nginx-proxy-manager
+ auto_detect: false
+ domains:
+ - domain: status.stacker.my
+ ssl: auto
+ upstream: status-panel-web:3000
+ config: null
+deploy:
+ target: cloud
+ environment: null
+ compose_file: docker-compose.yml
+ deployment_hash: null
+ cloud:
+ provider: hetzner
+ orchestrator: remote
+ region: nbg1
+ size: cx23
+ install_image: null
+ remote_payload_file: null
+ ssh_key: ~/.ssh/id_rsa
+ key: null
+ server: null
+ server: null
+ registry: null
+ default_target: null
+ targets: {}
+environments: {}
+ai:
+ enabled: true
+ provider: ollama
+ model: qwen2.5-coder
+ api_key: null
+ endpoint: http://192.168.100.245:11434
+ timeout: 0
+ tasks:
+ - compose
+ - troubleshoot
+ - security
+monitoring:
+ status_panel: true
+ healthcheck: null
+ metrics: null
+hooks:
+ pre_build: null
+ post_deploy: null
+ on_failure: null
+env_file: null
+env: {}
+config_contract:
+ services: {}