From aea0c72879d0417c459e6e8bbd457b8ee428260a Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Mon, 9 Feb 2026 22:53:20 +1100 Subject: [PATCH 1/4] Potential fix for code scanning alert no. 4: Server-side request forgery Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- web/actions/projects.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 1f5592d..19f52a8 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -40,6 +40,18 @@ import cronstrue from "cronstrue"; import { startMigration } from "./migrations"; import { inngest } from "@/lib/inngest/client"; +function isValidImageReferencePart(reference: string): boolean { + // Allow only characters that are valid in Docker tags/digests and avoid path traversal. + // Tags: letters, digits, underscores, periods and dashes; Digests: "algorithm:hex". + // This regex is intentionally conservative. + const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; + const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/; + + return reference === "latest" || + tagPattern.test(reference) || + digestPattern.test(reference); +} + function parseImageReference(image: string): { registry: string; namespace: string; @@ -96,6 +108,13 @@ export async function validateDockerImage( parseImageReference(image); const reference = digest || tag || "latest"; + if (!isValidImageReferencePart(reference)) { + return { + valid: false, + error: "Invalid image tag or digest", + }; + } + if (registry === "docker.io") { const repoPath = namespace === "library" ? repository : `${namespace}/${repository}`; From c52d63238453dc726803edfce0ec2cab8c3cb481 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Tue, 10 Feb 2026 08:03:07 +1100 Subject: [PATCH 2/4] Update workflow --- .github/workflows/control-plane-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/control-plane-release.yml b/.github/workflows/control-plane-release.yml index 376420b..9477ded 100644 --- a/.github/workflows/control-plane-release.yml +++ b/.github/workflows/control-plane-release.yml @@ -3,7 +3,7 @@ name: Build and Push Images on: push: branches: - - main + - release paths: - "web/**" - "registry/**" From 26575cb696641d49d4cdb7495f59860c471fe62a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:04:23 +0000 Subject: [PATCH 3/4] Bump axios from 1.13.4 to 1.13.5 in /web Bumps [axios](https://github.com/axios/axios) from 1.13.4 to 1.13.5. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.13.4...v1.13.5) --- updated-dependencies: - dependency-name: axios dependency-version: 1.13.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- web/pnpm-lock.yaml | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 3f9bf47..c61bc23 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -521,28 +521,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.3.10': resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.10': resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.3.10': resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.3.10': resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==} @@ -1163,105 +1159,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1394,28 +1374,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.1.5': resolution: {integrity: sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.1.5': resolution: {integrity: sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.1.5': resolution: {integrity: sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.1.5': resolution: {integrity: sha512-LZli0anutkIllMtTAWZlDqdfvjWX/ch8AFK5WgkNTvaqwlouiD1oHM+WW8RXMiL0+vAkAJyAGEzPPjO+hnrSNQ==} @@ -2637,28 +2613,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2871,49 +2843,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4475,28 +4439,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} From 4d699c6998d797c91b5e53f27499b6b0074bc065 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Tue, 10 Feb 2026 18:36:36 +1100 Subject: [PATCH 4/4] Improve deployment updates --- web/app/(auth)/login/page.tsx | 99 ---- web/app/(auth)/register/page.tsx | 2 +- .../[serviceId]/configuration/page.tsx | 28 +- .../[env]/services/[serviceId]/page.tsx | 231 +++++---- .../[serviceId]/rollouts/[rolloutId]/page.tsx | 84 ++++ web/app/(dashboard)/layout-client.tsx | 4 +- .../api/rollouts/[rolloutId]/logs/route.ts | 28 ++ web/app/api/services/[id]/rollouts/route.ts | 20 + web/app/page.tsx | 440 +++++------------- web/components/logs/log-viewer.tsx | 33 +- .../service/details/deployment-canvas.tsx | 10 +- ...status-bar.tsx => deployment-progress.tsx} | 301 +++++------- .../details/pending-changes-banner.tsx | 124 +++++ .../service/details/rollout-details.tsx | 156 +++++++ .../service/details/rollout-history.tsx | 191 ++++++++ .../service/service-layout-client.tsx | 16 +- web/components/ui/floating-bar.tsx | 33 -- web/lib/agent-status.ts | 8 + .../inngest/functions/on-deployment-failed.ts | 3 + web/lib/inngest/functions/rollout-workflow.ts | 37 +- web/lib/victoria-logs.ts | 79 +++- 21 files changed, 1135 insertions(+), 792 deletions(-) delete mode 100644 web/app/(auth)/login/page.tsx create mode 100644 web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx create mode 100644 web/app/api/rollouts/[rolloutId]/logs/route.ts create mode 100644 web/app/api/services/[id]/rollouts/route.ts rename web/components/service/details/{deployment-status-bar.tsx => deployment-progress.tsx} (57%) create mode 100644 web/components/service/details/pending-changes-banner.tsx create mode 100644 web/components/service/details/rollout-details.tsx create mode 100644 web/components/service/details/rollout-history.tsx delete mode 100644 web/components/ui/floating-bar.tsx diff --git a/web/app/(auth)/login/page.tsx b/web/app/(auth)/login/page.tsx deleted file mode 100644 index 5d2f874..0000000 --- a/web/app/(auth)/login/page.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { signIn } from "@/lib/auth-client"; - -export default function LoginPage() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(""); - setLoading(true); - - const { error } = await signIn.email({ - email, - password, - }); - - setLoading(false); - - if (error) { - setError(error.message || "Failed to sign in"); - return; - } - - router.push("/dashboard"); - } - - return ( -
- - - Sign In - - Enter your credentials to access your account - - -
- - {error && ( -
- {error} -
- )} -
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
-
- - -

- Don't have an account?{" "} - - Sign up - -

-
-
-
-
- ); -} diff --git a/web/app/(auth)/register/page.tsx b/web/app/(auth)/register/page.tsx index b42f812..097d904 100644 --- a/web/app/(auth)/register/page.tsx +++ b/web/app/(auth)/register/page.tsx @@ -101,7 +101,7 @@ export default function RegisterPage() {

Already have an account?{" "} - + Sign in

diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx index f69f472..0cd418e 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { useSWRConfig } from "swr"; import { deleteService } from "@/actions/projects"; import { useService } from "@/components/service/service-layout-client"; @@ -36,6 +37,11 @@ export default function ConfigurationPage() { const { service, projectSlug, envName, proxyDomain, onUpdate } = useService(); const [isDeleting, setIsDeleting] = useState(false); + const handleConfigSave = useCallback(() => { + onUpdate(); + toast.info("Changes saved. Deploy to apply them."); + }, [onUpdate]); + const handleDelete = async () => { setIsDeleting(true); try { @@ -49,29 +55,29 @@ export default function ConfigurationPage() { return (
- + - + - + - + - + - + - + - + - +

Danger Zone

diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx index c97b9b1..e9d674f 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx @@ -19,6 +19,12 @@ import { } from "@/actions/projects"; import { useService } from "@/components/service/service-layout-client"; import { DeploymentCanvas } from "@/components/service/details/deployment-canvas"; +import { + DeploymentProgress, + getBarState, +} from "@/components/service/details/deployment-progress"; +import { PendingChangesBanner } from "@/components/service/details/pending-changes-banner"; +import { RolloutHistory } from "@/components/service/details/rollout-history"; import { Button } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; import { @@ -42,11 +48,14 @@ import { type ConfirmAction = "redeploy" | "stop" | "delete" | null; -export default function ArchitecturePage() { - const { service, onUpdate } = useService(); +export default function DeploymentsPage() { + const { service, pendingChanges, projectSlug, envName, onUpdate } = + useService(); const [isLoading, setIsLoading] = useState(null); const [confirmAction, setConfirmAction] = useState(null); + const barState = getBarState(service, pendingChanges); + const handleAction = async ( actionName: string, action: () => Promise, @@ -123,114 +132,136 @@ export default function ArchitecturePage() { (service.configuredReplicas || []).length > 0; return ( -
- {service.deployments.length > 0 && ( -
- {hasRunningDeployments && ( - - - - - } +
+ + + + + + + 0 ? ( + hasRunningDeployments ? ( + + + + + } > - - Delete All - - - - - )} - {canStartAll && ( - - - - + + + + handleAction( + "restart", + () => restartService(service.id), + "Restart queued", + ) + } + > + + Restart + + + setConfirmAction("stop")} + className="text-orange-600 dark:text-orange-500" + > + + Stop All + + + onClick={() => setConfirmAction("delete")} + > + + Delete All + + + + + ) : canStartAll ? ( + + + + + } > - - Delete All - - - - - )} -
- )} - + +
+ + setConfirmAction("delete")} + > + + Delete All + + +
+
+ ) : undefined + ) : undefined + } + /> !open && setConfirmAction(null)} > - {confirmAction && ( + {confirmAction ? ( - )} + ) : null}
); diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx new file mode 100644 index 0000000..6ecc746 --- /dev/null +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/rollouts/[rolloutId]/page.tsx @@ -0,0 +1,84 @@ +import { and, eq } from "drizzle-orm"; +import { notFound } from "next/navigation"; +import { SetBreadcrumbs } from "@/components/core/breadcrumb-data"; +import { RolloutDetails } from "@/components/service/details/rollout-details"; +import { db } from "@/db"; +import { projects, rollouts, services } from "@/db/schema"; + +async function getRollout( + projectSlug: string, + serviceId: string, + rolloutId: string, +) { + const project = await db + .select() + .from(projects) + .where(eq(projects.slug, projectSlug)) + .then((r) => r[0]); + + if (!project) return null; + + const service = await db + .select() + .from(services) + .where(and(eq(services.id, serviceId), eq(services.projectId, project.id))) + .then((r) => r[0]); + + if (!service) return null; + + const rollout = await db + .select() + .from(rollouts) + .where(and(eq(rollouts.id, rolloutId), eq(rollouts.serviceId, serviceId))) + .then((r) => r[0]); + + if (!rollout) return null; + + return { project, service, rollout }; +} + +export default async function RolloutPage({ + params, +}: { + params: Promise<{ + slug: string; + env: string; + serviceId: string; + rolloutId: string; + }>; +}) { + const { slug, env, serviceId, rolloutId } = await params; + const data = await getRollout(slug, serviceId, rolloutId); + + if (!data) { + notFound(); + } + + return ( + <> + + + + ); +} diff --git a/web/app/(dashboard)/layout-client.tsx b/web/app/(dashboard)/layout-client.tsx index 3b2df63..a081f5d 100644 --- a/web/app/(dashboard)/layout-client.tsx +++ b/web/app/(dashboard)/layout-client.tsx @@ -117,7 +117,7 @@ function DashboardHeader({ email }: { email: string }) { signOut().then(() => router.push("/login"))} + onClick={() => signOut().then(() => router.push("/"))} className="cursor-pointer" > @@ -140,7 +140,7 @@ export function DashboardLayoutClient({ useEffect(() => { if (!isPending && !session) { - router.push("/login"); + router.push("/"); } }, [session, isPending, router]); diff --git a/web/app/api/rollouts/[rolloutId]/logs/route.ts b/web/app/api/rollouts/[rolloutId]/logs/route.ts new file mode 100644 index 0000000..68a4050 --- /dev/null +++ b/web/app/api/rollouts/[rolloutId]/logs/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isLoggingEnabled, queryLogsByRollout } from "@/lib/victoria-logs"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ rolloutId: string }> }, +) { + const { rolloutId } = await params; + + if (!isLoggingEnabled()) { + return NextResponse.json({ logs: [] }); + } + + try { + const { logs: rawLogs } = await queryLogsByRollout(rolloutId); + + const logs = rawLogs.map((log) => ({ + timestamp: log._time, + message: log._msg, + stage: log.stage, + })); + + return NextResponse.json({ logs }); + } catch (error) { + console.error("Failed to fetch rollout logs:", error); + return NextResponse.json({ logs: [] }); + } +} diff --git a/web/app/api/services/[id]/rollouts/route.ts b/web/app/api/services/[id]/rollouts/route.ts new file mode 100644 index 0000000..5b50256 --- /dev/null +++ b/web/app/api/services/[id]/rollouts/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { desc, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { rollouts } from "@/db/schema"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: serviceId } = await params; + + const rolloutsList = await db + .select() + .from(rollouts) + .where(eq(rollouts.serviceId, serviceId)) + .orderBy(desc(rollouts.createdAt)) + .limit(50); + + return NextResponse.json({ rollouts: rolloutsList }); +} diff --git a/web/app/page.tsx b/web/app/page.tsx index 00c830f..f7a776a 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,332 +1,124 @@ -import { - Book, - Box, - Clock, - FileText, - Github, - GitBranch, - Globe, - HardDrive, - Hammer, - KeyRound, - Layers, - Lock, - MapPin, - Network, - Plug, - Server, - Shield, -} from "lucide-react"; +"use client"; + +import Image from "next/image"; import Link from "next/link"; -import { buttonVariants } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { signIn, useSession } from "@/lib/auth-client"; export default function Page() { - return ( -
-
-
-

- Simple, Scalable -
- Container Deployment -

- -

- Run your containers without the headache. Fast, reliable, and you're - in control. -

- -
- - - Star on GitHub - - - - Self-Hosting Guide - -
-
- -
- - -
- -
-

Machines are Peers

-

- No master nodes, no single points of failure. Every machine - pulls its weight equally. One goes down? The others pick up the - slack. -

-
-
- - - -
- -
-

- Proxy & Worker Nodes -

-

- Separate concerns with node types. Proxy nodes handle public - traffic and TLS. Worker nodes just run containers. -

-
-
- - - -
- -
-

- Your Infrastructure, Your Way -

-

- If it runs in a container, it runs here—on your metal, cloud - VMs, or that Raspberry Pi. Scale as you grow. Your data, your - rules, no lock-in. -

-
-
- - - -
- -
-

- Stateless or Stateful -

-

- Containers come and go, that's the point. But when you need data - to stick around, volumes have you covered. -

-
-
- - - -
- -
-

Persistent Volumes

-

- Named volumes that survive container restarts. Your data stays - put. -

-
-
- - - -
- -
-

Scheduled Backups

-

- Automatic and manual backups to S3-compatible storage. Set it - and forget it. -

-
-
- - - -
- -
-

WireGuard Mesh

-

- All server-to-server traffic encrypted via WireGuard. Your - containers communicate over a private mesh network. -

-
-
- - - -
- -
-

Service Discovery

-

- Services find each other via .internal domains. No hardcoded - IPs, no service mesh complexity. Just DNS that works. -

-
-
- - - -
- -
-

Automatic HTTPS

-

- TLS certificates handled automatically. Point your domain, get - HTTPS. No manual certificate management. -

-
-
- - - -
- -
-

TCP/UDP Proxy

-

- Not everything speaks HTTP. Expose databases, game servers, - whatever you need. -

-
-
- - - -
- -
-

GeoDNS

-

- Route users to the nearest proxy. Automatic failover when things - go wrong. -

-
-
- - - -
- -
-

GitHub Auto-Deploy

-

- Push to your branch, watch it deploy. Connect your GitHub repo - and get automatic builds and deployments on every commit. -

-
-
- - - -
- -
-

Build from Source

-

- Push code, we build it. Railpack or your own Dockerfile—your - choice. -

-
-
- - - -
- -
-

- Scheduled Deployments -

-

- Cron-based deployments. Redeploy on a schedule without lifting a - finger. -

-
-
- - - -
- -
-

Multi-Environment

-

- Production, staging, dev—all in one project. Deploy the same - service differently. -

-
-
- - - -
- -
-

Private by Default

-

- Your services talk to each other privately. Nothing gets exposed - unless you say so. Public traffic only through proxy nodes. -

-
-
- - - -
- -
-

- Environment Secrets -

-

- Inject secrets at runtime. Never bake credentials into your - images. -

-
-
- - - -
- -
-

Logs & Monitoring

-

- Stream logs from containers, builds, and requests. All in one - place. -

-
-
-
+ const router = useRouter(); + const { data: session, isPending } = useSession(); + + useEffect(() => { + if (!isPending && session) { + router.push("/dashboard"); + } + }, [session, isPending, router]); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + const { error } = await signIn.email({ + email, + password, + }); + + setLoading(false); + + if (error) { + setError(error.message || "Failed to sign in"); + return; + } + + router.push("/dashboard"); + } + + if (isPending || session) { + return ( +
+
+ ); + } -
-
- Created by - - Arjun Komath - - · - - - GitHub - -
-
+ return ( +
+ Logo + + + Sign In + + Enter your credentials to access your account + + +
+ + {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + +

+ Don't have an account?{" "} + + Sign up + +

+
+
+
); } diff --git a/web/components/logs/log-viewer.tsx b/web/components/logs/log-viewer.tsx index 99a0f61..7519a80 100644 --- a/web/components/logs/log-viewer.tsx +++ b/web/components/logs/log-viewer.tsx @@ -70,7 +70,8 @@ type LogViewerProps = | { variant: "service-logs"; serviceId: string; servers?: Server[] } | { variant: "requests"; serviceId: string } | { variant: "build-logs"; buildId: string; isLive: boolean } - | { variant: "server-logs"; serverId: string }; + | { variant: "server-logs"; serverId: string } + | { variant: "rollout-logs"; rolloutId: string; isLive: boolean }; const LEVEL_COLORS: Record = { error: "text-red-500 bg-red-500/10", @@ -138,6 +139,8 @@ function useLogData(props: LogViewerProps, filterServerId?: string) { return `/api/builds/${props.buildId}/logs`; case "server-logs": return `/api/servers/${props.serverId}/logs?limit=500`; + case "rollout-logs": + return `/api/rollouts/${props.rolloutId}/logs`; } }, [props, filterServerId]); @@ -145,6 +148,9 @@ function useLogData(props: LogViewerProps, filterServerId?: string) { if (props.variant === "build-logs") { return props.isLive ? 2000 : 0; } + if (props.variant === "rollout-logs") { + return props.isLive ? 2000 : 0; + } if (props.variant === "server-logs") { return 5000; } @@ -789,6 +795,13 @@ export function LogViewer(props: LogViewerProps) { !entry.message.toLowerCase().includes(search.toLowerCase()) ) return false; + } else if (props.variant === "rollout-logs") { + const entry = log as BuildLogEntry; + if ( + search && + !entry.message.toLowerCase().includes(search.toLowerCase()) + ) + return false; } return true; @@ -851,6 +864,14 @@ export function LogViewer(props: LogViewerProps) { loadMoreLabel: "Load older logs", height: "h-[84vh]", }; + case "rollout-logs": + return { + searchPlaceholder: "Search logs...", + emptyMessage: "Waiting for rollout logs...", + noMatchMessage: "No logs match your search", + loadMoreLabel: "", + height: "h-[84vh]", + }; } }, [props.variant]); @@ -984,6 +1005,16 @@ export function LogViewer(props: LogViewerProps) { /> ); } + if (props.variant === "rollout-logs") { + const e = entry as BuildLogEntry; + return ( + + ); + } const e = entry as BuildLogEntry; return ( No deployments yet.

@@ -335,7 +335,7 @@ export function DeploymentCanvas({ service }: DeploymentCanvasProps) { return ( <> -
+
{hasEndpoints && (
{hasEndpoints && ( diff --git a/web/components/service/details/deployment-status-bar.tsx b/web/components/service/details/deployment-progress.tsx similarity index 57% rename from web/components/service/details/deployment-status-bar.tsx rename to web/components/service/details/deployment-progress.tsx index c2e043c..fe073c8 100644 --- a/web/components/service/details/deployment-status-bar.tsx +++ b/web/components/service/details/deployment-progress.tsx @@ -2,20 +2,12 @@ import { memo, useMemo, useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; -import { ArrowRight } from "lucide-react"; +import { Loader2, XCircle } from "lucide-react"; import { toast } from "sonner"; +import { abortRollout } from "@/actions/projects"; +import { cancelMigration } from "@/actions/migrations"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { FloatingBar } from "@/components/ui/floating-bar"; import { Spinner } from "@/components/ui/spinner"; -import { deployService, abortRollout } from "@/actions/projects"; -import { triggerBuild } from "@/actions/builds"; -import { cancelMigration } from "@/actions/migrations"; import type { ConfigChange } from "@/lib/service-config"; import type { DeploymentStatus, @@ -43,6 +35,22 @@ const MIGRATION_STAGES: Record = { failed: "Migration failed", }; +const ACTIVE_BUILD_STATUSES = [ + "pending", + "claimed", + "cloning", + "building", + "pushing", +]; + +const BUILD_STATUS_LABELS: Record = { + pending: "Queued", + claimed: "Starting", + cloning: "Cloning", + building: "Building", + pushing: "Pushing", +}; + function mapDeploymentStatusToStage(status: DeploymentStatus): string { switch (status) { case "pending": @@ -68,16 +76,10 @@ type BarState = | { mode: "ready"; hasChanges: boolean; changesCount: number } | { mode: "hidden" }; -const ACTIVE_BUILD_STATUSES = [ - "pending", - "claimed", - "cloning", - "building", - "pushing", -]; - -function getBarState(service: Service, changes: ConfigChange[]): BarState { - // Migration takes precedence over builds and deployments +export function getBarState( + service: Service, + changes: ConfigChange[], +): BarState { if (service.migrationStatus) { return { mode: "deploying", @@ -104,7 +106,6 @@ function getBarState(service: Service, changes: ConfigChange[]): BarState { if (activeRollout) { const currentStage = activeRollout.currentStage || "deploying"; - return { mode: "deploying", stage: currentStage, @@ -128,7 +129,9 @@ function getBarState(service: Service, changes: ConfigChange[]): BarState { const maxStageIndex = Math.max( ...service.deployments .filter((d) => inProgressStatuses.includes(d.status)) - .map((d) => getStageIndex(mapDeploymentStatusToStage(d.status))), + .map((d) => + getStageIndex(mapDeploymentStatusToStage(d.status)), + ), ); return { mode: "deploying", @@ -156,81 +159,25 @@ function getBarState(service: Service, changes: ConfigChange[]): BarState { return { mode: "hidden" }; } -function PendingChangesModal({ - changes, - isOpen, - onClose, - onDeploy, - isDeploying, - canDeploy, -}: { +interface DeploymentProgressProps { + service: Service; changes: ConfigChange[]; - isOpen: boolean; - onClose: () => void; - onDeploy: () => void; - isDeploying: boolean; - canDeploy: boolean; -}) { - if (!isOpen) return null; - - return ( - !open && onClose()}> - - - Pending Changes - -
- {changes.map((change, i) => ( -
- {change.field}: -
- - {change.from} - - - {change.to} -
-
- ))} -
-
- - -
-
-
- ); + projectSlug: string; + envName: string; + onUpdate: () => void; } -export const DeploymentStatusBar = memo(function DeploymentStatusBar({ +export const DeploymentProgress = memo(function DeploymentProgress({ service, changes, projectSlug, envName, onUpdate, -}: { - service: Service; - changes: ConfigChange[]; - projectSlug: string; - envName: string; - onUpdate: () => void; -}) { +}: DeploymentProgressProps) { const router = useRouter(); - const [showModal, setShowModal] = useState(false); - const [isDeploying, setIsDeploying] = useState(false); const [isAborting, setIsAborting] = useState(false); const [isCancellingMigration, setIsCancellingMigration] = useState(false); + const barState = useMemo( () => getBarState(service, changes), [service, changes], @@ -266,34 +213,6 @@ export const DeploymentStatusBar = memo(function DeploymentStatusBar({ } }, [barState.mode, service.rollouts]); - const totalReplicas = service.autoPlace - ? service.replicas - : service.configuredReplicas.reduce((sum, r) => sum + r.count, 0); - const hasNoDeployments = service.deployments.length === 0; - const isGithubWithNoDeployments = - service.sourceType === "github" && hasNoDeployments; - - const handleDeploy = async () => { - setIsDeploying(true); - try { - if (isGithubWithNoDeployments) { - await triggerBuild(service.id); - router.push( - `/dashboard/projects/${projectSlug}/${envName}/services/${service.id}/builds`, - ); - } else { - await deployService(service.id); - router.push( - `/dashboard/projects/${projectSlug}/${envName}/services/${service.id}`, - ); - } - onUpdate(); - setShowModal(false); - } finally { - setIsDeploying(false); - } - }; - const handleAbort = async () => { setIsAborting(true); try { @@ -319,44 +238,48 @@ export const DeploymentStatusBar = memo(function DeploymentStatusBar({ } }; - if (barState.mode === "hidden") { - return null; - } + const isVisible = + barState.mode === "building" || barState.mode === "deploying"; - if (barState.mode === "building") { - const statusLabels: Record = { - pending: "Queued", - claimed: "Starting", - cloning: "Cloning", - building: "Building", - pushing: "Pushing", - }; + let content: React.ReactNode = null; - return ( - +
+
+
+ +
+
+

+ Building +

+

+ {BUILD_STATUS_LABELS[barState.buildStatus] || "Building"} +

+
+
+ - } - /> + +
+
); } if (barState.mode === "deploying") { const currentStage = STAGES[barState.stageIndex]; const isMigrating = !!service.migrationStatus; + const isMigrationFailed = service.migrationStatus === "failed"; let status = currentStage?.label || "Deploying"; if (isMigrating && service.migrationStatus) { @@ -366,77 +289,61 @@ export const DeploymentStatusBar = memo(function DeploymentStatusBar({ "Migrating"; } - const isMigrationFailed = service.migrationStatus === "failed"; - - return ( - +
+
+ {isMigrationFailed ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+

+ {isMigrating ? "Migrating" : "Deploying"} +

+

+ {status} +

+
+
+ {isMigrating ? ( + + ) : ( - - ) - } - /> + + )} +
+
); } return ( - <> - - {barState.hasChanges && ( - - )} - -
- } - /> - setShowModal(false)} - onDeploy={handleDeploy} - isDeploying={isDeploying} - canDeploy={totalReplicas > 0} - /> - +
+
{content}
+
); }); diff --git a/web/components/service/details/pending-changes-banner.tsx b/web/components/service/details/pending-changes-banner.tsx new file mode 100644 index 0000000..6a3b010 --- /dev/null +++ b/web/components/service/details/pending-changes-banner.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { memo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { AlertTriangle, ArrowRight, Rocket } from "lucide-react"; +import { deployService } from "@/actions/projects"; +import { triggerBuild } from "@/actions/builds"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import type { ConfigChange } from "@/lib/service-config"; +import type { ServiceWithDetails as Service } from "@/db/types"; + +interface PendingChangesBannerProps { + service: Service; + changes: ConfigChange[]; + projectSlug: string; + envName: string; + onUpdate: () => void; + barMode: string; +} + +export const PendingChangesBanner = memo(function PendingChangesBanner({ + service, + changes, + projectSlug, + envName, + onUpdate, + barMode, +}: PendingChangesBannerProps) { + const router = useRouter(); + const [isDeploying, setIsDeploying] = useState(false); + + const totalReplicas = service.autoPlace + ? service.replicas + : service.configuredReplicas.reduce((sum, r) => sum + r.count, 0); + const hasNoDeployments = service.deployments.length === 0; + const isGithubWithNoDeployments = + service.sourceType === "github" && hasNoDeployments; + + const hasChanges = changes.length > 0; + const showBanner = + barMode === "ready" && (hasChanges || (hasNoDeployments && totalReplicas > 0)); + + const handleDeploy = async () => { + setIsDeploying(true); + try { + if (isGithubWithNoDeployments) { + await triggerBuild(service.id); + router.push( + `/dashboard/projects/${projectSlug}/${envName}/services/${service.id}/builds`, + ); + } else { + await deployService(service.id); + } + onUpdate(); + } finally { + setIsDeploying(false); + } + }; + + return ( +
+
+
+
+
+
+ +
+
+

+ {hasChanges + ? `${changes.length} pending change${changes.length !== 1 ? "s" : ""}` + : "Ready to deploy"} +

+ {hasChanges ? ( +
+ {changes.map((change, i) => ( +
+ + {change.field}: + + + {change.from} + + + {change.to} +
+ ))} +
+ ) : ( +

+ This service has no active deployments. +

+ )} +
+
+ +
+
+
+
+ ); +}); diff --git a/web/components/service/details/rollout-details.tsx b/web/components/service/details/rollout-details.tsx new file mode 100644 index 0000000..2c518f3 --- /dev/null +++ b/web/components/service/details/rollout-details.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { + ArrowLeft, + CheckCircle2, + Loader2, + RotateCcw, + XCircle, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { + Item, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { Button } from "@/components/ui/button"; +import type { Rollout, RolloutStatus, Service } from "@/db/types"; +import { formatRelativeTime } from "@/lib/date"; +import { LogViewer } from "@/components/logs/log-viewer"; + +type RolloutWithDates = Omit & { + createdAt: string | Date; + completedAt: string | Date | null; +}; + +const STATUS_CONFIG: Record< + RolloutStatus, + { + icon: typeof CheckCircle2; + color: string; + bgColor: string; + label: string; + } +> = { + in_progress: { + icon: Loader2, + color: "text-blue-500", + bgColor: "bg-blue-500/10", + label: "In Progress", + }, + completed: { + icon: CheckCircle2, + color: "text-green-500", + bgColor: "bg-green-500/10", + label: "Completed", + }, + failed: { + icon: XCircle, + color: "text-red-500", + bgColor: "bg-red-500/10", + label: "Failed", + }, + rolled_back: { + icon: RotateCcw, + color: "text-orange-500", + bgColor: "bg-orange-500/10", + label: "Rolled Back", + }, +}; + +const STAGE_LABELS: Record = { + preparing: "Preparing", + certificates: "Issuing Certificates", + deploying: "Deploying", + health_check: "Health Check", + dns_sync: "DNS Sync", + completed: "Completed", +}; + +function formatStage(stage: string | null): string { + if (!stage) return "—"; + return STAGE_LABELS[stage] || stage; +} + +function formatDuration( + start: string | Date, + end: string | Date | null, +): string { + const startDate = new Date(start); + const endDate = end ? new Date(end) : new Date(); + const diff = endDate.getTime() - startDate.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; +} + +export function RolloutDetails({ + projectSlug, + envName, + service, + rollout, +}: { + projectSlug: string; + envName: string; + service: Pick; + rollout: RolloutWithDates; +}) { + const router = useRouter(); + const config = STATUS_CONFIG[rollout.status as RolloutStatus]; + const Icon = config.icon; + const isLive = rollout.status === "in_progress"; + + return ( +
+
+ +
+
+ + + {config.label} + + {rollout.currentStage && isLive && ( + + {formatStage(rollout.currentStage)} + + )} + + Started {formatRelativeTime(rollout.createdAt)} + + + Duration: {formatDuration(rollout.createdAt, rollout.completedAt)} + +
+
+
+ +
+

Rollout Logs

+ +
+
+ ); +} diff --git a/web/components/service/details/rollout-history.tsx b/web/components/service/details/rollout-history.tsx new file mode 100644 index 0000000..ab58a76 --- /dev/null +++ b/web/components/service/details/rollout-history.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { + CheckCircle2, + Clock, + Loader2, + RotateCcw, + XCircle, +} from "lucide-react"; +import Link from "next/link"; +import useSWR from "swr"; +import { + Empty, + EmptyDescription, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, +} from "@/components/ui/item"; +import { Spinner } from "@/components/ui/spinner"; +import type { RolloutStatus } from "@/db/types"; +import { formatRelativeTime } from "@/lib/date"; +import { fetcher } from "@/lib/fetcher"; + +type RolloutListItem = { + id: string; + serviceId: string; + status: RolloutStatus; + currentStage: string | null; + createdAt: string; + completedAt: string | null; +}; + +const STATUS_CONFIG: Record< + RolloutStatus, + { + icon: typeof CheckCircle2; + color: string; + label: string; + } +> = { + in_progress: { + icon: Loader2, + color: "text-blue-500", + label: "In Progress", + }, + completed: { + icon: CheckCircle2, + color: "text-green-500", + label: "Completed", + }, + failed: { + icon: XCircle, + color: "text-red-500", + label: "Failed", + }, + rolled_back: { + icon: RotateCcw, + color: "text-orange-500", + label: "Rolled Back", + }, +}; + +function StatusBadge({ status }: { status: RolloutStatus }) { + const config = STATUS_CONFIG[status]; + const Icon = config.icon; + const isAnimated = status === "in_progress"; + + return ( + + + {config.label} + + ); +} + +const STAGE_LABELS: Record = { + preparing: "Preparing", + certificates: "Issuing Certificates", + deploying: "Deploying", + health_check: "Health Check", + dns_sync: "DNS Sync", + completed: "Completed", +}; + +function formatStage(stage: string | null): string { + if (!stage) return "Starting"; + return STAGE_LABELS[stage] || stage; +} + +function formatDuration(start: string, end: string | null): string { + const startDate = new Date(start); + const endDate = end ? new Date(end) : new Date(); + const diff = endDate.getTime() - startDate.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; +} + +export function RolloutHistory({ + serviceId, + projectSlug, + envName, + actions, +}: { + serviceId: string; + projectSlug: string; + envName: string; + actions?: React.ReactNode; +}) { + const { data, isLoading } = useSWR<{ rollouts: RolloutListItem[] }>( + `/api/services/${serviceId}/rollouts`, + fetcher, + { + refreshInterval: 10000, + }, + ); + + const rollouts = data?.rollouts || []; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Rollout History

+ {actions} +
+ + {rollouts.length === 0 ? ( + + + + + No rollouts yet + + Deploy your service to see rollout history here. + + + ) : ( + + {rollouts.map((rollout) => ( + + + + + + + {rollout.status === "in_progress" + ? `Deploying — ${formatStage(rollout.currentStage)}` + : STATUS_CONFIG[rollout.status].label} + + + + {formatRelativeTime(rollout.createdAt)} + + Duration:{" "} + {formatDuration( + rollout.createdAt, + rollout.completedAt, + )} + + + + + + ))} + + )} +
+ ); +} diff --git a/web/components/service/service-layout-client.tsx b/web/components/service/service-layout-client.tsx index 8328756..620a8b5 100644 --- a/web/components/service/service-layout-client.tsx +++ b/web/components/service/service-layout-client.tsx @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"; import { createContext, useCallback, useContext, useMemo } from "react"; import useSWR from "swr"; import type { ServiceWithDetails as Service } from "@/db/types"; +import type { ConfigChange } from "@/lib/service-config"; import { fetcher } from "@/lib/fetcher"; import { buildCurrentConfig, @@ -12,7 +13,6 @@ import { parseDeployedConfig, } from "@/lib/service-config"; import { cn } from "@/lib/utils"; -import { DeploymentStatusBar } from "./details/deployment-status-bar"; interface ServiceLayoutClientProps { serviceId: string; @@ -82,7 +82,7 @@ export function ServiceLayoutClient({ const hasPublicPorts = service?.ports?.some((p) => p.isPublic); const tabs = [ - { name: "Deployment", href: basePath }, + { name: "Deployments", href: basePath }, { name: "Configuration", href: `${basePath}/configuration` }, { name: "Logs", href: `${basePath}/logs` }, ...(hasPublicPorts @@ -152,13 +152,14 @@ export function ServiceLayoutClient({
- - ); } interface ServiceContextType { service: Service; + pendingChanges: ConfigChange[]; projectSlug: string; envName: string; proxyDomain: string | null; diff --git a/web/components/ui/floating-bar.tsx b/web/components/ui/floating-bar.tsx deleted file mode 100644 index c001db0..0000000 --- a/web/components/ui/floating-bar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { Spinner } from "@/components/ui/spinner"; - -export function FloatingBar({ - visible = true, - loading = false, - status, - action, -}: { - visible?: boolean; - loading?: boolean; - status: string; - action?: React.ReactNode; -}) { - return ( -
-
- {loading && } - {status} - {action} -
-
- ); -} diff --git a/web/lib/agent-status.ts b/web/lib/agent-status.ts index 424fff3..7431054 100644 --- a/web/lib/agent-status.ts +++ b/web/lib/agent-status.ts @@ -12,6 +12,7 @@ import { } from "@/db/schema"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import { inngest } from "@/lib/inngest/client"; +import { ingestRolloutLog } from "@/lib/victoria-logs"; type ContainerStatus = { deploymentId: string; @@ -245,6 +246,10 @@ export async function applyStatusReport( `[health:attach] deployment ${deployment.id} transitioning from ${deployment.status} to ${newStatus}`, ); + if (deployment.rolloutId) { + await ingestRolloutLog(deployment.rolloutId, deployment.serviceId, "deploying", `Deployment ${deployment.id} starting on server ${serverId}`); + } + if (!hasHealthCheck) { await db .update(deployments) @@ -308,6 +313,7 @@ export async function applyStatusReport( .where(eq(deployments.id, deployment.id)); if (deployment.rolloutId) { + await ingestRolloutLog(deployment.rolloutId, deployment.serviceId, "health_check", `Deployment ${deployment.id} is healthy`); await inngest.send({ name: "deployment/healthy", data: { @@ -347,6 +353,7 @@ export async function applyStatusReport( .where(eq(deployments.id, deployment.id)); if (deployment.rolloutId) { + await ingestRolloutLog(deployment.rolloutId, deployment.serviceId, "health_check", `Deployment ${deployment.id} failed health check`); await inngest.send({ name: "deployment/failed", data: { @@ -372,6 +379,7 @@ export async function applyStatusReport( ); for (const rollout of rolloutsInDnsSync) { + await ingestRolloutLog(rollout.id, "", "dns_sync", `DNS synced on server ${serverId}`); await inngest.send({ name: "server/dns-synced", data: { diff --git a/web/lib/inngest/functions/on-deployment-failed.ts b/web/lib/inngest/functions/on-deployment-failed.ts index 4677849..620cf17 100644 --- a/web/lib/inngest/functions/on-deployment-failed.ts +++ b/web/lib/inngest/functions/on-deployment-failed.ts @@ -3,6 +3,7 @@ import { db } from "@/db"; import { rollouts } from "@/db/schema"; import { inngest } from "../client"; import { handleRolloutFailure } from "./rollout-utils"; +import { ingestRolloutLog } from "@/lib/victoria-logs"; export const onDeploymentFailed = inngest.createFunction( { id: "on-deployment-failed" }, @@ -20,6 +21,8 @@ export const onDeploymentFailed = inngest.createFunction( if (!rollout || rollout.status !== "in_progress") return; + await ingestRolloutLog(rolloutId, serviceId, reason, `Rollout failed: ${reason}`); + await step.sendEvent("cancel-rollout", { name: "rollout/cancelled", data: { rolloutId }, diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts index 493a334..4695b4f 100644 --- a/web/lib/inngest/functions/rollout-workflow.ts +++ b/web/lib/inngest/functions/rollout-workflow.ts @@ -4,6 +4,7 @@ import { deployments, rollouts } from "@/db/schema"; import { getService } from "@/db/queries"; import { inngest } from "../client"; import { handleRolloutFailure } from "./rollout-utils"; +import { ingestRolloutLog } from "@/lib/victoria-logs"; import { calculateServicePlacements, validateServers, @@ -36,6 +37,7 @@ export const rolloutWorkflow = inngest.createFunction( .update(rollouts) .set({ status: "in_progress", currentStage: "preparing" }) .where(eq(rollouts.id, rolloutId)); + await ingestRolloutLog(rolloutId, serviceId, "preparing", "Rollout started"); }); const { placements, totalReplicas } = await step.run( @@ -45,13 +47,17 @@ export const rolloutWorkflow = inngest.createFunction( if (!service) { throw new Error("Service not found"); } - return calculateServicePlacements(service); + const result = await calculateServicePlacements(service); + await ingestRolloutLog(rolloutId, serviceId, "preparing", `Calculated placements: ${result.totalReplicas} replica(s)`); + return result; }, ); const serverIds = await step.run("validate-servers", async () => { const serverMap = await validateServers(placements); - return [...serverMap.keys()]; + const ids = [...serverMap.keys()]; + await ingestRolloutLog(rolloutId, serviceId, "preparing", `Validated ${ids.length} server(s)`); + return ids; }); const isRollingUpdate = await step.run("check-rolling-update", async () => { @@ -61,10 +67,12 @@ export const rolloutWorkflow = inngest.createFunction( if (isRollingUpdate) { await step.run("prepare-rolling-update", async () => { await prepareRollingUpdate(serviceId); + await ingestRolloutLog(rolloutId, serviceId, "preparing", "Prepared rolling update"); }); } else { await step.run("cleanup-existing", async () => { await cleanupExistingDeployments(serviceId); + await ingestRolloutLog(rolloutId, serviceId, "preparing", "Cleaned up existing deployments"); }); } @@ -74,6 +82,7 @@ export const rolloutWorkflow = inngest.createFunction( .set({ currentStage: "certificates" }) .where(eq(rollouts.id, rolloutId)); await issueCertificatesForService(serviceId); + await ingestRolloutLog(rolloutId, serviceId, "certificates", "Certificates issued"); }); const { deploymentIds } = await step.run("create-deployments", async () => { @@ -89,13 +98,17 @@ export const rolloutWorkflow = inngest.createFunction( const serverMap = await validateServers(placements); - return createDeploymentRecords(rolloutId, serviceId, { + const result = await createDeploymentRecords(rolloutId, serviceId, { service, placements, serverMap, totalReplicas, isRollingUpdate, }); + + await ingestRolloutLog(rolloutId, serviceId, "deploying", `Created ${result.deploymentIds.length} deployment(s)`); + + return result; }); await step.run("save-deployed-config", async () => { @@ -120,6 +133,7 @@ export const rolloutWorkflow = inngest.createFunction( .update(rollouts) .set({ currentStage: "health_check" }) .where(eq(rollouts.id, rolloutId)); + await ingestRolloutLog(rolloutId, serviceId, "health_check", "Waiting for health checks"); }); const healthResults = await Promise.all( @@ -135,6 +149,9 @@ export const rolloutWorkflow = inngest.createFunction( const timedOutIndex = healthResults.indexOf(null); if (timedOutIndex !== -1) { const failedDeploymentId = deploymentIds[timedOutIndex]; + await step.run("log-health-timeout", async () => { + await ingestRolloutLog(rolloutId, serviceId, "health_check", `Health check timed out for deployment ${failedDeploymentId}`); + }); await step.run("handle-health-timeout", async () => { await handleRolloutFailure( rolloutId, @@ -175,6 +192,8 @@ export const rolloutWorkflow = inngest.createFunction( eq(deployments.status, "healthy"), ), ); + + await ingestRolloutLog(rolloutId, serviceId, "dns_sync", "Routing traffic to new deployments"); }); const dnsResults = await Promise.all( @@ -187,13 +206,16 @@ export const rolloutWorkflow = inngest.createFunction( ), ); - dnsResults.forEach((result, index) => { - if (result === null) { + for (let i = 0; i < dnsResults.length; i++) { + if (dnsResults[i] === null) { console.warn( - `[rollout:${rolloutId}] DNS sync timeout for server ${serverIds[index]}`, + `[rollout:${rolloutId}] DNS sync timeout for server ${serverIds[i]}`, ); + await step.run(`log-dns-timeout-${serverIds[i]}`, async () => { + await ingestRolloutLog(rolloutId, serviceId, "dns_sync", `DNS sync timed out for server ${serverIds[i]}`); + }); } - }); + } await step.run("complete-rollout", async () => { await db @@ -204,6 +226,7 @@ export const rolloutWorkflow = inngest.createFunction( completedAt: new Date(), }) .where(eq(rollouts.id, rolloutId)); + await ingestRolloutLog(rolloutId, serviceId, "completed", "Rollout completed successfully"); }); return { status: "completed", rolloutId }; diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts index 7100531..e308d68 100644 --- a/web/lib/victoria-logs.ts +++ b/web/lib/victoria-logs.ts @@ -77,7 +77,9 @@ export async function queryLogsByService( if (logType === "http") { query += ` log_type:http`; } else if (logType === "container") { - query += ` -log_type:http`; + query += ` -log_type:http -log_type:build -log_type:rollout`; + } else { + query += ` -log_type:build -log_type:rollout`; } if (serverId) { query += ` server_id:${serverId}`; @@ -202,6 +204,81 @@ export async function queryLogsByServer( return { logs, hasMore }; } +export type RolloutLog = { + _msg: string; + _time: string; + rollout_id: string; + service_id: string; + stage: string; + log_type: "rollout"; +}; + +export async function ingestRolloutLog( + rolloutId: string, + serviceId: string, + stage: string, + message: string, +): Promise { + try { + const endpoint = getQueryEndpoint(); + if (!endpoint) return; + + const entry: RolloutLog = { + _msg: message, + _time: new Date().toISOString(), + rollout_id: rolloutId, + service_id: serviceId, + stage, + log_type: "rollout", + }; + + const url = `${endpoint.url}/insert/jsonline`; + const options = buildFetchOptions(endpoint); + + await fetch(url, { + ...options, + method: "POST", + body: JSON.stringify(entry) + "\n", + headers: { + ...((options.headers as Record) || {}), + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("Failed to ingest rollout log:", error); + } +} + +export async function queryLogsByRollout( + rolloutId: string, + limit: number = 1000, +): Promise<{ logs: RolloutLog[] }> { + const endpoint = getQueryEndpoint(); + if (!endpoint) { + throw new Error("VICTORIA_LOGS_URL is not configured"); + } + + const query = `rollout_id:${rolloutId} log_type:rollout | sort by (_time)`; + + const url = new URL(`${endpoint.url}/select/logsql/query`); + url.searchParams.set("query", query); + url.searchParams.set("limit", String(limit)); + + const response = await fetch(url.toString(), buildFetchOptions(endpoint)); + + if (!response.ok) { + throw new Error( + `Failed to query rollout logs: ${response.status} ${response.statusText}`, + ); + } + + const text = await response.text(); + const lines = text.trim().split("\n").filter(Boolean); + const logs = lines.map((line) => JSON.parse(line) as RolloutLog); + + return { logs }; +} + export async function queryLogsByBuild( buildId: string, limit: number = 1000,