From 9b65b82ccba58af4691f58ee095298007854bf96 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen Date: Sat, 31 Jan 2026 01:56:17 +1100 Subject: [PATCH 01/84] feat(template): cloudflare-ddns --- public/svgs/cloudflare-ddns.svg | 8 ++++++++ templates/compose/cloudflare-ddns.yaml | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 public/svgs/cloudflare-ddns.svg create mode 100644 templates/compose/cloudflare-ddns.yaml diff --git a/public/svgs/cloudflare-ddns.svg b/public/svgs/cloudflare-ddns.svg new file mode 100644 index 000000000..efe800bcc --- /dev/null +++ b/public/svgs/cloudflare-ddns.svg @@ -0,0 +1,8 @@ + + + + + + DDNS + + diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml new file mode 100644 index 000000000..874f2cffb --- /dev/null +++ b/templates/compose/cloudflare-ddns.yaml @@ -0,0 +1,20 @@ +# documentation: https://github.com/favonia/cloudflare-ddns +# slogan: A small, feature-rich, and robust Cloudflare DDNS updater. +# category: automation +# tags: cloud, ddns +# logo: svgs/cloudflare-ddns.svg + +services: + cloudflare-ddns: + image: favonia/cloudflare-ddns:1 + network_mode: host + restart: unless-stopped + user: "1000:1000" + read_only: true + cap_drop: [all] + security_opt: [no-new-privileges:true] + environment: + - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN} + - DOMAINS=${DOMAINS} + - PROXIED=false + - IP6_PROVIDER=none From 90449d2bb5e7b8370dadb0899f511bb9c5b6c2cc Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:55:44 +1100 Subject: [PATCH 02/84] fix: remove restart: unless-stopped Update templates/compose/cloudflare-ddns.yaml Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> --- templates/compose/cloudflare-ddns.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index 874f2cffb..b44828a70 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -8,7 +8,6 @@ services: cloudflare-ddns: image: favonia/cloudflare-ddns:1 network_mode: host - restart: unless-stopped user: "1000:1000" read_only: true cap_drop: [all] From 96b9cd3fa543c4e78554af27607ab231d7122e60 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:56:09 +1100 Subject: [PATCH 03/84] fix: mark the API token env as required, and other env as configurable from the UI Update templates/compose/cloudflare-ddns.yaml Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> --- templates/compose/cloudflare-ddns.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index b44828a70..92f857c41 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -13,7 +13,7 @@ services: cap_drop: [all] security_opt: [no-new-privileges:true] environment: - - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN} + - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?} - DOMAINS=${DOMAINS} - - PROXIED=false - - IP6_PROVIDER=none + - PROXIED=${PROXIED:-false} + - IP6_PROVIDER=${IP6_PROVIDER:-none} From b65f6399df736777a48498aca17c43f9609a5a1f Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:52:14 +1100 Subject: [PATCH 04/84] fix: make domains env compulsory Update templates/compose/cloudflare-ddns.yaml --- templates/compose/cloudflare-ddns.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index 92f857c41..4f29a98d4 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -14,6 +14,6 @@ services: security_opt: [no-new-privileges:true] environment: - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?} - - DOMAINS=${DOMAINS} + - DOMAINS=${DOMAINS:?} - PROXIED=${PROXIED:-false} - IP6_PROVIDER=${IP6_PROVIDER:-none} From d4e4e446b02c2a0f09bc838e7869ea16138aeffc Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi Date: Wed, 15 Apr 2026 11:30:43 +0300 Subject: [PATCH 05/84] feat: add emqx service template --- public/svgs/emqx-enterprise.svg | 7 +++++++ templates/compose/emqx-enterprise.yaml | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 public/svgs/emqx-enterprise.svg create mode 100644 templates/compose/emqx-enterprise.yaml diff --git a/public/svgs/emqx-enterprise.svg b/public/svgs/emqx-enterprise.svg new file mode 100644 index 000000000..e67e1bffe --- /dev/null +++ b/public/svgs/emqx-enterprise.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml new file mode 100644 index 000000000..1d62ea6f9 --- /dev/null +++ b/templates/compose/emqx-enterprise.yaml @@ -0,0 +1,22 @@ +# documentation: https://www.emqx.io/docs/en/latest/deploy/install-docker.html +# slogan: Open-source MQTT broker for IoT, IIoT, and connected vehicles. +# category: iot +# tags: mqtt,broker,iot,messaging,emqx,iiot +# logo: svgs/emqx-enterprise.svg +# port: 18083 + +services: + emqx: + image: emqx/emqx-enterprise:6.2.0 + environment: + - SERVICE_URL_EMQX_18083 + - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} + volumes: + - emqx_data:/opt/emqx/data + - emqx_log:/opt/emqx/log + healthcheck: + test: ["CMD", "/opt/emqx/bin/emqx", "ctl", "status"] + interval: 10s + timeout: 30s + retries: 5 + start_period: 30s From be6acd6f24983d47d3da0d7e0f520a98320e2da7 Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:11:03 +0300 Subject: [PATCH 06/84] Changed the category of emqx to Networking --- templates/compose/emqx-enterprise.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index 1d62ea6f9..0f62c1983 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -1,6 +1,6 @@ # documentation: https://www.emqx.io/docs/en/latest/deploy/install-docker.html # slogan: Open-source MQTT broker for IoT, IIoT, and connected vehicles. -# category: iot +# category: Networking # tags: mqtt,broker,iot,messaging,emqx,iiot # logo: svgs/emqx-enterprise.svg # port: 18083 From 950c4e5936c9e5cd179bc913047940bf0825ceaa Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:17:08 +0300 Subject: [PATCH 07/84] Update EMQX image to use 'latest' tag --- templates/compose/emqx-enterprise.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index 0f62c1983..e68c894bf 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -7,7 +7,7 @@ services: emqx: - image: emqx/emqx-enterprise:6.2.0 + image: emqx/emqx-enterprise:latest environment: - SERVICE_URL_EMQX_18083 - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} From 9208ed102206a67b06ea3dfa978b996106c6500d Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:25:16 +0300 Subject: [PATCH 08/84] Update EMQX image version to 6.2.0 --- templates/compose/emqx-enterprise.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index e68c894bf..0f62c1983 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -7,7 +7,7 @@ services: emqx: - image: emqx/emqx-enterprise:latest + image: emqx/emqx-enterprise:6.2.0 environment: - SERVICE_URL_EMQX_18083 - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} From 71771c7d3a2265254e92568955596d0c59016e0d Mon Sep 17 00:00:00 2001 From: Mohmmad Qunibi <82610649+MohmmadQunibi@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:25:48 +0300 Subject: [PATCH 09/84] Add ports configuration for EMQX Enterprise --- templates/compose/emqx-enterprise.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/compose/emqx-enterprise.yaml b/templates/compose/emqx-enterprise.yaml index 0f62c1983..01d063b6f 100644 --- a/templates/compose/emqx-enterprise.yaml +++ b/templates/compose/emqx-enterprise.yaml @@ -11,6 +11,11 @@ services: environment: - SERVICE_URL_EMQX_18083 - EMQX_DASHBOARD__DEFAULT_PASSWORD=${SERVICE_PASSWORD_EMQX} + ports: + - "1883:1883" + - "8083:8083" + - "8084:8084" + - "8883:8883" volumes: - emqx_data:/opt/emqx/data - emqx_log:/opt/emqx/log From 655e9f4685a4cf487a0212b26d63f46ccff0737f Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 08:34:57 +0200 Subject: [PATCH 10/84] Update ryot.yaml --- templates/compose/ryot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/ryot.yaml b/templates/compose/ryot.yaml index 56190cd5e..41a9ccf0a 100644 --- a/templates/compose/ryot.yaml +++ b/templates/compose/ryot.yaml @@ -7,7 +7,7 @@ services: ryot: - image: ignisda/ryot:v8 + image: ignisda/ryot:v10.3.0 environment: - SERVICE_URL_RYOT_8000 - DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@postgresql:5432/${POSTGRES_DB} From ea6c63edcf2594181f217216435561ee4c53b8f3 Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 08:58:05 +0200 Subject: [PATCH 11/84] Update jellyfin.yaml --- templates/compose/jellyfin.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/jellyfin.yaml b/templates/compose/jellyfin.yaml index d03b66571..59e36821d 100644 --- a/templates/compose/jellyfin.yaml +++ b/templates/compose/jellyfin.yaml @@ -7,7 +7,7 @@ services: jellyfin: - image: lscr.io/linuxserver/jellyfin:latest + image: lscr.io/linuxserver/jellyfin:10.11.8 environment: - SERVICE_URL_JELLYFIN_8096 - PUID=1000 From b678b5852473d2b55a7c7e5882f32ce696d4c907 Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 09:09:36 +0200 Subject: [PATCH 12/84] Update audiobookshelf.yaml --- templates/compose/audiobookshelf.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/audiobookshelf.yaml b/templates/compose/audiobookshelf.yaml index d958f56ff..4bb54710d 100644 --- a/templates/compose/audiobookshelf.yaml +++ b/templates/compose/audiobookshelf.yaml @@ -7,7 +7,7 @@ services: audiobookshelf: - image: ghcr.io/advplyr/audiobookshelf:latest + image: ghcr.io/advplyr/audiobookshelf:2.34.0 environment: - SERVICE_URL_AUDIOBOOKSHELF_80 - TZ=${TIMEZONE:-America/Toronto} From 5267b0ad82152746d62be0a25734065a9e196fef Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 09:10:44 +0200 Subject: [PATCH 13/84] Update grocy.yaml --- templates/compose/grocy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/grocy.yaml b/templates/compose/grocy.yaml index 8a014ce70..a78373fdf 100644 --- a/templates/compose/grocy.yaml +++ b/templates/compose/grocy.yaml @@ -6,7 +6,7 @@ services: grocy: - image: lscr.io/linuxserver/grocy:latest + image: lscr.io/linuxserver/grocy:4.6.0 environment: - SERVICE_URL_GROCY - PUID=1000 From dd19d81e491b60b7e8e51d40a82c6b4ebcd1588f Mon Sep 17 00:00:00 2001 From: "Mr. Kiter" <107297392+kiterwork@users.noreply.github.com> Date: Tue, 12 May 2026 09:22:15 +0200 Subject: [PATCH 14/84] Update mealie.yaml --- templates/compose/mealie.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/mealie.yaml b/templates/compose/mealie.yaml index 7f1121613..a2034bce5 100644 --- a/templates/compose/mealie.yaml +++ b/templates/compose/mealie.yaml @@ -7,7 +7,7 @@ services: mealie: - image: 'ghcr.io/mealie-recipes/mealie:latest' + image: 'ghcr.io/mealie-recipes/mealie:3.17.0' environment: - SERVICE_URL_MEALIE_9000 - ALLOW_SIGNUP=${ALLOW_SIGNUP:-true} From 8c0ecedda426616681112686006a339212d598fe Mon Sep 17 00:00:00 2001 From: toanalien Date: Sat, 16 May 2026 08:40:10 +0200 Subject: [PATCH 15/84] feat(templates): add Hermes Agent + WebUI one-click service Two-container template: hermes-agent gateway plus the hermes-webui chat UI. The WebUI is public-facing (gets the generated FQDN and password via Coolify magic vars); the agent stays internal, sharing named volumes. Hermes uses embedded SQLite, so no external database is needed. --- templates/compose/hermes-agent.yaml | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 templates/compose/hermes-agent.yaml diff --git a/templates/compose/hermes-agent.yaml b/templates/compose/hermes-agent.yaml new file mode 100644 index 000000000..d0a7ec6d3 --- /dev/null +++ b/templates/compose/hermes-agent.yaml @@ -0,0 +1,56 @@ +# documentation: https://github.com/nesquena/hermes-webui +# slogan: Hermes Agent — autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI. +# category: ai +# tags: ai, agent, llm, chatbot, hermes, openrouter, anthropic, openai +# logo: svgs/default.webp +# port: 8787 + +services: + hermes-agent: + image: nousresearch/hermes-agent:latest + command: gateway run + environment: + - HERMES_HOME=/home/hermes/.hermes + - HERMES_UID=1000 + - HERMES_GID=1000 + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - GOOGLE_API_KEY=${GOOGLE_API_KEY} + volumes: + - hermes-home:/home/hermes/.hermes + - hermes-agent-src:/opt/hermes + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "test -d /home/hermes/.hermes || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + + hermes-webui: + image: ghcr.io/nesquena/hermes-webui:latest + depends_on: + - hermes-agent + environment: + - SERVICE_FQDN_HERMESWEBUI_8787 + - HERMES_WEBUI_HOST=0.0.0.0 + - HERMES_WEBUI_PORT=8787 + - HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui + - WANTED_UID=1000 + - WANTED_GID=1000 + - HERMES_WEBUI_PASSWORD=${SERVICE_PASSWORD_HERMESWEBUI} + volumes: + - hermes-home:/home/hermeswebui/.hermes + - hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent + - hermes-workspace:/workspace + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:8787/health"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + hermes-home: + hermes-agent-src: + hermes-workspace: From 7dd6d2b13cfa39ffd21d87c635cb0c45cb3daaff Mon Sep 17 00:00:00 2001 From: Tam Nguyen Date: Mon, 18 May 2026 14:52:15 +1000 Subject: [PATCH 16/84] deps: bump cloudflare-ddns to v2.1.2 --- templates/compose/cloudflare-ddns.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index 4f29a98d4..e66c33a93 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -6,7 +6,7 @@ services: cloudflare-ddns: - image: favonia/cloudflare-ddns:1 + image: favonia/cloudflare-ddns:2.1.2 network_mode: host user: "1000:1000" read_only: true From bce0c51d3709449404c1c61b341a582209f0449c Mon Sep 17 00:00:00 2001 From: Tam Nguyen Date: Mon, 18 May 2026 15:42:31 +1000 Subject: [PATCH 17/84] fix: cloudflare-ddns 1.16.2 --- templates/compose/cloudflare-ddns.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/cloudflare-ddns.yaml b/templates/compose/cloudflare-ddns.yaml index e66c33a93..d7f15effb 100644 --- a/templates/compose/cloudflare-ddns.yaml +++ b/templates/compose/cloudflare-ddns.yaml @@ -6,7 +6,7 @@ services: cloudflare-ddns: - image: favonia/cloudflare-ddns:2.1.2 + image: favonia/cloudflare-ddns:1.16.2 network_mode: host user: "1000:1000" read_only: true From 978d46739d1138833de7071b3882269a4541694b Mon Sep 17 00:00:00 2001 From: Alexandru Furculita Date: Tue, 19 May 2026 10:15:33 +0300 Subject: [PATCH 18/84] feat(service): add openobserve template Adds OpenObserve as a one-click service template. OpenObserve is a cloud-native observability platform for logs, metrics, traces, RUM and session replays, positioned as a self-hosted alternative to Elasticsearch, Splunk and Datadog. - Uses the official open-source image (public.ecr.aws/zinclabs/openobserve) - Wires admin password through Coolify's SERVICE_PASSWORD_* magic env - Persists /data via a named volume - Exposes port 5080 via SERVICE_URL_OPENOBSERVE_5080 - Opts out of telemetry by default (overridable via ZO_TELEMETRY) - Adds /healthz healthcheck and the OpenObserve logo Supersedes #6328, addressing the prior review feedback (drop the deprecated version key, drop hardcoded container_name and restart policy, switch to the magic password env, and use a named volume). --- public/svgs/openobserve.svg | 39 ++++++++++++++++++++++++++++++ templates/compose/openobserve.yaml | 25 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 public/svgs/openobserve.svg create mode 100644 templates/compose/openobserve.yaml diff --git a/public/svgs/openobserve.svg b/public/svgs/openobserve.svg new file mode 100644 index 000000000..c687d948b --- /dev/null +++ b/public/svgs/openobserve.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/compose/openobserve.yaml b/templates/compose/openobserve.yaml new file mode 100644 index 000000000..93239aa19 --- /dev/null +++ b/templates/compose/openobserve.yaml @@ -0,0 +1,25 @@ +# documentation: https://openobserve.ai/docs/ +# slogan: Cloud-native observability platform for logs, metrics, traces, RUM, errors and session replays — a 140x cheaper alternative to Elasticsearch, Splunk and Datadog. +# category: monitoring +# tags: logs, metrics, traces, observability, monitoring, opentelemetry, otel, elasticsearch, splunk, datadog +# logo: svgs/openobserve.svg +# port: 5080 + +services: + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + environment: + - SERVICE_URL_OPENOBSERVE_5080 + - ZO_DATA_DIR=/data + - ZO_ROOT_USER_EMAIL=${ZO_ROOT_USER_EMAIL:-root@example.com} + - ZO_ROOT_USER_PASSWORD=${SERVICE_PASSWORD_OPENOBSERVE} + - ZO_TELEMETRY=${ZO_TELEMETRY:-false} + - ZO_COOKIE_SECURE_ONLY=${ZO_COOKIE_SECURE_ONLY:-true} + volumes: + - openobserve-data:/data + healthcheck: + test: ["CMD", "/openobserve", "node", "status"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 60s From e7853656c303710ab5366687e503ae22c228ca76 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 19 May 2026 16:40:18 +0530 Subject: [PATCH 19/84] fix(service): pin image to static version for open observe --- templates/compose/openobserve.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/openobserve.yaml b/templates/compose/openobserve.yaml index 93239aa19..295491a20 100644 --- a/templates/compose/openobserve.yaml +++ b/templates/compose/openobserve.yaml @@ -7,7 +7,7 @@ services: openobserve: - image: public.ecr.aws/zinclabs/openobserve:latest + image: public.ecr.aws/zinclabs/openobserve:v0.90.0 environment: - SERVICE_URL_OPENOBSERVE_5080 - ZO_DATA_DIR=/data From b64968d50359c38346b90a17fc136858a34086c7 Mon Sep 17 00:00:00 2001 From: toanalien Date: Tue, 19 May 2026 18:55:11 +0700 Subject: [PATCH 20/84] fix(templates): pin image versions and fix magic variable for hermes-agent Address PR review: pin Docker images to v0.14.0 and v0.51.92, change SERVICE_FQDN to SERVICE_URL (generator auto-converts). --- templates/compose/hermes-agent.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/compose/hermes-agent.yaml b/templates/compose/hermes-agent.yaml index d0a7ec6d3..6522e05d5 100644 --- a/templates/compose/hermes-agent.yaml +++ b/templates/compose/hermes-agent.yaml @@ -7,7 +7,7 @@ services: hermes-agent: - image: nousresearch/hermes-agent:latest + image: nousresearch/hermes-agent:v0.14.0 command: gateway run environment: - HERMES_HOME=/home/hermes/.hermes @@ -28,11 +28,11 @@ services: retries: 5 hermes-webui: - image: ghcr.io/nesquena/hermes-webui:latest + image: ghcr.io/nesquena/hermes-webui:v0.51.92 depends_on: - hermes-agent environment: - - SERVICE_FQDN_HERMESWEBUI_8787 + - SERVICE_URL_HERMESWEBUI_8787 - HERMES_WEBUI_HOST=0.0.0.0 - HERMES_WEBUI_PORT=8787 - HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui From 70c187ea40536a3656c3b80738274698299db0a6 Mon Sep 17 00:00:00 2001 From: toanalien Date: Tue, 19 May 2026 19:00:41 +0700 Subject: [PATCH 21/84] fix(templates): add hermes-agent logo and mount agent-src read-only Add official Hermes Agent logo (256x256 PNG from upstream repo). Mount hermes-agent-src volume as read-only in webui container per upstream recommendation (since v0.51.84). --- public/svgs/hermes-agent.png | Bin 0 -> 39138 bytes templates/compose/hermes-agent.yaml | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 public/svgs/hermes-agent.png diff --git a/public/svgs/hermes-agent.png b/public/svgs/hermes-agent.png new file mode 100644 index 0000000000000000000000000000000000000000..0d4a8e82ac5693f10fe763ae398aee596ab8009f GIT binary patch literal 39138 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 xu9 Date: Tue, 19 May 2026 19:23:53 +0200 Subject: [PATCH 22/84] chore(gitea-runner): bumped patch version fix: reverted quote autoformat --- templates/compose/gitea-runner.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/gitea-runner.yaml b/templates/compose/gitea-runner.yaml index 81cce4492..42bb21984 100644 --- a/templates/compose/gitea-runner.yaml +++ b/templates/compose/gitea-runner.yaml @@ -6,7 +6,7 @@ services: runner: - image: 'docker.io/gitea/runner:1.0.0' + image: 'docker.io/gitea/runner:1.0.4' environment: - 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}' - 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}' From 597a2d806f8c21934d48fb7766ba2e59cda16054 Mon Sep 17 00:00:00 2001 From: toanalien Date: Wed, 20 May 2026 01:05:14 +0700 Subject: [PATCH 23/84] fix(templates): correct image tags for hermes-agent and hermes-webui Pin hermes-agent to sha-273ff5c (no semver tags on Docker Hub). Fix hermes-webui tag from v0.51.92 to 0.51.92 (GHCR has no v prefix). --- templates/compose/hermes-agent.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/compose/hermes-agent.yaml b/templates/compose/hermes-agent.yaml index 848a476e2..9f7bceda4 100644 --- a/templates/compose/hermes-agent.yaml +++ b/templates/compose/hermes-agent.yaml @@ -7,7 +7,7 @@ services: hermes-agent: - image: nousresearch/hermes-agent:v0.14.0 + image: nousresearch/hermes-agent:sha-273ff5c4a47af4499bbe5e3b1139efd313995554 command: gateway run environment: - HERMES_HOME=/home/hermes/.hermes @@ -28,7 +28,7 @@ services: retries: 5 hermes-webui: - image: ghcr.io/nesquena/hermes-webui:v0.51.92 + image: ghcr.io/nesquena/hermes-webui:0.51.92 depends_on: - hermes-agent environment: From 9264f391cb771c0e349a34edec0820b511a81a42 Mon Sep 17 00:00:00 2001 From: toanalien Date: Wed, 20 May 2026 12:04:26 +0700 Subject: [PATCH 24/84] fix(templates): address review feedback for hermes-agent template - Remove top-level volumes block (Coolify auto-generates it) - Remove redundant restart: unless-stopped (Coolify default) - Rename hermes-agent.yaml to hermes-agent-with-webui.yaml --- .../{hermes-agent.yaml => hermes-agent-with-webui.yaml} | 7 ------- 1 file changed, 7 deletions(-) rename templates/compose/{hermes-agent.yaml => hermes-agent-with-webui.yaml} (93%) diff --git a/templates/compose/hermes-agent.yaml b/templates/compose/hermes-agent-with-webui.yaml similarity index 93% rename from templates/compose/hermes-agent.yaml rename to templates/compose/hermes-agent-with-webui.yaml index 9f7bceda4..2d30396d8 100644 --- a/templates/compose/hermes-agent.yaml +++ b/templates/compose/hermes-agent-with-webui.yaml @@ -20,7 +20,6 @@ services: volumes: - hermes-home:/home/hermes/.hermes - hermes-agent-src:/opt/hermes - restart: unless-stopped healthcheck: test: ["CMD-SHELL", "test -d /home/hermes/.hermes || exit 1"] interval: 10s @@ -43,14 +42,8 @@ services: - hermes-home:/home/hermeswebui/.hermes - hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro - hermes-workspace:/workspace - restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:8787/health"] interval: 30s timeout: 5s retries: 3 - -volumes: - hermes-home: - hermes-agent-src: - hermes-workspace: From b9f773c1d9d37eabee8778b04bbd5f2984ef7e3b Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Wed, 20 May 2026 19:04:43 +0000 Subject: [PATCH 25/84] fix(livewire): stop broadcast handlers from wiping in-progress form input --- .../Project/Application/Configuration.php | 17 +- .../Project/Database/Clickhouse/General.php | 13 ++ .../Project/Database/Configuration.php | 16 +- .../Project/Database/Dragonfly/General.php | 15 +- app/Livewire/Project/Database/Import.php | 54 +++--- .../Project/Database/Keydb/General.php | 15 +- .../Project/Database/Mariadb/General.php | 15 +- .../Project/Database/Mongodb/General.php | 15 +- .../Project/Database/Mysql/General.php | 15 +- .../Project/Database/Postgresql/General.php | 15 +- .../Project/Database/Redis/General.php | 99 +---------- .../Project/Database/Redis/StatusInfo.php | 116 +++++++++++++ .../Project/Service/Configuration.php | 29 +--- app/Livewire/Server/Sentinel.php | 4 +- app/Livewire/Server/Show.php | 15 +- .../project/database/redis/general.blade.php | 50 +----- .../database/redis/status-info.blade.php | 51 ++++++ .../Feature/DatabaseSslStatusRefreshTest.php | 163 ++++++++++++++++-- 18 files changed, 470 insertions(+), 247 deletions(-) create mode 100644 app/Livewire/Project/Database/Redis/StatusInfo.php create mode 100644 resources/views/livewire/project/database/redis/status-info.blade.php diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index cc1bf15b9..887fff35a 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -17,17 +17,10 @@ class Configuration extends Component public $servers; - public function getListeners() - { - $teamId = auth()->user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh', - 'buildPackUpdated' => '$refresh', - 'refresh' => '$refresh', - ]; - } + protected $listeners = [ + 'buildPackUpdated' => '$refresh', + 'refresh' => '$refresh', + ]; public function mount() { @@ -51,8 +44,6 @@ class Configuration extends Component $this->environment = $environment; $this->application = $application; - - if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 2583c10ea..edcb31f5e 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -48,13 +48,26 @@ class General extends Component public function getListeners() { + $userId = Auth::id(); $teamId = Auth::user()->currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + } + public function mount() { try { diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php index 7c64a6eef..9f952ff2b 100644 --- a/app/Livewire/Project/Database/Configuration.php +++ b/app/Livewire/Project/Database/Configuration.php @@ -2,8 +2,9 @@ namespace App\Livewire\Project\Database; -use Auth; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\ItemNotFoundException; use Livewire\Component; class Configuration extends Component @@ -18,15 +19,6 @@ class Configuration extends Component public $environment; - public function getListeners() - { - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - ]; - } - public function mount() { try { @@ -55,10 +47,10 @@ class Configuration extends Component $this->dispatch('configurationChanged'); } } catch (\Throwable $e) { - if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($e instanceof AuthorizationException) { return redirect()->route('dashboard'); } - if ($e instanceof \Illuminate\Support\ItemNotFoundException) { + if ($e instanceof ItemNotFoundException) { return redirect()->route('dashboard'); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 9e1ea0d10..ae8ec9476 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -57,11 +57,22 @@ class General extends Component return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + public function mount() { try { diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 1cdc681cd..0c19709a5 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -5,9 +5,17 @@ namespace App\Livewire\Project\Database; use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; +use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -192,15 +200,9 @@ class Import extends Component return Server::ownedByCurrentTeam()->find($this->serverId); } - public function getListeners() - { - $userId = Auth::id(); - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'slideOverClosed' => 'resetActivityId', - ]; - } + protected $listeners = [ + 'slideOverClosed' => 'resetActivityId', + ]; public function resetActivityId() { @@ -219,7 +221,7 @@ class Import extends Component $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -231,7 +233,7 @@ class Import extends Component } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': if ($value === true) { $this->mariadbRestoreCommand = <<<'EOD' @@ -247,7 +249,7 @@ EOD; $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': if ($value === true) { $this->mysqlRestoreCommand = <<<'EOD' @@ -263,7 +265,7 @@ EOD; $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': if ($value === true) { $this->postgresqlRestoreCommand = <<<'EOD' @@ -321,7 +323,7 @@ EOD; $this->resourceStatus = $resource->status ?? ''; // Handle ServiceDatabase server access differently - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $server = $resource->service?->server; if (! $server) { abort(404, 'Server not found for this service database.'); @@ -359,16 +361,16 @@ EOD; } if ( - $resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $resource->getMorphClass() === StandaloneRedis::class || + $resource->getMorphClass() === StandaloneKeydb::class || + $resource->getMorphClass() === StandaloneDragonfly::class || + $resource->getMorphClass() === StandaloneClickhouse::class ) { $this->unsupported = true; } // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $dbType = $resource->databaseType(); if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { @@ -664,7 +666,7 @@ EOD; $fullImageName = "{$helperImage}:{$latestVersion}"; // Get the database destination network - if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->resource->getMorphClass() === ServiceDatabase::class) { $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; } else { $destinationNetwork = $this->resource->destination->network ?? 'coolify'; @@ -756,7 +758,7 @@ EOD; $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -770,7 +772,7 @@ EOD; } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': $restoreCommand = $this->mariadbRestoreCommand; if ($this->dumpAll) { @@ -779,7 +781,7 @@ EOD; $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': $restoreCommand = $this->mysqlRestoreCommand; if ($this->dumpAll) { @@ -788,7 +790,7 @@ EOD; $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { @@ -797,7 +799,7 @@ EOD; $restoreCommand .= " {$tmpPath}"; } break; - case \App\Models\StandaloneMongodb::class: + case StandaloneMongodb::class: case 'mongodb': $restoreCommand = $this->mongodbRestoreCommand; if ($this->dumpAll === false) { diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 7c8808499..0511c9d04 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -59,11 +59,22 @@ class General extends Component return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + public function mount() { try { diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index ea6d902e7..edd02eb95 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -64,11 +64,22 @@ class General extends Component $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 3af4b0b2a..1b5a62d2f 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -64,11 +64,22 @@ class General extends Component $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 34726bd0a..6e1e55b3f 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -66,11 +66,22 @@ class General extends Component $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index b5fb85483..1b36ac28a 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -74,13 +74,24 @@ class General extends Component $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', 'save_init_script', 'delete_init_script', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index c3cc43972..aff7b7afa 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Redis; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -48,25 +45,9 @@ class General extends Component public string $redisVersion; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - - public bool $enableSsl = false; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'envsUpdated' => 'refresh', - ]; - } + protected $listeners = [ + 'envsUpdated' => 'refresh', + ]; protected function rules(): array { @@ -87,7 +68,6 @@ class General extends Component 'redisPassword' => ValidationPatterns::databasePasswordRules( enforcePattern: $this->redisPassword !== $this->database->redis_password, ), - 'enableSsl' => 'boolean', ]; } @@ -122,7 +102,6 @@ class General extends Component 'customDockerRunOptions' => 'Custom Docker Options', 'redisUsername' => 'Redis Username', 'redisPassword' => 'Redis Password', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -136,12 +115,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -161,11 +134,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -177,9 +146,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; $this->redisVersion = $this->database->getRedisVersion(); $this->redisUsername = $this->database->redis_username; $this->redisPassword = $this->database->redis_password; @@ -227,6 +193,7 @@ class General extends Component ); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -259,6 +226,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -267,63 +235,6 @@ class General extends Component } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php new file mode 100644 index 000000000..183ed936f --- /dev/null +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -0,0 +1,116 @@ +currentTeam()->id; + + return [ + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + 'databaseUpdated' => 'refresh', + ]; + } + + public function mount(): void + { + $this->refresh(); + } + + public function refresh(): void + { + $this->database->refresh(); + $this->enableSsl = (bool) $this->database->enable_ssl; + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + + public function instantSaveSSL(): void + { + try { + $this->authorize('update', $this->database); + $this->database->enable_ssl = $this->enableSsl; + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function regenerateSslCertificate(): void + { + try { + $this->authorize('update', $this->database); + + $existingCert = $this->database->sslCertificates()->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $server = $this->database->destination->server; + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + + if (! $caCert) { + $server->generateCaCertificate(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + mountPath: $existingCert->mount_path, + isPemKeyFileRequired: true, + ); + + $this->refresh(); + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.database.redis.status-info'); + } +} diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 2d69ceb12..ac2b39bb8 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -4,7 +4,6 @@ namespace App\Livewire\Project\Service; use App\Models\Service; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class Configuration extends Component @@ -27,16 +26,10 @@ class Configuration extends Component public array $parameters; - public function getListeners() - { - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked', - 'refreshServices' => 'refreshServices', - 'refresh' => 'refreshServices', - ]; - } + protected $listeners = [ + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', + ]; public function render() { @@ -105,18 +98,4 @@ class Configuration extends Component return handleError($e, $this); } } - - public function serviceChecked() - { - try { - $this->service->applications->each(function ($application) { - $application->refresh(); - }); - $this->service->databases->each(function ($database) { - $database->refresh(); - }); - } catch (\Exception $e) { - return handleError($e, $this); - } - } } diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a4b35891b..06aebd8f8 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -93,7 +93,9 @@ class Sentinel extends Component { if ($event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 3e05d9306..d7339dcdb 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -277,7 +277,9 @@ class Show extends Component // Only refresh if the event is for this server if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } @@ -457,12 +459,15 @@ class Show extends Component return; } - // Refresh server data + // Refresh server data and only the display-only state that validation produces. + // Never re-sync text-input properties via syncData() — would clobber any + // unsaved typing (see coolify#6062 / #6354 / #9695). $this->server->refresh(); - $this->syncData(); - - // Update validation state + $this->server->settings->refresh(); $this->isValidating = $this->server->is_validating ?? false; + $this->validationLogs = $this->server->validation_logs; + $this->isReachable = $this->server->settings->is_reachable; + $this->isUsable = $this->server->settings->is_usable; // Reload Hetzner tokens in case the linking section should now be shown $this->loadHetznerTokens(); diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 73ee5f0e5..b72b05ff6 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -60,56 +60,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" /> - - @if ($dbUrlPublic) - - @endif - -
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/redis/status-info.blade.php b/resources/views/livewire/project/database/redis/status-info.blade.php new file mode 100644 index 000000000..9f329504c --- /dev/null +++ b/resources/views/livewire/project/database/redis/status-info.blade.php @@ -0,0 +1,51 @@ +
+ + @if ($dbUrlPublic) + + @endif +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if (str($database->status)->contains('exited')) + + @else + + @endif +
+
+
+
diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index e62ef48ad..b663213a5 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -7,11 +7,16 @@ use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral; use App\Livewire\Project\Database\Mysql\General as MysqlGeneral; use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral; use App\Livewire\Project\Database\Redis\General as RedisGeneral; +use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo; +use App\Livewire\Server\Sentinel; +use App\Livewire\Server\Show; use App\Models\Environment; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -28,25 +33,75 @@ beforeEach(function () { session(['currentTeam' => $this->team]); }); -dataset('ssl-aware-database-general-components', [ +dataset('database-general-forms-without-broadcasts', [ + // Redis splits status-derived display into a sibling component; the form itself + // takes no broadcast listeners. Other DBs use the narrower refreshStatus pattern below. + RedisGeneral::class, +]); + +dataset('database-general-forms-with-narrow-refresh', [ + // Form listens to status broadcasts but routes them to refreshStatus, which only + // writes display-only properties (URLs, cert expiry) — never input-bound text fields. + PostgresqlGeneral::class, MysqlGeneral::class, MariadbGeneral::class, MongodbGeneral::class, - RedisGeneral::class, - PostgresqlGeneral::class, KeydbGeneral::class, DragonflyGeneral::class, ]); -it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) { - $component = app($componentClass); - $listeners = $component->getListeners(); +dataset('database-status-info-components', [ + RedisStatusInfo::class, +]); - expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh') - ->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh'); -})->with('ssl-aware-database-general-components'); +it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) { + // Regression guard for coolify#6062 / #6354 / #9695: + // For DBs whose status-derived display moved into a sibling component, the form + // itself must not subscribe to status broadcasts at all. + $listeners = resolveLivewireListeners(app($componentClass)); -it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () { + expect($listeners) + ->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); +})->with('database-general-forms-without-broadcasts'); + +it('routes status broadcasts to refreshStatus, never to a handler that re-syncs inputs', function (string $componentClass) { + // Regression guard for coolify#6062 / #6354 / #9695: + // The form may listen to broadcasts, but only to a narrow handler (refreshStatus) + // that touches display-only properties. Routing to `refresh` or `$refresh` would + // re-sync every input property from the DB and wipe in-progress typing. + $listeners = resolveLivewireListeners(app($componentClass)); + + $databaseStatusKey = "echo-private:user.{$this->user->id},DatabaseStatusChanged"; + $serviceCheckedKey = "echo-private:team.{$this->team->id},ServiceChecked"; + + expect($listeners[$databaseStatusKey] ?? null)->toBe('refreshStatus') + ->and($listeners[$serviceCheckedKey] ?? null)->toBe('refreshStatus'); +})->with('database-general-forms-with-narrow-refresh'); + +function resolveLivewireListeners(object $component): array +{ + // Livewire's HandlesEvents trait declares getListeners() as protected, + // so subclasses that override it as public are callable directly, but + // subclasses that rely on $listeners are not. Reflection handles both. + $method = new ReflectionMethod($component, 'getListeners'); + $method->setAccessible(true); + + return (array) $method->invoke($component); +} + +it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) { + // Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling + // Livewire component so it can re-render on broadcasts without touching the form's DOM. + $listeners = resolveLivewireListeners(app($componentClass)); + + expect($listeners) + ->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked"); +})->with('database-status-info-components'); + +it('reloads the mysql database model when refresh is called directly so ssl controls follow the latest status', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); $project = Project::factory()->create(['team_id' => $this->team->id]); @@ -75,3 +130,91 @@ it('reloads the mysql database model when refreshing so ssl controls follow the $component->call('refresh') ->assertSee('Database should be stopped to change this settings.'); }); + +it('does not clobber server form text inputs when sentinel restarts', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid]) + ->set('sentinelToken', 'user-was-typing-this-token'); + + $component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]); + + expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token'); +}); + +it('does not clobber server form text inputs when server validation completes', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->set('name', 'user-was-typing-here') + ->set('ip', '203.0.113.42'); + + $component->call('handleServerValidated', ['serverUuid' => $server->uuid]); + + expect($component->get('name'))->toBe('user-was-typing-here') + ->and($component->get('ip'))->toBe('203.0.113.42'); +}); + +it('preserves typed input on the postgres form when refreshStatus runs', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandalonePostgresql::create([ + 'name' => 'persisted-name', + 'image' => 'postgres:16', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'status' => 'exited:unhealthy', + 'enable_ssl' => false, + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(PostgresqlGeneral::class, ['database' => $database]) + ->set('name', 'user-was-typing-here') + ->set('portsMappings', '5433:5432'); + + $component->call('refreshStatus'); + + expect($component->get('name'))->toBe('user-was-typing-here') + ->and($component->get('portsMappings'))->toBe('5433:5432'); +}); + +it('shows the redis ssl gate hint after the sibling is refreshed', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandaloneRedis::create([ + 'name' => 'test-redis', + 'image' => 'redis:7', + 'redis_password' => 'password', + 'redis_username' => 'default', + 'status' => 'exited:unhealthy', + 'enable_ssl' => true, + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(RedisStatusInfo::class, ['database' => $database]) + ->assertDontSee('Database should be stopped to change this settings.'); + + $database->fill(['status' => 'running:healthy'])->save(); + + $component->call('refresh') + ->assertSee('Database should be stopped to change this settings.'); +}); From e7e65831a7c0d6b2ac39e98f0ded48afb39317db Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 21 May 2026 08:31:08 +0000 Subject: [PATCH 26/84] fix(livewire): preserve wire:dirty across DB status broadcasts The earlier refreshStatus fix kept user-typed values intact but Livewire still absorbed deferred wire:model values into the snapshot on every broadcast- triggered roundtrip, clearing the unsaved-changes indicator and making the form look auto-saved. Move all status-derived display (DB URLs, SSL toggle/mode, cert expiry) out of each DB General form into a sibling StatusInfo Livewire component, so the form never roundtrips on broadcasts. Shared scaffolding lives in App\Traits\HasDatabaseStatusInfo plus an x-database- status-info Blade component, leaving each per-DB StatusInfo class as a ~20-50 line declaration of label, SSL mode options, and SSL save hooks. Parents dispatch databaseUpdated from save methods so the sibling refreshes after writes. Tests cover the architecture (no DB form subscribes to status broadcasts) and the sibling's refresh-on-status-change behavior. --- .../Project/Database/Clickhouse/General.php | 27 +-- .../Database/Clickhouse/StatusInfo.php | 31 ++++ .../Project/Database/Dragonfly/General.php | 104 +---------- .../Project/Database/Dragonfly/StatusInfo.php | 26 +++ .../Project/Database/Keydb/General.php | 106 +---------- .../Project/Database/Keydb/StatusInfo.php | 26 +++ .../Project/Database/Mariadb/General.php | 107 +----------- .../Project/Database/Mariadb/StatusInfo.php | 21 +++ .../Project/Database/Mongodb/General.php | 119 +------------ .../Project/Database/Mongodb/StatusInfo.php | 51 ++++++ .../Project/Database/Mysql/General.php | 119 +------------ .../Project/Database/Mysql/StatusInfo.php | 51 ++++++ .../Project/Database/Postgresql/General.php | 124 +------------ .../Database/Postgresql/StatusInfo.php | 52 ++++++ .../Project/Database/Redis/StatusInfo.php | 103 +---------- app/Traits/HasDatabaseStatusInfo.php | 164 ++++++++++++++++++ .../components/database-status-info.blade.php | 94 ++++++++++ .../database/clickhouse/general.blade.php | 13 +- .../database/dragonfly/general.blade.php | 54 +----- .../project/database/keydb/general.blade.php | 53 +----- .../database/mariadb/general.blade.php | 52 +----- .../database/mongodb/general.blade.php | 77 +------- .../project/database/mysql/general.blade.php | 74 +------- .../database/postgresql/general.blade.php | 126 +++----------- .../database/redis/status-info.blade.php | 51 ------ .../project/database/status-info.blade.php | 6 + .../Feature/DatabaseSslStatusRefreshTest.php | 80 +++------ 27 files changed, 603 insertions(+), 1308 deletions(-) create mode 100644 app/Livewire/Project/Database/Clickhouse/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Dragonfly/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Keydb/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Mariadb/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Mongodb/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Mysql/StatusInfo.php create mode 100644 app/Livewire/Project/Database/Postgresql/StatusInfo.php create mode 100644 app/Traits/HasDatabaseStatusInfo.php create mode 100644 resources/views/components/database-status-info.blade.php delete mode 100644 resources/views/livewire/project/database/redis/status-info.blade.php create mode 100644 resources/views/livewire/project/database/status-info.blade.php diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index edcb31f5e..b5c0ffff4 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -40,34 +40,17 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; public function getListeners() { - $userId = Auth::id(); $teamId = Auth::user()->currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } - public function refreshStatus(): void - { - $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - } - public function mount() { try { @@ -101,8 +84,6 @@ class General extends Component 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', ]; } @@ -142,9 +123,6 @@ class General extends Component $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -157,8 +135,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -207,6 +183,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -218,6 +195,7 @@ class General extends Component public function databaseProxyStopped() { $this->syncData(); + $this->dispatch('databaseUpdated'); } public function submit() @@ -233,6 +211,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Clickhouse/StatusInfo.php b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php new file mode 100644 index 000000000..51a3192fa --- /dev/null +++ b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php @@ -0,0 +1,31 @@ +currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } - public function refreshStatus(): void - { - $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - public function mount() { try { @@ -84,12 +60,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -109,10 +79,7 @@ class General extends Component 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'nullable|boolean', ]; } @@ -148,11 +115,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -164,9 +127,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -215,6 +175,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -226,6 +187,7 @@ class General extends Component public function databaseProxyStopped() { $this->syncData(); + $this->dispatch('databaseUpdated'); } public function submit() @@ -241,6 +203,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -252,67 +215,6 @@ class General extends Component } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $server = $this->database->destination->server; - - $caCert = $server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Dragonfly/StatusInfo.php b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php new file mode 100644 index 000000000..baeb3d09f --- /dev/null +++ b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php @@ -0,0 +1,26 @@ +currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } - public function refreshStatus(): void - { - $this->database->refresh(); - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - public function mount() { try { @@ -86,12 +62,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -99,7 +69,7 @@ class General extends Component protected function rules(): array { - $baseRules = [ + return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', @@ -112,13 +82,8 @@ class General extends Component 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'boolean', ]; - - return $baseRules; } protected function messages(): array @@ -154,11 +119,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -171,9 +132,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; - $this->enable_ssl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } } @@ -222,6 +180,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -233,6 +192,7 @@ class General extends Component public function databaseProxyStopped() { $this->syncData(); + $this->dispatch('databaseUpdated'); } public function submit() @@ -248,6 +208,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -259,65 +220,6 @@ class General extends Component } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates() - ->where('is_ca_certificate', true) - ->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Keydb/StatusInfo.php b/app/Livewire/Project/Database/Keydb/StatusInfo.php new file mode 100644 index 000000000..1e87461cd --- /dev/null +++ b/app/Livewire/Project/Database/Keydb/StatusInfo.php @@ -0,0 +1,26 @@ +currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - protected function rules(): array { return [ @@ -105,7 +72,6 @@ class General extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', ]; } @@ -144,7 +110,6 @@ class General extends Component 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -158,12 +123,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -187,11 +146,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -207,9 +162,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -245,6 +197,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -281,6 +234,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -289,63 +243,6 @@ class General extends Component } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mariadb/StatusInfo.php b/app/Livewire/Project/Database/Mariadb/StatusInfo.php new file mode 100644 index 000000000..c6fda37b6 --- /dev/null +++ b/app/Livewire/Project/Database/Mariadb/StatusInfo.php @@ -0,0 +1,21 @@ +currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - protected function rules(): array { return [ @@ -102,8 +67,6 @@ class General extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full', ]; } @@ -123,7 +86,6 @@ class General extends Component 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', ] ); } @@ -141,8 +103,6 @@ class General extends Component 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -156,12 +116,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -184,12 +138,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -204,10 +153,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -246,6 +191,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -282,6 +228,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -290,68 +237,6 @@ class General extends Component } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mongodb/StatusInfo.php b/app/Livewire/Project/Database/Mongodb/StatusInfo.php new file mode 100644 index 000000000..a92a682c9 --- /dev/null +++ b/app/Livewire/Project/Database/Mongodb/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MongoDB connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 6e1e55b3f..6b88d735d 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mysql; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneMysql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -50,38 +47,6 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - protected function rules(): array { return [ @@ -107,8 +72,6 @@ class General extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', ]; } @@ -129,7 +92,6 @@ class General extends Component 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', ] ); } @@ -148,8 +110,6 @@ class General extends Component 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -163,12 +123,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -192,12 +146,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -213,10 +162,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -252,6 +197,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -288,6 +234,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -296,68 +243,6 @@ class General extends Component } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mysql/StatusInfo.php b/app/Livewire/Project/Database/Mysql/StatusInfo.php new file mode 100644 index 000000000..5fbbc1583 --- /dev/null +++ b/app/Livewire/Project/Database/Mysql/StatusInfo.php @@ -0,0 +1,51 @@ + ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'], + 'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'], + 'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'], + 'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for MySQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 1b36ac28a..4e89e8b62 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Postgresql; use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -54,43 +51,14 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - public string $new_filename; public string $new_content; - public ?string $db_url = null; - - public ?string $db_url_public = null; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - // Broadcasts go to refreshStatus, which only writes display-only properties. - // Never wire status broadcasts to a handler that touches text-input properties — - // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', - "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', - 'save_init_script', - 'delete_init_script', - ]; - } - - public function refreshStatus(): void - { - $this->database->refresh(); - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } + protected $listeners = [ + 'save_init_script', + 'delete_init_script', + ]; protected function rules(): array { @@ -117,8 +85,6 @@ class General extends Component 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', - 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full', ]; } @@ -138,7 +104,6 @@ class General extends Component 'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', - 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', ] ); } @@ -159,8 +124,6 @@ class General extends Component 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -174,12 +137,6 @@ class General extends Component return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -205,12 +162,7 @@ class General extends Component $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -228,10 +180,6 @@ class General extends Component $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->sslMode = $this->database->ssl_mode; - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } } @@ -254,68 +202,6 @@ class General extends Component } } - public function updatedSslMode() - { - $this->instantSaveSSL(); - } - - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - public function instantSave() { try { @@ -341,6 +227,7 @@ class General extends Component StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -504,6 +391,7 @@ class General extends Component } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Postgresql/StatusInfo.php b/app/Livewire/Project/Database/Postgresql/StatusInfo.php new file mode 100644 index 000000000..cc27b61bb --- /dev/null +++ b/app/Livewire/Project/Database/Postgresql/StatusInfo.php @@ -0,0 +1,52 @@ + ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'], + 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'], + 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'], + 'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'], + 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'], + ]; + } + + protected function sslModeHelper(): string + { + return 'Choose the SSL verification mode for PostgreSQL connections'; + } + + protected function afterRefresh(): void + { + $this->sslMode = $this->database->ssl_mode; + } + + protected function applyExtraSslAttributes(): void + { + $this->database->ssl_mode = $this->sslMode; + } + + public function updatedSslMode(): void + { + $this->instantSaveSSL(); + } +} diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php index 183ed936f..2e784e2c0 100644 --- a/app/Livewire/Project/Database/Redis/StatusInfo.php +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -2,115 +2,20 @@ namespace App\Livewire\Project\Database\Redis; -use App\Helpers\SslHelper; use App\Models\StandaloneRedis; -use Carbon\Carbon; -use Exception; +use App\Traits\HasDatabaseStatusInfo; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class StatusInfo extends Component { use AuthorizesRequests; + use HasDatabaseStatusInfo; public StandaloneRedis $database; - public bool $enableSsl = false; - - public ?Carbon $certificateValidUntil = null; - - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - - public function getListeners() + protected function databaseLabel(): string { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'databaseUpdated' => 'refresh', - ]; - } - - public function mount(): void - { - $this->refresh(); - } - - public function refresh(): void - { - $this->database->refresh(); - $this->enableSsl = (bool) $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; - $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; - } - - public function instantSaveSSL(): void - { - try { - $this->authorize('update', $this->database); - $this->database->enable_ssl = $this->enableSsl; - $this->database->save(); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - - public function regenerateSslCertificate(): void - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $server = $this->database->destination->server; - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $server->generateCaCertificate(); - $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->refresh(); - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - - public function render() - { - return view('livewire.project.database.redis.status-info'); + return 'Redis'; } } diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php new file mode 100644 index 000000000..98c939b7e --- /dev/null +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -0,0 +1,164 @@ +currentTeam()->id; + + return [ + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + 'databaseUpdated' => 'refresh', + ]; + } + + public function mount(): void + { + $this->refresh(); + } + + public function refresh(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + if ($this->supportsSsl()) { + $this->enableSsl = (bool) $this->database->enable_ssl; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + $this->afterRefresh(); + } + } + + /** + * Hook for subclasses with extra status-derived properties (e.g. sslMode). + */ + protected function afterRefresh(): void {} + + public function instantSaveSSL(): void + { + try { + $this->authorize('update', $this->database); + $this->database->enable_ssl = $this->enableSsl; + $this->applyExtraSslAttributes(); + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + /** + * Hook for subclasses with additional SSL columns to persist (e.g. ssl_mode). + */ + protected function applyExtraSslAttributes(): void {} + + public function regenerateSslCertificate(): void + { + try { + $this->authorize('update', $this->database); + + $existingCert = $this->database->sslCertificates()->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $server = $this->database->destination->server; + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + + if (! $caCert) { + $server->generateCaCertificate(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + mountPath: $existingCert->mount_path, + isPemKeyFileRequired: true, + ); + + $this->refresh(); + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.database.status-info', [ + 'label' => $this->databaseLabel(), + 'supportsSsl' => $this->supportsSsl(), + 'sslModeOptions' => $this->sslModeOptions(), + 'sslModeHelper' => $this->sslModeHelper(), + 'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(), + 'isExited' => str($this->database->status)->contains('exited'), + ]); + } +} diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php new file mode 100644 index 000000000..a7c8dade1 --- /dev/null +++ b/resources/views/components/database-status-info.blade.php @@ -0,0 +1,94 @@ +@props([ + 'database', + 'label', + 'dbUrl' => null, + 'dbUrlPublic' => null, + 'supportsSsl' => true, + 'enableSsl' => false, + 'sslMode' => null, + 'sslModeOptions' => null, + 'sslModeHelper' => null, + 'certificateValidUntil' => null, + 'isExited' => false, + 'showPublicUrlPlaceholder' => false, +]) + +@php + $urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.'; +@endphp + +
+ + @if ($dbUrlPublic) + + @elseif ($showPublicUrlPlaceholder) + + @endif + + @if ($supportsSsl) +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if ($isExited) + + @else + + @endif +
+ @if ($sslModeOptions && $enableSsl) +
+ @if ($isExited) + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @else + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @endif +
+ @endif +
+
+ @endif +
diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 9283172ad..acba65442 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -41,19 +41,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($dbUrlPublic) - - @else - - @endif
+
diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index ce46e47dd..7f217f0cc 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -37,60 +37,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - - @if ($dbUrlPublic) - - @else - - @endif -
-
-
-
-

SSL Configuration

- @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
-
- @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index ee3f8fd0c..fa241dec2 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -38,59 +38,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($dbUrlPublic) - - @else - - @endif -
-
-
-
-

SSL Configuration

- @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
-
- @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index 1154124d1..b29b3e81e 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -61,59 +61,9 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($db_url_public) - - @endif
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
-
+
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index e9e5d621d..c1ec60219 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -50,85 +50,10 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($db_url_public) - - @endif
+
-
-
-

SSL Configuration

- @if ($enableSsl) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
- @if ($enableSsl) -
- @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
- @endif -
-
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index bb3916ec8..e90885e7c 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -56,81 +56,9 @@
- - @if ($db_url_public) - - @endif
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
- @if ($enableSsl) -
- @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
- @endif -
-
+
diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 9c956f5b3..ab6f6ed88 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -68,114 +68,38 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - - @if ($db_url_public) - - @endif
+
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - +

Proxy

+ + @if (data_get($database, 'is_public')) + + Proxy Logs + + + + Logs + @endif
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif +
+ +
+
+ + +
-
-
- @if ($database->isExited()) - - @else - - @endif -
- @if ($enableSsl) -
- @if ($database->isExited()) - - - - - - - - @else - - - - - - - - @endif -
- @endif - -
-
-

Proxy

- - @if (data_get($database, 'is_public')) - - Proxy Logs - - - - Logs - - @endif -
-
- -
-
- - -
-
- -
- -
-
+
diff --git a/resources/views/livewire/project/database/redis/status-info.blade.php b/resources/views/livewire/project/database/redis/status-info.blade.php deleted file mode 100644 index 9f329504c..000000000 --- a/resources/views/livewire/project/database/redis/status-info.blade.php +++ /dev/null @@ -1,51 +0,0 @@ -
- - @if ($dbUrlPublic) - - @endif -
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
-
-
diff --git a/resources/views/livewire/project/database/status-info.blade.php b/resources/views/livewire/project/database/status-info.blade.php new file mode 100644 index 000000000..7107b3daf --- /dev/null +++ b/resources/views/livewire/project/database/status-info.blade.php @@ -0,0 +1,6 @@ +
+ +
diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index b663213a5..7b0e4c0a3 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -1,11 +1,19 @@ not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); })->with('database-general-forms-without-broadcasts'); -it('routes status broadcasts to refreshStatus, never to a handler that re-syncs inputs', function (string $componentClass) { - // Regression guard for coolify#6062 / #6354 / #9695: - // The form may listen to broadcasts, but only to a narrow handler (refreshStatus) - // that touches display-only properties. Routing to `refresh` or `$refresh` would - // re-sync every input property from the DB and wipe in-progress typing. - $listeners = resolveLivewireListeners(app($componentClass)); - - $databaseStatusKey = "echo-private:user.{$this->user->id},DatabaseStatusChanged"; - $serviceCheckedKey = "echo-private:team.{$this->team->id},ServiceChecked"; - - expect($listeners[$databaseStatusKey] ?? null)->toBe('refreshStatus') - ->and($listeners[$serviceCheckedKey] ?? null)->toBe('refreshStatus'); -})->with('database-general-forms-with-narrow-refresh'); - function resolveLivewireListeners(object $component): array { // Livewire's HandlesEvents trait declares getListeners() as protected, @@ -101,7 +99,7 @@ it('auto-refreshes status-info sibling on database status broadcasts', function ->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked"); })->with('database-status-info-components'); -it('reloads the mysql database model when refresh is called directly so ssl controls follow the latest status', function () { +it('reloads the mysql status-info model when refresh is called so ssl controls follow the latest status', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); $project = Project::factory()->create(['team_id' => $this->team->id]); @@ -122,7 +120,7 @@ it('reloads the mysql database model when refresh is called directly so ssl cont 'destination_type' => $destination->getMorphClass(), ]); - $component = Livewire::test(MysqlGeneral::class, ['database' => $database]) + $component = Livewire::test(MysqlStatusInfo::class, ['database' => $database]) ->assertDontSee('Database should be stopped to change this settings.'); $database->fill(['status' => 'running:healthy'])->save(); @@ -161,36 +159,6 @@ it('does not clobber server form text inputs when server validation completes', ->and($component->get('ip'))->toBe('203.0.113.42'); }); -it('preserves typed input on the postgres form when refreshStatus runs', function () { - $server = Server::factory()->create(['team_id' => $this->team->id]); - $destination = StandaloneDocker::where('server_id', $server->id)->first(); - $project = Project::factory()->create(['team_id' => $this->team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - - $database = StandalonePostgresql::create([ - 'name' => 'persisted-name', - 'image' => 'postgres:16', - 'postgres_user' => 'postgres', - 'postgres_password' => 'password', - 'postgres_db' => 'postgres', - 'status' => 'exited:unhealthy', - 'enable_ssl' => false, - 'is_log_drain_enabled' => false, - 'environment_id' => $environment->id, - 'destination_id' => $destination->id, - 'destination_type' => $destination->getMorphClass(), - ]); - - $component = Livewire::test(PostgresqlGeneral::class, ['database' => $database]) - ->set('name', 'user-was-typing-here') - ->set('portsMappings', '5433:5432'); - - $component->call('refreshStatus'); - - expect($component->get('name'))->toBe('user-was-typing-here') - ->and($component->get('portsMappings'))->toBe('5433:5432'); -}); - it('shows the redis ssl gate hint after the sibling is refreshed', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); From 7a3fcd37d5b5057523aa5107d986f209b29d5403 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Thu, 21 May 2026 10:24:49 +0000 Subject: [PATCH 27/84] fix(livewire): scope DatabaseProxyStopped to proxy fields, harden status trait Clickhouse, Dragonfly, and Keydb still called syncData() inside the DatabaseProxyStopped broadcast handler, clobbering in-progress edits to name/description/credentials. Refresh only is_public/public_port/ public_port_timeout instead, matching the pattern used elsewhere. Also null-guard HasDatabaseStatusInfo::getListeners() against an absent Auth::user()/currentTeam(), add explicit return types on getListeners() and render(), and convert inline comments in the SSL refresh test to a PHPDoc block. --- .../Project/Database/Clickhouse/General.php | 7 +++-- .../Project/Database/Dragonfly/General.php | 7 +++-- .../Project/Database/Keydb/General.php | 7 +++-- app/Traits/HasDatabaseStatusInfo.php | 26 ++++++++++++------- .../Feature/DatabaseSslStatusRefreshTest.php | 8 +++--- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index b5c0ffff4..857300926 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -192,9 +192,12 @@ class General extends Component } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->dispatch('databaseUpdated'); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 5f57693b1..01a474761 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -184,9 +184,12 @@ class General extends Component } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->dispatch('databaseUpdated'); } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 1c5c828a3..6031cb7ac 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -189,9 +189,12 @@ class General extends Component } } - public function databaseProxyStopped() + public function databaseProxyStopped(): void { - $this->syncData(); + $this->database->refresh(); + $this->isPublic = $this->database->is_public; + $this->publicPort = $this->database->public_port; + $this->publicPortTimeout = $this->database->public_port_timeout; $this->dispatch('databaseUpdated'); } diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php index 98c939b7e..e46cccf0c 100644 --- a/app/Traits/HasDatabaseStatusInfo.php +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -5,6 +5,7 @@ namespace App\Traits; use App\Helpers\SslHelper; use Carbon\Carbon; use Exception; +use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; /** @@ -51,16 +52,23 @@ trait HasDatabaseStatusInfo return false; } - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; + $listeners = ['databaseUpdated' => 'refresh']; - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'databaseUpdated' => 'refresh', - ]; + $user = Auth::user(); + if (! $user) { + return $listeners; + } + + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refresh'; + + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refresh'; + } + + return $listeners; } public function mount(): void @@ -150,7 +158,7 @@ trait HasDatabaseStatusInfo } } - public function render() + public function render(): View { return view('livewire.project.database.status-info', [ 'label' => $this->databaseLabel(), diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index 7b0e4c0a3..7efb03789 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -78,11 +78,13 @@ it('does not subscribe the form to status broadcasts when display lives in a sib ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); })->with('database-general-forms-without-broadcasts'); +/** + * Resolve a Livewire component's listeners regardless of whether the subclass + * exposes getListeners() publicly or only declares a $listeners array — the + * HandlesEvents trait keeps getListeners() protected by default. + */ function resolveLivewireListeners(object $component): array { - // Livewire's HandlesEvents trait declares getListeners() as protected, - // so subclasses that override it as public are callable directly, but - // subclasses that rely on $listeners are not. Reflection handles both. $method = new ReflectionMethod($component, 'getListeners'); $method->setAccessible(true); From 9b977b9e4d12348c0172c9f73e3a9ab3bd430b0c Mon Sep 17 00:00:00 2001 From: michalzard Date: Thu, 21 May 2026 19:59:14 +0200 Subject: [PATCH 28/84] chore(gitea-runner): bumped version to 1.0.5 --- templates/compose/gitea-runner.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/gitea-runner.yaml b/templates/compose/gitea-runner.yaml index 42bb21984..712424881 100644 --- a/templates/compose/gitea-runner.yaml +++ b/templates/compose/gitea-runner.yaml @@ -6,7 +6,7 @@ services: runner: - image: 'docker.io/gitea/runner:1.0.4' + image: 'docker.io/gitea/runner:1.0.5' environment: - 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}' - 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}' From d415f3a3d1e60243281676d90bc7e8de8976f0e2 Mon Sep 17 00:00:00 2001 From: Firsak <31401457+Firsak@users.noreply.github.com> Date: Fri, 22 May 2026 11:06:32 +0200 Subject: [PATCH 29/84] fix(team): prevent 500 after deleting the current team When a user deletes their current team, the session and cache still reference the just-deleted team. `refreshSession()` then resolves that stale team via `currentTeam()`, calls `Team::find()` (which returns null because the row is gone) and dereferences `$team->id`, leaving the session without a current team. The subsequent redirect to the team page assigns the now-null `currentTeam()` to the non-nullable `Team $team` property in `Team\Index::mount()`, throwing a TypeError and producing an HTTP 500. Guard `refreshSession()` against a deleted current team: fall back to any team the user still belongs to, and if none remain, clear the stale session reference instead of dereferencing null. Co-Authored-By: Claude Opus 4.7 (1M context) --- bootstrap/helpers/shared.php | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 860b550dd..011c86744 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -353,14 +353,30 @@ function showBoarding(): bool function refreshSession(?Team $team = null): void { if (! $team) { - if (Auth::user()->currentTeam()) { - $team = Team::find(Auth::user()->currentTeam()->id); - } else { - $team = User::find(Auth::id())->teams->first(); + $currentTeam = Auth::user()->currentTeam(); + if ($currentTeam) { + // currentTeam() can resolve a stale (just-deleted) team from the + // session/cache, so Team::find() may still return null here. + $team = Team::find($currentTeam->id); + } + if (! $team) { + // Fall back to any team the user still belongs to. + $team = User::find(Auth::id())->teams()->first(); } } + // Clear old cache key format for backwards compatibility Cache::forget('team:'.Auth::id()); + + if (! $team) { + // The user has no team left (e.g. just deleted their current team and + // belongs to no other): clear the stale session reference instead of + // dereferencing null. + session()->forget('currentTeam'); + + return; + } + // Use new cache key format that includes team ID Cache::forget('user:'.Auth::id().':team:'.$team->id); Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) { From bd744eb8dd9c3071c2c4b4fd7498faa4825764bb Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Fri, 22 May 2026 21:22:50 +0530 Subject: [PATCH 30/84] fix(ui): configuration changes modal values, colors and spacing --- app/Models/Application.php | 21 ++++--- .../ApplicationConfigurationSnapshot.php | 4 +- .../ConfigurationDiffer.php | 4 +- .../deployment/configuration-diff.blade.php | 58 +++++++++---------- .../ApplicationConfigurationChangedTest.php | 14 +++-- .../Livewire/ConfigurationCheckerTest.php | 7 +-- .../ApplicationConfigurationSnapshotTest.php | 8 +-- 7 files changed, 58 insertions(+), 58 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 97b257752..fd7f486b9 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1188,17 +1188,20 @@ class Application extends BaseModel $currentSnapshot = $this->deploymentConfigurationSnapshot(); $lastDeployment = $this->get_last_successful_deployment(); - if ($lastDeployment?->configuration_snapshot) { - return app(ConfigurationDiffer::class)->diff($lastDeployment->configuration_snapshot, $currentSnapshot); + $previousSnapshot = $lastDeployment?->configuration_snapshot; + + if (! $previousSnapshot) { + $oldConfigHash = data_get($this, 'config_hash'); + $hasLegacyChange = $oldConfigHash === null || $oldConfigHash !== $this->legacyConfigurationHash(); + + if (! $hasLegacyChange) { + return ConfigurationDiff::unchanged(); + } + + $previousSnapshot = []; } - $oldConfigHash = data_get($this, 'config_hash'); - - if ($oldConfigHash === null) { - return ConfigurationDiff::legacy(true); - } - - return ConfigurationDiff::legacy($oldConfigHash !== $this->legacyConfigurationHash()); + return app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot); } public function hasPendingDeploymentConfigurationChanges(): bool diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 676b22b6c..8369f9a90 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -306,7 +306,7 @@ class ApplicationConfigurationSnapshot private function displayValue(mixed $value): string { if ($value === null) { - return 'Not set'; + return '-'; } if (is_bool($value)) { @@ -323,7 +323,7 @@ class ApplicationConfigurationSnapshot private function summarizeText(?string $value): string { if (blank($value)) { - return 'Not set'; + return '-'; } $value = trim((string) $value); diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 27e8d4c3f..b101b9d5b 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -37,8 +37,8 @@ class ConfigurationDiffer 'impact' => data_get($item, 'impact', 'redeploy'), 'sensitive' => $sensitive, 'display_summary' => $displaySummary, - 'old_display_value' => $sensitive ? ($previous === null ? 'Not set' : 'Set') : data_get($previous, 'display_value', 'Not set'), - 'new_display_value' => $sensitive ? ($current === null ? 'Removed' : 'Set') : data_get($current, 'display_value', 'Not set'), + 'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'), + 'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'), ]; } diff --git a/resources/views/components/deployment/configuration-diff.blade.php b/resources/views/components/deployment/configuration-diff.blade.php index ffc0cd34a..f01481057 100644 --- a/resources/views/components/deployment/configuration-diff.blade.php +++ b/resources/views/components/deployment/configuration-diff.blade.php @@ -4,9 +4,9 @@ ]) @php - $changes = data_get($diff, 'changes', []); - $count = data_get($diff, 'count', count($changes)); - $requiresBuild = data_get($diff, 'requires_build', false); + $changes = collect(data_get($diff, 'changes', []))->filter(fn ($change) => data_get($change, 'key') !== 'domains.custom_labels')->values()->all(); + $count = count($changes); + $requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build'); @endphp @if ($count > 0) @@ -21,45 +21,39 @@ 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' => $requiresBuild, 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild, ])> - {{ $requiresBuild ? 'Rebuild' : 'Redeploy' }} + {{ $requiresBuild ? 'Rebuild required' : 'Redeploy required' }}
@unless ($compact) -
+
@foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
{{ $sectionLabel }}
-
-
-
-
Field
-
Type
-
From
-
-
To
-
-
- @foreach ($sectionChanges as $change) -
-
- {{ data_get($change, 'label') }} -
-
- {{ data_get($change, 'type') }} -
-
- {{ data_get($change, 'old_display_value') }} -
-
-
- {{ data_get($change, 'new_display_value') }} -
+
+
+
Field
+
From
+
+
To
+
+
+ @foreach ($sectionChanges as $change) +
+
+ {{ data_get($change, 'label') }}
- @endforeach -
+
+ {{ data_get($change, 'old_display_value') }} +
+
+
+ {{ data_get($change, 'new_display_value') }} +
+
+ @endforeach
diff --git a/tests/Feature/ApplicationConfigurationChangedTest.php b/tests/Feature/ApplicationConfigurationChangedTest.php index f862f840d..b91e9f289 100644 --- a/tests/Feature/ApplicationConfigurationChangedTest.php +++ b/tests/Feature/ApplicationConfigurationChangedTest.php @@ -80,11 +80,11 @@ it('checks legacy preview deployment configuration hash using preview environmen $diff = $application->pendingDeploymentConfigurationDiff(); - expect($diff->isLegacyFallback())->toBeTrue() - ->and($diff->isChanged())->toBeTrue(); + expect($diff->isChanged())->toBeTrue() + ->and($diff->count())->toBeGreaterThan(0); }); -it('falls back to legacy configuration hash when no deployment snapshot exists', function () { +it('falls back to real diff against empty snapshot when no deployment snapshot exists', function () { $application = configurationChangedTestApplication(); $application->isConfigurationChanged(save: true); @@ -92,6 +92,10 @@ it('falls back to legacy configuration hash when no deployment snapshot exists', $application->update(['build_command' => 'pnpm build']); - expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue() - ->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue(); + $diff = $application->refresh()->pendingDeploymentConfigurationDiff(); + + expect($diff->isChanged())->toBeTrue() + ->and($diff->isLegacyFallback())->toBeFalse() + ->and($diff->count())->toBeGreaterThan(0) + ->and(collect($diff->changes())->pluck('label')->toArray())->toContain('Build command'); }); diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php index edf8c5044..d9e6729c8 100644 --- a/tests/Feature/Livewire/ConfigurationCheckerTest.php +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -126,8 +126,7 @@ it('does not render environment variable secret values', function () { Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) ->assertSee('API_TOKEN') - ->assertSee('changed') - ->assertSee('Set') + ->assertSee('••••••••') ->assertDontSee('Hidden') ->assertDontSee('old-secret') ->assertDontSee('new-secret'); @@ -150,9 +149,9 @@ it('renders added environment variables as set without exposing secret values', Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) ->assertSee('API_TOKEN') ->assertSee('From') - ->assertSee('Not set') + ->assertSee('-') ->assertSee('To') - ->assertSee('Set') + ->assertSee('••••••••') ->assertDontSee('Hidden') ->assertDontSee('new-secret'); }); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 2106697b2..20b7c0adc 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -93,8 +93,8 @@ it('detects environment variable value changes without exposing secret values', expect($change)->not->toBeNull() ->and($change['display_summary'])->toBe('Changed') - ->and($change['old_display_value'])->toBe('Set') - ->and($change['new_display_value'])->toBe('Set') + ->and($change['old_display_value'])->toBe('••••••••') + ->and($change['new_display_value'])->toBe('••••••••') ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret'); }); @@ -117,7 +117,7 @@ it('describes added environment variables as set without exposing secret values' expect($change)->not->toBeNull() ->and($change['display_summary'])->toBeNull() - ->and($change['old_display_value'])->toBe('Not set') - ->and($change['new_display_value'])->toBe('Set') + ->and($change['old_display_value'])->toBe('-') + ->and($change['new_display_value'])->toBe('••••••••') ->and(json_encode($diff->toArray()))->not->toContain('new-secret'); }); From 9c5c39334a46ed5a302ba5c466a6db89bfb35c0a Mon Sep 17 00:00:00 2001 From: michalzard Date: Mon, 25 May 2026 16:02:48 +0200 Subject: [PATCH 31/84] chore(gitea-runner): bumped version to 1.0.6 --- templates/compose/gitea-runner.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/compose/gitea-runner.yaml b/templates/compose/gitea-runner.yaml index 712424881..4cf5ac503 100644 --- a/templates/compose/gitea-runner.yaml +++ b/templates/compose/gitea-runner.yaml @@ -6,7 +6,7 @@ services: runner: - image: 'docker.io/gitea/runner:1.0.5' + image: 'docker.io/gitea/runner:1.0.6' environment: - 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}' - 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}' From 21db1fd3745694c735410bec986c723795774555 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 26 May 2026 11:41:04 +0200 Subject: [PATCH 32/84] fix(sync-bunny): sync nightly CDN files to nested paths Write nightly versions and releases under json/nightly in the CDN repo, and cover both release and versions-only sync flows with feature tests. --- app/Console/Commands/SyncBunny.php | 9 +-- templates/service-templates-latest.json | 81 +++++++++++++++++++++++-- templates/service-templates.json | 81 +++++++++++++++++++++++-- tests/Feature/SyncBunnyTest.php | 68 +++++++++++++++++++++ 4 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 tests/Feature/SyncBunnyTest.php diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 9ac3371e0..50bdfaf1e 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -212,7 +212,8 @@ class SyncBunny extends Command $timestamp = time(); $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp; $branchName = 'update-releases-and-versions-'.$timestamp; - $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; + $versionsTargetPath = $nightly ? 'json/nightly/versions.json' : 'json/versions.json'; + $releasesTargetPath = $nightly ? 'json/nightly/releases.json' : 'json/releases.json'; // 3. Clone the repository $this->info('Cloning coolify-cdn repository...'); @@ -237,7 +238,7 @@ class SyncBunny extends Command // 5. Write releases.json $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/json/releases.json"; + $releasesPath = "$tmpDir/$releasesTargetPath"; $releasesDir = dirname($releasesPath); if (! is_dir($releasesDir)) { @@ -282,7 +283,7 @@ class SyncBunny extends Command // 7. Stage both files $this->info('Staging changes...'); $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); + exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($releasesTargetPath).' '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); if ($returnCode !== 0) { $this->error('Failed to stage changes: '.implode("\n", $output)); exec('rm -rf '.escapeshellarg($tmpDir)); @@ -539,7 +540,7 @@ class SyncBunny extends Command $timestamp = time(); $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp; $branchName = 'update-versions-'.$timestamp; - $targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json'; + $targetPath = $nightly ? 'json/nightly/versions.json' : 'json/versions.json'; // Clone the repository $this->info('Cloning coolify-cdn repository...'); diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index d1cebb2ca..b97e5ef9a 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -171,7 +171,7 @@ "audiobookshelf": { "documentation": "https://www.audiobookshelf.org/?utm_source=coolify.io", "slogan": "Self-hosted audiobook, ebook, and podcast server", - "compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BVURJT0JPT0tTSEVMRl84MAogICAgICAtICdUWj0ke1RJTUVaT05FOi1BbWVyaWNhL1Rvcm9udG99JwogICAgdm9sdW1lczoKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtYXVkaW9ib29rczovYXVkaW9ib29rcycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtcG9kY2FzdHM6L3BvZGNhc3RzJwogICAgICAtICdhdWRpb2Jvb2tzaGVsZi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtbWV0YWRhdGE6L21ldGFkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC0tcXVpZXQgLS10cmllcz0xIC0tdGltZW91dD01IGh0dHA6Ly9sb2NhbGhvc3Q6ODAvcGluZyAtTyAvZGV2L251bGwgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTVzCg==", + "compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjoyLjM0LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9BVURJT0JPT0tTSEVMRl84MAogICAgICAtICdUWj0ke1RJTUVaT05FOi1BbWVyaWNhL1Rvcm9udG99JwogICAgdm9sdW1lczoKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtYXVkaW9ib29rczovYXVkaW9ib29rcycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtcG9kY2FzdHM6L3BvZGNhc3RzJwogICAgICAtICdhdWRpb2Jvb2tzaGVsZi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtbWV0YWRhdGE6L21ldGFkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC0tcXVpZXQgLS10cmllcz0xIC0tdGltZW91dD01IGh0dHA6Ly9sb2NhbGhvc3Q6ODAvcGluZyAtTyAvZGV2L251bGwgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTVzCg==", "tags": [ "audiobooks", "ebooks", @@ -654,6 +654,18 @@ "minversion": "0.0.0", "port": "8978" }, + "cloudflare-ddns": { + "documentation": "https://github.com/favonia/cloudflare-ddns?utm_source=coolify.io", + "slogan": "A small, feature-rich, and robust Cloudflare DDNS updater.", + "compose": "c2VydmljZXM6CiAgY2xvdWRmbGFyZS1kZG5zOgogICAgaW1hZ2U6ICdmYXZvbmlhL2Nsb3VkZmxhcmUtZGRuczoxLjE2LjInCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIHVzZXI6ICcxMDAwOjEwMDAnCiAgICByZWFkX29ubHk6IHRydWUKICAgIGNhcF9kcm9wOgogICAgICAtIGFsbAogICAgc2VjdXJpdHlfb3B0OgogICAgICAtICduby1uZXctcHJpdmlsZWdlczp0cnVlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMT1VERkxBUkVfQVBJX1RPS0VOPSR7Q0xPVURGTEFSRV9BUElfVE9LRU46P30nCiAgICAgIC0gJ0RPTUFJTlM9JHtET01BSU5TOj99JwogICAgICAtICdQUk9YSUVEPSR7UFJPWElFRDotZmFsc2V9JwogICAgICAtICdJUDZfUFJPVklERVI9JHtJUDZfUFJPVklERVI6LW5vbmV9Jwo=", + "tags": [ + "cloud", + "ddns" + ], + "category": "automation", + "logo": "svgs/cloudflare-ddns.svg", + "minversion": "0.0.0" + }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", "slogan": "Client for Cloudflare Tunnel, a daemon that exposes private services through the Cloudflare edge.", @@ -1149,6 +1161,23 @@ "minversion": "0.0.0", "port": "6555" }, + "emqx-enterprise": { + "documentation": "https://www.emqx.io/docs/en/latest/deploy/install-docker.html?utm_source=coolify.io", + "slogan": "Open-source MQTT broker for IoT, IIoT, and connected vehicles.", + "compose": "c2VydmljZXM6CiAgZW1xeDoKICAgIGltYWdlOiAnZW1xeC9lbXF4LWVudGVycHJpc2U6Ni4yLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FTVFYXzE4MDgzCiAgICAgIC0gJ0VNUVhfREFTSEJPQVJEX19ERUZBVUxUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9FTVFYfScKICAgIHBvcnRzOgogICAgICAtICcxODgzOjE4ODMnCiAgICAgIC0gJzgwODM6ODA4MycKICAgICAgLSAnODA4NDo4MDg0JwogICAgICAtICc4ODgzOjg4ODMnCiAgICB2b2x1bWVzOgogICAgICAtICdlbXF4X2RhdGE6L29wdC9lbXF4L2RhdGEnCiAgICAgIC0gJ2VtcXhfbG9nOi9vcHQvZW1xeC9sb2cnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL29wdC9lbXF4L2Jpbi9lbXF4CiAgICAgICAgLSBjdGwKICAgICAgICAtIHN0YXR1cwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCg==", + "tags": [ + "mqtt", + "broker", + "iot", + "messaging", + "emqx", + "iiot" + ], + "category": "Networking", + "logo": "svgs/emqx-enterprise.svg", + "minversion": "0.0.0", + "port": "18083" + }, "ente-photos-with-s3": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", @@ -1637,7 +1666,7 @@ "gitea-runner": { "documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io", "slogan": "Gitea Actions runner for docker", - "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC42JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", "tags": [ "gitea", "actions", @@ -1951,7 +1980,7 @@ "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", "slogan": "Grocy is a web-based household management and grocery list application.", - "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfR1JPQ1kKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdncm9jeS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", + "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6NC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUk9DWQogICAgICAtIFBVSUQ9MTAwMAogICAgICAtIFBHSUQ9MTAwMAogICAgICAtIFRaPUV1cm9wZS9NYWRyaWQKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyb2N5LWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", "tags": [ "groceries", "household", @@ -1992,6 +2021,25 @@ "logo": "svgs/heimdall.svg", "minversion": "0.0.0" }, + "hermes-agent-with-webui": { + "documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io", + "slogan": "Hermes Agent \u2014 autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.", + "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnNoYS0yNzNmZjVjNGE0N2FmNDQ5OWJiZTVlM2IxMTM5ZWZkMzEzOTk1NTU0JwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hFUk1FU1dFQlVJXzg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfSE9TVD0wLjAuMC4wCiAgICAgIC0gSEVSTUVTX1dFQlVJX1BPUlQ9ODc4NwogICAgICAtIEhFUk1FU19XRUJVSV9TVEFURV9ESVI9L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy93ZWJ1aQogICAgICAtIFdBTlRFRF9VSUQ9MTAwMAogICAgICAtIFdBTlRFRF9HSUQ9MTAwMAogICAgICAtICdIRVJNRVNfV0VCVUlfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0hFUk1FU1dFQlVJfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1hZ2VudC1zcmM6L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy9oZXJtZXMtYWdlbnQ6cm8nCiAgICAgIC0gJ2hlcm1lcy13b3Jrc3BhY2U6L3dvcmtzcGFjZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4Nzg3L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", + "tags": [ + "ai", + "agent", + "llm", + "chatbot", + "hermes", + "openrouter", + "anthropic", + "openai" + ], + "category": "ai", + "logo": "svgs/hermes-agent.png", + "minversion": "0.0.0", + "port": "8787" + }, "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", @@ -2176,7 +2224,7 @@ "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", - "compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSkVMTFlGSU5fODA5NgogICAgICAtIFBVSUQ9MTAwMAogICAgICAtIFBHSUQ9MTAwMAogICAgICAtIFRaPUV1cm9wZS9NYWRyaWQKICAgICAgLSBKRUxMWUZJTl9QdWJsaXNoZWRTZXJ2ZXJVcmw9JFNFUlZJQ0VfVVJMX0pFTExZRklOCiAgICB2b2x1bWVzOgogICAgICAtICdqZWxseWZpbi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnamVsbHlmaW4tdHZzaG93czovZGF0YS90dnNob3dzJwogICAgICAtICdqZWxseWZpbi1tb3ZpZXM6L2RhdGEvbW92aWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwOTYnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46MTAuMTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0pFTExZRklOXzgwOTYKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICAgIC0gSkVMTFlGSU5fUHVibGlzaGVkU2VydmVyVXJsPSRTRVJWSUNFX1VSTF9KRUxMWUZJTgogICAgdm9sdW1lczoKICAgICAgLSAnamVsbHlmaW4tY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ2plbGx5ZmluLXR2c2hvd3M6L2RhdGEvdHZzaG93cycKICAgICAgLSAnamVsbHlmaW4tbW92aWVzOi9kYXRhL21vdmllcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDk2JwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", "tags": [ "media", "server", @@ -2755,7 +2803,7 @@ "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", - "compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9NRUFMSUVfOTAwMAogICAgICAtICdBTExPV19TSUdOVVA9JHtBTExPV19TSUdOVVA6LXRydWV9JwogICAgICAtICdQVUlEPSR7UFVJRDotMTAwMH0nCiAgICAgIC0gJ1BHSUQ9JHtQR0lEOi0xMDAwfScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gJ01BWF9XT1JLRVJTPSR7TUFYX1dPUktFUlM6LTF9JwogICAgICAtICdXRUJfQ09OQ1VSUkVOQ1k9JHtXRUJfQ09OQ1VSUkVOQ1k6LTF9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVhbGllX2RhdGE6L2FwcC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvOTAwMCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTozLjE3LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9NRUFMSUVfOTAwMAogICAgICAtICdBTExPV19TSUdOVVA9JHtBTExPV19TSUdOVVA6LXRydWV9JwogICAgICAtICdQVUlEPSR7UFVJRDotMTAwMH0nCiAgICAgIC0gJ1BHSUQ9JHtQR0lEOi0xMDAwfScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gJ01BWF9XT1JLRVJTPSR7TUFYX1dPUktFUlM6LTF9JwogICAgICAtICdXRUJfQ09OQ1VSUkVOQ1k9JHtXRUJfQ09OQ1VSUkVOQ1k6LTF9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVhbGllX2RhdGE6L2FwcC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvOTAwMCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", "tags": [ "recipe manager", "meal planner", @@ -3452,6 +3500,27 @@ "minversion": "0.0.0", "port": "8080" }, + "openobserve": { + "documentation": "https://openobserve.ai/docs/?utm_source=coolify.io", + "slogan": "Cloud-native observability platform for logs, metrics, traces, RUM, errors and session replays \u2014 a 140x cheaper alternative to Elasticsearch, Splunk and Datadog.", + "compose": "c2VydmljZXM6CiAgb3Blbm9ic2VydmU6CiAgICBpbWFnZTogJ3B1YmxpYy5lY3IuYXdzL3ppbmNsYWJzL29wZW5vYnNlcnZlOnYwLjkwLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PUEVOT0JTRVJWRV81MDgwCiAgICAgIC0gWk9fREFUQV9ESVI9L2RhdGEKICAgICAgLSAnWk9fUk9PVF9VU0VSX0VNQUlMPSR7Wk9fUk9PVF9VU0VSX0VNQUlMOi1yb290QGV4YW1wbGUuY29tfScKICAgICAgLSAnWk9fUk9PVF9VU0VSX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9PUEVOT0JTRVJWRX0nCiAgICAgIC0gJ1pPX1RFTEVNRVRSWT0ke1pPX1RFTEVNRVRSWTotZmFsc2V9JwogICAgICAtICdaT19DT09LSUVfU0VDVVJFX09OTFk9JHtaT19DT09LSUVfU0VDVVJFX09OTFk6LXRydWV9JwogICAgdm9sdW1lczoKICAgICAgLSAnb3Blbm9ic2VydmUtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvb3Blbm9ic2VydmUKICAgICAgICAtIG5vZGUKICAgICAgICAtIHN0YXR1cwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMK", + "tags": [ + "logs", + "metrics", + "traces", + "observability", + "monitoring", + "opentelemetry", + "otel", + "elasticsearch", + "splunk", + "datadog" + ], + "category": "monitoring", + "logo": "svgs/openobserve.svg", + "minversion": "0.0.0", + "port": "5080" + }, "openpanel": { "documentation": "https://openpanel.dev/docs?utm_source=coolify.io", "slogan": "Open source alternative to Mixpanel and Plausible for product analytics", @@ -4125,7 +4194,7 @@ "ryot": { "documentation": "https://github.com/ignisda/ryot?utm_source=coolify.io", "slogan": "Roll your own tracker! Ryot is a self-hosted platform for tracking various aspects of life such as media consumption, fitness activities, and more.", - "compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnY4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUllPVF84MDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzcWw6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnU0VSVkVSX0FETUlOX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUllPVH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9BbXN0ZXJkYW19JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncnlvdF9wb3N0Z3Jlc3FsX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yeW90LWRifScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Ftc3RlcmRhbX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnYxMC4zLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9SWU9UXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXNxbDo1NDMyLyR7UE9TVEdSRVNfREJ9JwogICAgICAtICdTRVJWRVJfQURNSU5fQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF82NF9SWU9UfScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Ftc3RlcmRhbX0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAwMC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyeW90X3Bvc3RncmVzcWxfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXJ5b3QtZGJ9JwogICAgICAtICdUWj0ke1RaOi1FdXJvcGUvQW1zdGVyZGFtfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "rss", "reader", diff --git a/templates/service-templates.json b/templates/service-templates.json index 206a8cd6e..b07fe0511 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -171,7 +171,7 @@ "audiobookshelf": { "documentation": "https://www.audiobookshelf.org/?utm_source=coolify.io", "slogan": "Self-hosted audiobook, ebook, and podcast server", - "compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQVVESU9CT09LU0hFTEZfODAKICAgICAgLSAnVFo9JHtUSU1FWk9ORTotQW1lcmljYS9Ub3JvbnRvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLWF1ZGlvYm9va3M6L2F1ZGlvYm9va3MnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLXBvZGNhc3RzOi9wb2RjYXN0cycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLW1ldGFkYXRhOi9tZXRhZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXF1aWV0IC0tdHJpZXM9MSAtLXRpbWVvdXQ9NSBodHRwOi8vbG9jYWxob3N0OjgwL3BpbmcgLU8gL2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=", + "compose": "c2VydmljZXM6CiAgYXVkaW9ib29rc2hlbGY6CiAgICBpbWFnZTogJ2doY3IuaW8vYWR2cGx5ci9hdWRpb2Jvb2tzaGVsZjoyLjM0LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQVVESU9CT09LU0hFTEZfODAKICAgICAgLSAnVFo9JHtUSU1FWk9ORTotQW1lcmljYS9Ub3JvbnRvfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLWF1ZGlvYm9va3M6L2F1ZGlvYm9va3MnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLXBvZGNhc3RzOi9wb2RjYXN0cycKICAgICAgLSAnYXVkaW9ib29rc2hlbGYtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ2F1ZGlvYm9va3NoZWxmLW1ldGFkYXRhOi9tZXRhZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtLXF1aWV0IC0tdHJpZXM9MSAtLXRpbWVvdXQ9NSBodHRwOi8vbG9jYWxob3N0OjgwL3BpbmcgLU8gL2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDE1cwo=", "tags": [ "audiobooks", "ebooks", @@ -654,6 +654,18 @@ "minversion": "0.0.0", "port": "8978" }, + "cloudflare-ddns": { + "documentation": "https://github.com/favonia/cloudflare-ddns?utm_source=coolify.io", + "slogan": "A small, feature-rich, and robust Cloudflare DDNS updater.", + "compose": "c2VydmljZXM6CiAgY2xvdWRmbGFyZS1kZG5zOgogICAgaW1hZ2U6ICdmYXZvbmlhL2Nsb3VkZmxhcmUtZGRuczoxLjE2LjInCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIHVzZXI6ICcxMDAwOjEwMDAnCiAgICByZWFkX29ubHk6IHRydWUKICAgIGNhcF9kcm9wOgogICAgICAtIGFsbAogICAgc2VjdXJpdHlfb3B0OgogICAgICAtICduby1uZXctcHJpdmlsZWdlczp0cnVlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMT1VERkxBUkVfQVBJX1RPS0VOPSR7Q0xPVURGTEFSRV9BUElfVE9LRU46P30nCiAgICAgIC0gJ0RPTUFJTlM9JHtET01BSU5TOj99JwogICAgICAtICdQUk9YSUVEPSR7UFJPWElFRDotZmFsc2V9JwogICAgICAtICdJUDZfUFJPVklERVI9JHtJUDZfUFJPVklERVI6LW5vbmV9Jwo=", + "tags": [ + "cloud", + "ddns" + ], + "category": "automation", + "logo": "svgs/cloudflare-ddns.svg", + "minversion": "0.0.0" + }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", "slogan": "Client for Cloudflare Tunnel, a daemon that exposes private services through the Cloudflare edge.", @@ -1149,6 +1161,23 @@ "minversion": "0.0.0", "port": "6555" }, + "emqx-enterprise": { + "documentation": "https://www.emqx.io/docs/en/latest/deploy/install-docker.html?utm_source=coolify.io", + "slogan": "Open-source MQTT broker for IoT, IIoT, and connected vehicles.", + "compose": "c2VydmljZXM6CiAgZW1xeDoKICAgIGltYWdlOiAnZW1xeC9lbXF4LWVudGVycHJpc2U6Ni4yLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRU1RWF8xODA4MwogICAgICAtICdFTVFYX0RBU0hCT0FSRF9fREVGQVVMVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRU1RWH0nCiAgICBwb3J0czoKICAgICAgLSAnMTg4MzoxODgzJwogICAgICAtICc4MDgzOjgwODMnCiAgICAgIC0gJzgwODQ6ODA4NCcKICAgICAgLSAnODg4Mzo4ODgzJwogICAgdm9sdW1lczoKICAgICAgLSAnZW1xeF9kYXRhOi9vcHQvZW1xeC9kYXRhJwogICAgICAtICdlbXF4X2xvZzovb3B0L2VtcXgvbG9nJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9vcHQvZW1xeC9iaW4vZW1xeAogICAgICAgIC0gY3RsCiAgICAgICAgLSBzdGF0dXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDMwcwo=", + "tags": [ + "mqtt", + "broker", + "iot", + "messaging", + "emqx", + "iiot" + ], + "category": "Networking", + "logo": "svgs/emqx-enterprise.svg", + "minversion": "0.0.0", + "port": "18083" + }, "ente-photos-with-s3": { "documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io", "slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.", @@ -1637,7 +1666,7 @@ "gitea-runner": { "documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io", "slogan": "Gitea Actions runner for docker", - "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC42JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", "tags": [ "gitea", "actions", @@ -1951,7 +1980,7 @@ "grocy": { "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", "slogan": "Grocy is a web-based household management and grocery list application.", - "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0dST0NZCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgdm9sdW1lczoKICAgICAgLSAnZ3JvY3ktY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6NC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JPQ1kKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdncm9jeS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", "tags": [ "groceries", "household", @@ -1992,6 +2021,25 @@ "logo": "svgs/heimdall.svg", "minversion": "0.0.0" }, + "hermes-agent-with-webui": { + "documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io", + "slogan": "Hermes Agent \u2014 autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.", + "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnNoYS0yNzNmZjVjNGE0N2FmNDQ5OWJiZTVlM2IxMTM5ZWZkMzEzOTk1NTU0JwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9IRVJNRVNXRUJVSV84Nzg3CiAgICAgIC0gSEVSTUVTX1dFQlVJX0hPU1Q9MC4wLjAuMAogICAgICAtIEhFUk1FU19XRUJVSV9QT1JUPTg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfU1RBVEVfRElSPS9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvd2VidWkKICAgICAgLSBXQU5URURfVUlEPTEwMDAKICAgICAgLSBXQU5URURfR0lEPTEwMDAKICAgICAgLSAnSEVSTUVTX1dFQlVJX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9IRVJNRVNXRUJVSX0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXJtZXMtaG9tZTovaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvaGVybWVzLWFnZW50OnJvJwogICAgICAtICdoZXJtZXMtd29ya3NwYWNlOi93b3Jrc3BhY2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODc4Ny9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", + "tags": [ + "ai", + "agent", + "llm", + "chatbot", + "hermes", + "openrouter", + "anthropic", + "openai" + ], + "category": "ai", + "logo": "svgs/hermes-agent.png", + "minversion": "0.0.0", + "port": "8787" + }, "heyform": { "documentation": "https://docs.heyform.net/open-source/self-hosting?utm_source=coolify.io", "slogan": "Allows anyone to create engaging conversational forms for surveys, questionnaires, quizzes, and polls. No coding skills required.", @@ -2176,7 +2224,7 @@ "jellyfin": { "documentation": "https://jellyfin.org?utm_source=coolify.io", "slogan": "Jellyfin is a media server for hosting and streaming your media collection.", - "compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0pFTExZRklOXzgwOTYKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICAgIC0gSkVMTFlGSU5fUHVibGlzaGVkU2VydmVyVXJsPSRTRVJWSUNFX0ZRRE5fSkVMTFlGSU4KICAgIHZvbHVtZXM6CiAgICAgIC0gJ2plbGx5ZmluLWNvbmZpZzovY29uZmlnJwogICAgICAtICdqZWxseWZpbi10dnNob3dzOi9kYXRhL3R2c2hvd3MnCiAgICAgIC0gJ2plbGx5ZmluLW1vdmllczovZGF0YS9tb3ZpZXMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA5NicKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", + "compose": "c2VydmljZXM6CiAgamVsbHlmaW46CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvamVsbHlmaW46MTAuMTEuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9KRUxMWUZJTl84MDk2CiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgICAtIEpFTExZRklOX1B1Ymxpc2hlZFNlcnZlclVybD0kU0VSVklDRV9GUUROX0pFTExZRklOCiAgICB2b2x1bWVzOgogICAgICAtICdqZWxseWZpbi1jb25maWc6L2NvbmZpZycKICAgICAgLSAnamVsbHlmaW4tdHZzaG93czovZGF0YS90dnNob3dzJwogICAgICAtICdqZWxseWZpbi1tb3ZpZXM6L2RhdGEvbW92aWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwOTYnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", "tags": [ "media", "server", @@ -2755,7 +2803,7 @@ "mealie": { "documentation": "https://docs.mealie.io/?utm_source=coolify.io", "slogan": "A recipe manager and meal planner.", - "compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTUVBTElFXzkwMDAKICAgICAgLSAnQUxMT1dfU0lHTlVQPSR7QUxMT1dfU0lHTlVQOi10cnVlfScKICAgICAgLSAnUFVJRD0ke1BVSUQ6LTEwMDB9JwogICAgICAtICdQR0lEPSR7UEdJRDotMTAwMH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9CZXJsaW59JwogICAgICAtICdNQVhfV09SS0VSUz0ke01BWF9XT1JLRVJTOi0xfScKICAgICAgLSAnV0VCX0NPTkNVUlJFTkNZPSR7V0VCX0NPTkNVUlJFTkNZOi0xfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21lYWxpZV9kYXRhOi9hcHAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzkwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgbWVhbGllOgogICAgaW1hZ2U6ICdnaGNyLmlvL21lYWxpZS1yZWNpcGVzL21lYWxpZTozLjE3LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTUVBTElFXzkwMDAKICAgICAgLSAnQUxMT1dfU0lHTlVQPSR7QUxMT1dfU0lHTlVQOi10cnVlfScKICAgICAgLSAnUFVJRD0ke1BVSUQ6LTEwMDB9JwogICAgICAtICdQR0lEPSR7UEdJRDotMTAwMH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9CZXJsaW59JwogICAgICAtICdNQVhfV09SS0VSUz0ke01BWF9XT1JLRVJTOi0xfScKICAgICAgLSAnV0VCX0NPTkNVUlJFTkNZPSR7V0VCX0NPTkNVUlJFTkNZOi0xfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21lYWxpZV9kYXRhOi9hcHAvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzkwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1Cg==", "tags": [ "recipe manager", "meal planner", @@ -3452,6 +3500,27 @@ "minversion": "0.0.0", "port": "8080" }, + "openobserve": { + "documentation": "https://openobserve.ai/docs/?utm_source=coolify.io", + "slogan": "Cloud-native observability platform for logs, metrics, traces, RUM, errors and session replays \u2014 a 140x cheaper alternative to Elasticsearch, Splunk and Datadog.", + "compose": "c2VydmljZXM6CiAgb3Blbm9ic2VydmU6CiAgICBpbWFnZTogJ3B1YmxpYy5lY3IuYXdzL3ppbmNsYWJzL29wZW5vYnNlcnZlOnYwLjkwLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1BFTk9CU0VSVkVfNTA4MAogICAgICAtIFpPX0RBVEFfRElSPS9kYXRhCiAgICAgIC0gJ1pPX1JPT1RfVVNFUl9FTUFJTD0ke1pPX1JPT1RfVVNFUl9FTUFJTDotcm9vdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ1pPX1JPT1RfVVNFUl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfT1BFTk9CU0VSVkV9JwogICAgICAtICdaT19URUxFTUVUUlk9JHtaT19URUxFTUVUUlk6LWZhbHNlfScKICAgICAgLSAnWk9fQ09PS0lFX1NFQ1VSRV9PTkxZPSR7Wk9fQ09PS0lFX1NFQ1VSRV9PTkxZOi10cnVlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ29wZW5vYnNlcnZlLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL29wZW5vYnNlcnZlCiAgICAgICAgLSBub2RlCiAgICAgICAgLSBzdGF0dXMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCg==", + "tags": [ + "logs", + "metrics", + "traces", + "observability", + "monitoring", + "opentelemetry", + "otel", + "elasticsearch", + "splunk", + "datadog" + ], + "category": "monitoring", + "logo": "svgs/openobserve.svg", + "minversion": "0.0.0", + "port": "5080" + }, "openpanel": { "documentation": "https://openpanel.dev/docs?utm_source=coolify.io", "slogan": "Open source alternative to Mixpanel and Plausible for product analytics", @@ -4125,7 +4194,7 @@ "ryot": { "documentation": "https://github.com/ignisda/ryot?utm_source=coolify.io", "slogan": "Roll your own tracker! Ryot is a self-hosted platform for tracking various aspects of life such as media consumption, fitness activities, and more.", - "compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnY4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1JZT1RfODAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlc3FsOjU0MzIvJHtQT1NUR1JFU19EQn0nCiAgICAgIC0gJ1NFUlZFUl9BRE1JTl9BQ0NFU1NfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JZT1R9JwogICAgICAtICdUWj0ke1RaOi1FdXJvcGUvQW1zdGVyZGFtfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3J5b3RfcG9zdGdyZXNxbF9kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcnlvdC1kYn0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9BbXN0ZXJkYW19JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcnlvdDoKICAgIGltYWdlOiAnaWduaXNkYS9yeW90OnYxMC4zLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUllPVF84MDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzcWw6NTQzMi8ke1BPU1RHUkVTX0RCfScKICAgICAgLSAnU0VSVkVSX0FETUlOX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUllPVH0nCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9BbXN0ZXJkYW19JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncnlvdF9wb3N0Z3Jlc3FsX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1yeW90LWRifScKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Ftc3RlcmRhbX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "rss", "reader", diff --git a/tests/Feature/SyncBunnyTest.php b/tests/Feature/SyncBunnyTest.php new file mode 100644 index 000000000..8e2b892c6 --- /dev/null +++ b/tests/Feature/SyncBunnyTest.php @@ -0,0 +1,68 @@ + Http::response([], 200), + ]); + + $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid(); + $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log'; + + mkdir($binDir, 0755, true); + + createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH' +#!/bin/sh +printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" +if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then + mkdir -p "$4/json/nightly" + printf '{}' > "$4/json/releases.json" + printf '{}' > "$4/json/nightly/versions.json" +fi +exit 0 +SH); + + createFakeSyncBunnyBinary($binDir, 'git', <<<'SH' +#!/bin/sh +printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" +if [ "$1" = "status" ]; then + printf 'M %s\n' "$3" +fi +exit 0 +SH); + + $originalPath = getenv('PATH') ?: ''; + putenv("PATH={$binDir}:{$originalPath}"); + putenv("SYNC_BUNNY_TEST_LOG={$logFile}"); + + try { + $this->artisan("sync:bunny {$option} --nightly") + ->expectsConfirmation($confirmation, 'yes') + ->assertExitCode(0); + } finally { + putenv("PATH={$originalPath}"); + putenv('SYNC_BUNNY_TEST_LOG'); + } + + $log = file_get_contents($logFile); + + expect($log) + ->toContain('json/nightly/versions.json') + ->not->toContain('json/versions-nightly.json'); + + if ($syncsReleases) { + expect($log) + ->toContain('json/nightly/releases.json') + ->not->toContain('git add json/releases.json'); + } +})->with([ + 'release sync with releases' => ['--release', 'Are you sure you want to proceed?', true], + 'versions-only github sync' => ['--github-versions', 'Are you sure you want to sync versions.json via GitHub PR?', false], +]); From 8a40c4e348dd87c472c218f0592b1486a09ecb77 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 26 May 2026 11:51:38 +0200 Subject: [PATCH 33/84] chore(sync-bunny): remove GitHub release sync paths Drop the unused GitHub release and version sync options from sync:bunny, leaving the command focused on BunnyCDN template, release, and nightly syncs. Update the nightly test to assert it does not invoke gh or git. --- app/Console/Commands/SyncBunny.php | 781 +---------------------------- tests/Feature/SyncBunnyTest.php | 60 +-- 2 files changed, 27 insertions(+), 814 deletions(-) diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index 50bdfaf1e..d6d77f22e 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -16,7 +16,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}'; + protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; /** * The console command description. @@ -25,651 +25,6 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; - /** - * Fetch GitHub releases and sync to GitHub repository - */ - private function syncReleasesToGitHubRepo(): bool - { - $this->info('Fetching releases from GitHub...'); - try { - $response = Http::timeout(30) - ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ - 'per_page' => 30, // Fetch more releases for better changelog - ]); - - if (! $response->successful()) { - $this->error('Failed to fetch releases from GitHub: '.$response->status()); - - return false; - } - - $releases = $response->json(); - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp; - $branchName = 'update-releases-'.$timestamp; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Write releases.json - $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/json/releases.json"; - $releasesDir = dirname($releasesPath); - - // Ensure directory exists - if (! is_dir($releasesDir)) { - $this->info("Creating directory: $releasesDir"); - if (! mkdir($releasesDir, 0755, true)) { - $this->error("Failed to create directory: $releasesDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $bytesWritten = file_put_contents($releasesPath, $jsonContent); - - if ($bytesWritten === false) { - $this->error("Failed to write releases.json to: $releasesPath"); - $this->error('Possible reasons: permission denied or disk full.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Stage and commit - $this->info('Committing changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('Releases are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - $commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Create pull request - $this->info('Creating pull request...'); - $prTitle = 'Update releases.json - '.date('Y-m-d H:i:s'); - $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API'; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - $output = []; - exec($prCommand, $output, $returnCode); - - // Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR Output: '.implode("\n", $output)); - } - $this->info('Total releases synced: '.count($releases)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing releases: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync both releases.json and versions.json to GitHub repository in one PR - */ - private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool - { - $this->info('Syncing releases.json and versions.json to GitHub repository...'); - try { - // 1. Fetch releases from GitHub API - $this->info('Fetching releases from GitHub API...'); - $response = Http::timeout(30) - ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ - 'per_page' => 30, - ]); - - if (! $response->successful()) { - $this->error('Failed to fetch releases from GitHub: '.$response->status()); - - return false; - } - - $releases = $response->json(); - - // 2. Read versions.json - if (! file_exists($versionsLocation)) { - $this->error("versions.json not found at: $versionsLocation"); - - return false; - } - - $file = file_get_contents($versionsLocation); - $versionsJson = json_decode($file, true); - $actualVersion = data_get($versionsJson, 'coolify.v4.version'); - - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp; - $branchName = 'update-releases-and-versions-'.$timestamp; - $versionsTargetPath = $nightly ? 'json/nightly/versions.json' : 'json/versions.json'; - $releasesTargetPath = $nightly ? 'json/nightly/releases.json' : 'json/releases.json'; - - // 3. Clone the repository - $this->info('Cloning coolify-cdn repository...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // 4. Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 5. Write releases.json - $this->info('Writing releases.json...'); - $releasesPath = "$tmpDir/$releasesTargetPath"; - $releasesDir = dirname($releasesPath); - - if (! is_dir($releasesDir)) { - if (! mkdir($releasesDir, 0755, true)) { - $this->error("Failed to create directory: $releasesDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if (file_put_contents($releasesPath, $releasesJsonContent) === false) { - $this->error("Failed to write releases.json to: $releasesPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 6. Write versions.json - $this->info('Writing versions.json...'); - $versionsPath = "$tmpDir/$versionsTargetPath"; - $versionsDir = dirname($versionsPath); - - if (! is_dir($versionsDir)) { - if (! mkdir($versionsDir, 0755, true)) { - $this->error("Failed to create directory: $versionsDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if (file_put_contents($versionsPath, $versionsJsonContent) === false) { - $this->error("Failed to write versions.json to: $versionsPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 7. Stage both files - $this->info('Staging changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($releasesTargetPath).' '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 8. Check for changes - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('Both files are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // 9. Commit changes - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 10. Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // 11. Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion"; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - $output = []; - exec($prCommand, $output, $returnCode); - - // 12. Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR URL: '.implode("\n", $output)); - } - $this->info("Version synced: $actualVersion"); - $this->info('Total releases synced: '.count($releases)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing to GitHub: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync install.sh, docker-compose, and env files to GitHub repository via PR - */ - private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool - { - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $this->info("Syncing $envLabel files to GitHub repository..."); - try { - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp; - $branchName = 'update-files-'.$timestamp; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Copy each file to its target path in the CDN repo - $copiedFiles = []; - foreach ($files as $sourceFile => $targetPath) { - if (! file_exists($sourceFile)) { - $this->warn("Source file not found, skipping: $sourceFile"); - - continue; - } - - $destPath = "$tmpDir/$targetPath"; - $destDir = dirname($destPath); - - if (! is_dir($destDir)) { - if (! mkdir($destDir, 0755, true)) { - $this->error("Failed to create directory: $destDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - if (copy($sourceFile, $destPath) === false) { - $this->error("Failed to copy $sourceFile to $destPath"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $copiedFiles[] = $targetPath; - $this->info("Copied: $targetPath"); - } - - if (empty($copiedFiles)) { - $this->warn('No files were copied. Nothing to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // Stage all copied files - $this->info('Staging changes...'); - $output = []; - $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1'; - exec($stageCmd, $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Check for changes - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('All files are already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - // Commit changes - $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s'); - $fileList = implode("\n- ", $copiedFiles); - $prBody = "Automated update of $envLabel files:\n- $fileList"; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - $output = []; - exec($prCommand, $output, $returnCode); - - // Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR URL: '.implode("\n", $output)); - } - $this->info('Files synced: '.count($copiedFiles)); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing files to GitHub: '.$e->getMessage()); - - return false; - } - } - - /** - * Sync versions.json to GitHub repository via PR - */ - private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool - { - $this->info('Syncing versions.json to GitHub repository...'); - try { - if (! file_exists($versionsLocation)) { - $this->error("versions.json not found at: $versionsLocation"); - - return false; - } - - $file = file_get_contents($versionsLocation); - $json = json_decode($file, true); - $actualVersion = data_get($json, 'coolify.v4.version'); - - $timestamp = time(); - $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp; - $branchName = 'update-versions-'.$timestamp; - $targetPath = $nightly ? 'json/nightly/versions.json' : 'json/versions.json'; - - // Clone the repository - $this->info('Cloning coolify-cdn repository...'); - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to clone repository: '.implode("\n", $output)); - - return false; - } - - // Create feature branch - $this->info('Creating feature branch...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to create branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Write versions.json - $this->info('Writing versions.json...'); - $versionsPath = "$tmpDir/$targetPath"; - $versionsDir = dirname($versionsPath); - - // Ensure directory exists - if (! is_dir($versionsDir)) { - $this->info("Creating directory: $versionsDir"); - if (! mkdir($versionsDir, 0755, true)) { - $this->error("Failed to create directory: $versionsDir"); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - } - - $jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $bytesWritten = file_put_contents($versionsPath, $jsonContent); - - if ($bytesWritten === false) { - $this->error("Failed to write versions.json to: $versionsPath"); - $this->error('Possible reasons: permission denied or disk full.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Stage and commit - $this->info('Committing changes...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to stage changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - $this->info('Checking for changes...'); - $statusOutput = []; - exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to check repository status: '.implode("\n", $statusOutput)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - if (empty(array_filter($statusOutput))) { - $this->info('versions.json is already up to date. No changes to commit.'); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return true; - } - - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to commit changes: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Push to remote - $this->info('Pushing branch to remote...'); - $output = []; - exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode); - if ($returnCode !== 0) { - $this->error('Failed to push branch: '.implode("\n", $output)); - exec('rm -rf '.escapeshellarg($tmpDir)); - - return false; - } - - // Create pull request - $this->info('Creating pull request...'); - $prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s'); - $prBody = "Automated update of $envLabel versions.json to version $actualVersion"; - $output = []; - $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1'; - exec($prCommand, $output, $returnCode); - - // Clean up - exec('rm -rf '.escapeshellarg($tmpDir)); - - if ($returnCode !== 0) { - $this->error('Failed to create PR: '.implode("\n", $output)); - - return false; - } - - $this->info('Pull request created successfully!'); - if (! empty($output)) { - $this->info('PR URL: '.implode("\n", $output)); - } - $this->info("Version synced: $actualVersion"); - - return true; - } catch (\Throwable $e) { - $this->error('Error syncing versions.json: '.$e->getMessage()); - - return false; - } - } - /** * Execute the console command. */ @@ -678,8 +33,6 @@ class SyncBunny extends Command $that = $this; $only_template = $this->option('templates'); $only_version = $this->option('release'); - $only_github_releases = $this->option('github-releases'); - $only_github_versions = $this->option('github-versions'); $nightly = $this->option('nightly'); $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; @@ -737,30 +90,11 @@ class SyncBunny extends Command $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) { + if (! $only_template && ! $only_version) { $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn."); + $this->info("About to sync $envLabel files to BunnyCDN."); $this->newLine(); - // Build file mapping for diff - if ($nightly) { - $fileMapping = [ - $compose_file_location => 'docker/nightly/docker-compose.yml', - $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', - $production_env_location => 'environment/nightly/.env.production', - $upgrade_script_location => 'scripts/nightly/upgrade.sh', - $install_script_location => 'scripts/nightly/install.sh', - ]; - } else { - $fileMapping = [ - $compose_file_location => 'docker/docker-compose.yml', - $compose_file_prod_location => 'docker/docker-compose.prod.yml', - $production_env_location => 'environment/.env.production', - $upgrade_script_location => 'scripts/upgrade.sh', - $install_script_location => 'scripts/install.sh', - ]; - } - // BunnyCDN file mapping (local file => CDN URL path) $bunnyFileMapping = [ $compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file", @@ -813,44 +147,6 @@ class SyncBunny extends Command } } - // Diff against GitHub coolify-cdn repo - $this->newLine(); - $this->info('Fetching coolify-cdn repo to compare...'); - $output = []; - exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode); - - if ($returnCode === 0) { - foreach ($fileMapping as $localFile => $cdnPath) { - $remotePath = "$diffTmpDir/repo/$cdnPath"; - if (! file_exists($localFile)) { - continue; - } - if (! file_exists($remotePath)) { - $this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)"); - $hasChanges = true; - - continue; - } - - $diffOutput = []; - exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode); - if ($diffCode !== 0) { - $hasChanges = true; - $this->newLine(); - $this->info("--- GitHub: $cdnPath"); - $this->info("+++ Local: $cdnPath"); - foreach ($diffOutput as $line) { - if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { - continue; - } - $this->line($line); - } - } - } - } else { - $this->warn('Could not fetch coolify-cdn repo for diff.'); - } - exec('rm -rf '.escapeshellarg($diffTmpDir)); if (! $hasChanges) { @@ -882,9 +178,9 @@ class SyncBunny extends Command return; } elseif ($only_version) { if ($nightly) { - $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.'); + $this->info('About to sync NIGHTLY versions.json to BunnyCDN.'); } else { - $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.'); + $this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); } $file = file_get_contents($versions_location); $json = json_decode($file, true); @@ -892,8 +188,7 @@ class SyncBunny extends Command $this->info("Version: {$actual_version}"); $this->info('This will:'); - $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)'); - $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json'); + $this->info(' 1. Sync versions.json to BunnyCDN'); $this->newLine(); $confirmed = confirm('Are you sure you want to proceed?'); @@ -901,8 +196,7 @@ class SyncBunny extends Command return; } - // 1. Sync versions.json to BunnyCDN (deprecated but still needed) - $this->info('Step 1/2: Syncing versions.json to BunnyCDN...'); + $this->info('Syncing versions.json to BunnyCDN...'); Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), @@ -910,46 +204,8 @@ class SyncBunny extends Command $this->info('✓ versions.json uploaded & purged to BunnyCDN'); $this->newLine(); - // 2. Create GitHub PR with both releases.json and versions.json - $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...'); - $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly); - if ($githubSuccess) { - $this->info('✓ GitHub PR created successfully with both files'); - } else { - $this->error('✗ Failed to create GitHub PR'); - } - $this->newLine(); - $this->info('=== Summary ==='); $this->info('BunnyCDN sync: ✓ Complete'); - $this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed')); - - return; - } elseif ($only_github_releases) { - $this->info('About to sync GitHub releases to GitHub repository.'); - $confirmed = confirm('Are you sure you want to sync GitHub releases?'); - if (! $confirmed) { - return; - } - - // Sync releases to GitHub repository - $this->syncReleasesToGitHubRepo(); - - return; - } elseif ($only_github_versions) { - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $file = file_get_contents($versions_location); - $json = json_decode($file, true); - $actual_version = data_get($json, 'coolify.v4.version'); - - $this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository."); - $confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?'); - if (! $confirmed) { - return; - } - - // Sync versions.json to GitHub repository - $this->syncVersionsToGitHubRepo($versions_location, $nightly); return; } @@ -971,31 +227,8 @@ class SyncBunny extends Command $this->info('All files uploaded & purged to BunnyCDN.'); $this->newLine(); - // Sync files to GitHub CDN repository via PR - $this->info('Creating GitHub PR for coolify-cdn repository...'); - if ($nightly) { - $files = [ - $compose_file_location => 'docker/nightly/docker-compose.yml', - $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml', - $production_env_location => 'environment/nightly/.env.production', - $upgrade_script_location => 'scripts/nightly/upgrade.sh', - $install_script_location => 'scripts/nightly/install.sh', - ]; - } else { - $files = [ - $compose_file_location => 'docker/docker-compose.yml', - $compose_file_prod_location => 'docker/docker-compose.prod.yml', - $production_env_location => 'environment/.env.production', - $upgrade_script_location => 'scripts/upgrade.sh', - $install_script_location => 'scripts/install.sh', - ]; - } - - $githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly); - $this->newLine(); $this->info('=== Summary ==='); $this->info('BunnyCDN sync: Complete'); - $this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed')); } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); } diff --git a/tests/Feature/SyncBunnyTest.php b/tests/Feature/SyncBunnyTest.php index 8e2b892c6..841bb5b5f 100644 --- a/tests/Feature/SyncBunnyTest.php +++ b/tests/Feature/SyncBunnyTest.php @@ -2,67 +2,47 @@ use Illuminate\Support\Facades\Http; -function createFakeSyncBunnyBinary(string $binDir, string $name, string $contents): void +function createSyncBunnyFailingBinary(string $binDir, string $name): void { - file_put_contents("{$binDir}/{$name}", $contents); + file_put_contents("{$binDir}/{$name}", <<<'SH' +#!/bin/sh +printf '%s %s\n' "$(basename "$0")" "$*" >> "$SYNC_BUNNY_TEST_LOG" +exit 1 +SH); chmod("{$binDir}/{$name}", 0755); } -it('syncs nightly files to the nested nightly json path in the cdn repository', function (string $option, string $confirmation, bool $syncsReleases) { +it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () { Http::fake([ - 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200), + 'storage.bunnycdn.com/*' => Http::response([], 201), + 'api.bunny.net/purge*' => Http::response([], 200), ]); $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid(); $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log'; mkdir($binDir, 0755, true); - - createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH' -#!/bin/sh -printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" -if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then - mkdir -p "$4/json/nightly" - printf '{}' > "$4/json/releases.json" - printf '{}' > "$4/json/nightly/versions.json" -fi -exit 0 -SH); - - createFakeSyncBunnyBinary($binDir, 'git', <<<'SH' -#!/bin/sh -printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG" -if [ "$1" = "status" ]; then - printf 'M %s\n' "$3" -fi -exit 0 -SH); + createSyncBunnyFailingBinary($binDir, 'gh'); + createSyncBunnyFailingBinary($binDir, 'git'); $originalPath = getenv('PATH') ?: ''; putenv("PATH={$binDir}:{$originalPath}"); putenv("SYNC_BUNNY_TEST_LOG={$logFile}"); try { - $this->artisan("sync:bunny {$option} --nightly") - ->expectsConfirmation($confirmation, 'yes') + $this->artisan('sync:bunny --release --nightly') + ->expectsConfirmation('Are you sure you want to proceed?', 'yes') + ->expectsOutputToContain('BunnyCDN sync: ✓ Complete') + ->doesntExpectOutputToContain('GitHub PR') ->assertExitCode(0); } finally { putenv("PATH={$originalPath}"); putenv('SYNC_BUNNY_TEST_LOG'); } - $log = file_get_contents($logFile); + expect(file_exists($logFile))->toBeFalse(); - expect($log) - ->toContain('json/nightly/versions.json') - ->not->toContain('json/versions-nightly.json'); - - if ($syncsReleases) { - expect($log) - ->toContain('json/nightly/releases.json') - ->not->toContain('git add json/releases.json'); - } -})->with([ - 'release sync with releases' => ['--release', 'Are you sure you want to proceed?', true], - 'versions-only github sync' => ['--github-versions', 'Are you sure you want to sync versions.json via GitHub PR?', false], -]); + Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json'); + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge') + && $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json'); +}); From a22a0c027d80774f9d51465613fa0706ceda1f7b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 26 May 2026 12:03:30 +0200 Subject: [PATCH 34/84] fix(navbar): align upgrade item with collapsed menu Keep the upgrade action visible while collapsed and apply shared menu icon and label classes so its layout matches other navbar items. Also remove extra logout button spacing. --- resources/views/components/navbar.blade.php | 4 ++-- resources/views/livewire/upgrade.blade.php | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 3b21a81d5..433102dcb 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -368,7 +368,7 @@
@if (isInstanceAdmin() && !isCloud()) @persist('upgrade') -
  • +
  • @endpersist @@ -420,7 +420,7 @@
  • @csrf - -