From ca13d40957e93aebc45dd9f29a81f38a361b9231 Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Tue, 9 Jun 2026 10:59:39 +0200 Subject: [PATCH 1/3] feat: added admin page and update page for cluster --- app/src/lib/api/system.ts | 73 ++++++ .../settings/update-settings.svelte | 220 ++++++++++++++++++ .../lib/components/sidebar/app-sidebar.svelte | 13 +- .../lib/components/sidebar/update-card.svelte | 34 +++ .../routes/(app)/admin/settings/+page.svelte | 43 ++++ .../routes/(app)/system/update/+page.svelte | 8 + control-plane/api-gateway/src/routes/mod.rs | 4 +- .../api-gateway/src/routes/releases.rs | 131 +++++++++++ 8 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 app/src/lib/api/system.ts create mode 100644 app/src/lib/components/settings/update-settings.svelte create mode 100644 app/src/lib/components/sidebar/update-card.svelte create mode 100644 app/src/routes/(app)/admin/settings/+page.svelte create mode 100644 app/src/routes/(app)/system/update/+page.svelte create mode 100644 control-plane/api-gateway/src/routes/releases.rs diff --git a/app/src/lib/api/system.ts b/app/src/lib/api/system.ts new file mode 100644 index 0000000..e29cc03 --- /dev/null +++ b/app/src/lib/api/system.ts @@ -0,0 +1,73 @@ +const API_BASE = (import.meta.env.VITE_API_URL ?? 'http://localhost:8000') + '/api'; + +export interface UpdateStatus { + current_version: string; + desired_version: string | null; + available_flake_rev: string | null; + desired_flake_rev: string | null; + build_status: string | null; + last_result: string | null; + paused: boolean; +} + +export async function getUpdateStatus(token: string): Promise { + const res = await fetch(`${API_BASE}/system/update/status`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`update status fetch failed: ${res.status}`); + return res.json(); +} + +export async function triggerUpdate(token: string, version: string): Promise { + const res = await fetch(`${API_BASE}/system/update`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ version }), + }); + if (!res.ok) throw new Error(`trigger update failed: ${res.status}`); +} + +export async function pauseUpdate(token: string): Promise { + const res = await fetch(`${API_BASE}/system/update/pause`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`pause update failed: ${res.status}`); +} + +export async function resumeUpdate(token: string): Promise { + const res = await fetch(`${API_BASE}/system/update/resume`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`resume update failed: ${res.status}`); +} + +export interface ReleaseEntry { + version: string; + tag: string; + prerelease: boolean; + html_url: string; + name: string | null; + is_current: boolean; + is_newer: boolean; +} + +export interface ReleasesResponse { + current_version: string; + update_available: boolean; + latest_stable: string | null; + releases: ReleaseEntry[]; +} + +export async function getReleases(token: string, includePre = false): Promise { + const url = `${API_BASE}/system/releases${includePre ? '?include_pre=true' : ''}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`releases fetch failed: ${res.status}`); + return res.json(); +} diff --git a/app/src/lib/components/settings/update-settings.svelte b/app/src/lib/components/settings/update-settings.svelte new file mode 100644 index 0000000..9072551 --- /dev/null +++ b/app/src/lib/components/settings/update-settings.svelte @@ -0,0 +1,220 @@ + + +{#if error} +

{error}

+{/if} + +
+ + + Current version + + + {#if statusLoading} +

Loading...

+ {:else if status} +
+ Installed + {status.current_version} +
+ {#if status.desired_version && status.desired_version !== status.current_version} +
+ Scheduled + {status.desired_version} +
+ {/if} + {#if status.build_status} +
+ Build status + {buildStatusLabel(status.build_status)} +
+ {/if} + {#if status.last_result} +
+ Last result + {buildStatusLabel(status.last_result)} +
+ {/if} + {#if status.paused} +
+ Updates + paused +
+ {/if} + {/if} +
+ {#if status} + + {#if !status.paused} + + {:else} + + {/if} + + {/if} +
+ + + + Available releases + + + + + + {#if releasesLoading} +

Loading...

+ {:else if releases && releases.releases.length > 0} +
+ {#each releases.releases as release (release.tag)} +
+
+
+ + {release.version} + + {#if release.is_current} + current + {:else if release.is_newer} + newer + {/if} + {#if release.prerelease} + pre-release + {/if} +
+ {#if release.name} + {release.name} + {/if} +
+ {#if !release.is_current} + + {/if} +
+ {/each} +
+ {:else} +

No releases found.

+ {/if} +
+
+
diff --git a/app/src/lib/components/sidebar/app-sidebar.svelte b/app/src/lib/components/sidebar/app-sidebar.svelte index 3844484..2bf0d61 100644 --- a/app/src/lib/components/sidebar/app-sidebar.svelte +++ b/app/src/lib/components/sidebar/app-sidebar.svelte @@ -3,13 +3,20 @@ import ChartPieIcon from "@lucide/svelte/icons/chart-pie"; import MapIcon from "@lucide/svelte/icons/map"; import BookOpenIcon from "@lucide/svelte/icons/book-open"; - import { Server, Layers, ShipWheel, Container, LaptopMinimal } from "@lucide/svelte"; + import { Server, Layers, ShipWheel, Container, LaptopMinimal, Settings } from "@lucide/svelte"; import IconBucket from "./icon-bucket.svelte"; import IconDashboard from "./icon-dashboard.svelte"; import IconActivity from "./icon-activity.svelte"; import IconLogs from "./icon-logs.svelte"; const data = { + navAdmin: [ + { + title: "Settings", + url: "/admin/settings", + icon: Settings, + }, + ], navBottom: [ { title: "Monitoring", @@ -69,6 +76,7 @@ import NavMain from "./nav-main.svelte"; import NavProjects from "./nav-projects.svelte"; import NavUser from "./nav-user.svelte"; + import UpdateCard from "./update-card.svelte"; import * as Sidebar from "$lib/components/ui/sidebar/index.js"; import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js"; import type { ComponentProps } from "svelte"; @@ -108,7 +116,10 @@ + + + diff --git a/app/src/lib/components/sidebar/update-card.svelte b/app/src/lib/components/sidebar/update-card.svelte new file mode 100644 index 0000000..bbfb412 --- /dev/null +++ b/app/src/lib/components/sidebar/update-card.svelte @@ -0,0 +1,34 @@ + + +{#if latestStable} + +{/if} diff --git a/app/src/routes/(app)/admin/settings/+page.svelte b/app/src/routes/(app)/admin/settings/+page.svelte new file mode 100644 index 0000000..88017e9 --- /dev/null +++ b/app/src/routes/(app)/admin/settings/+page.svelte @@ -0,0 +1,43 @@ + + +
+ + Admin / Settings +
+ +
+

Settings

+ +
+ + +
+ +
+ {#if activeTab === "general"} +

No general settings configured.

+ {:else if activeTab === "update"} + + {/if} +
+
diff --git a/app/src/routes/(app)/system/update/+page.svelte b/app/src/routes/(app)/system/update/+page.svelte new file mode 100644 index 0000000..de42484 --- /dev/null +++ b/app/src/routes/(app)/system/update/+page.svelte @@ -0,0 +1,8 @@ + diff --git a/control-plane/api-gateway/src/routes/mod.rs b/control-plane/api-gateway/src/routes/mod.rs index b8e0cad..0ee20fb 100644 --- a/control-plane/api-gateway/src/routes/mod.rs +++ b/control-plane/api-gateway/src/routes/mod.rs @@ -18,6 +18,7 @@ pub mod events; pub mod networks; pub mod organizations; pub mod registry; +pub mod releases; pub mod ssh_keys; pub mod system; pub mod update; @@ -98,7 +99,8 @@ pub fn create_router() -> Router { let api_router = Router::new() .merge(rate_limited_router) - .merge(update::routes()); + .merge(update::routes()) + .merge(releases::routes()); Router::new() .route("/metrics", get(metrics::metrics_handler)) diff --git a/control-plane/api-gateway/src/routes/releases.rs b/control-plane/api-gateway/src/routes/releases.rs new file mode 100644 index 0000000..1f6e2dc --- /dev/null +++ b/control-plane/api-gateway/src/routes/releases.rs @@ -0,0 +1,131 @@ +use axum::{extract::Query, http::StatusCode, response::Json, routing::get, Router}; +use serde::{Deserialize, Serialize}; + +use crate::auth::rbac::CanManageSystem; +use crate::AppState; + +const RELEASES_API: &str = "https://api.github.com/repos/CSFX-cloud/CSF-Core/releases"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Deserialize)] +struct GithubRelease { + tag_name: String, + prerelease: bool, + html_url: String, + name: Option, +} + +#[derive(Debug, Serialize)] +pub struct ReleaseEntry { + pub version: String, + pub tag: String, + pub prerelease: bool, + pub html_url: String, + pub name: Option, + pub is_current: bool, + pub is_newer: bool, +} + +#[derive(Debug, Serialize)] +pub struct ReleasesResponse { + pub current_version: String, + pub update_available: bool, + pub latest_stable: Option, + pub releases: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ReleasesQuery { + #[serde(default)] + pub include_pre: bool, +} + +pub fn routes() -> Router { + Router::new().route("/system/releases", get(list_releases)) +} + +async fn list_releases( + _auth: CanManageSystem, + Query(query): Query, +) -> Result, StatusCode> { + let github_releases = fetch_github_releases().await?; + + let releases: Vec = github_releases + .iter() + .filter(|r| query.include_pre || !r.prerelease) + .take(10) + .map(|r| { + let version = r.tag_name.trim_start_matches('v').to_string(); + let is_current = version == CURRENT_VERSION; + let is_newer = semver_is_newer(&version, CURRENT_VERSION); + ReleaseEntry { + version, + tag: r.tag_name.clone(), + prerelease: r.prerelease, + html_url: r.html_url.clone(), + name: r.name.clone(), + is_current, + is_newer, + } + }) + .collect(); + + let latest_stable = releases + .iter() + .find(|r| !r.prerelease && r.is_newer) + .map(|r| r.version.clone()); + + let update_available = latest_stable.is_some(); + + Ok(Json(ReleasesResponse { + current_version: CURRENT_VERSION.to_string(), + update_available, + latest_stable, + releases, + })) +} + +async fn fetch_github_releases() -> Result, StatusCode> { + let client = reqwest::Client::new(); + let resp = client + .get(RELEASES_API) + .header("User-Agent", "csfx-api-gateway") + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| { + tracing::error!(error = %e, "github releases fetch failed"); + StatusCode::BAD_GATEWAY + })?; + + if !resp.status().is_success() { + tracing::error!(status = %resp.status(), "github api returned error"); + return Err(StatusCode::BAD_GATEWAY); + } + + resp.json::>().await.map_err(|e| { + tracing::error!(error = %e, "failed to deserialize github releases"); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +fn semver_is_newer(candidate: &str, current: &str) -> bool { + parse_semver(candidate) + .zip(parse_semver(current)) + .map(|(c, cur)| c > cur) + .unwrap_or(false) +} + +fn parse_semver(v: &str) -> Option<(u32, u32, u32)> { + let v = v.trim_start_matches('v'); + let base = v.split('-').next().unwrap_or(v); + let parts: Vec<&str> = base.split('.').collect(); + if parts.len() != 3 { + return None; + } + Some(( + parts[0].parse().ok()?, + parts[1].parse().ok()?, + parts[2].parse().ok()?, + )) +} From f98739f2989a5e22e1baf5edf891266be4e565b4 Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Thu, 11 Jun 2026 15:47:03 +0200 Subject: [PATCH 2/3] ci: added renovate --- renovate.json | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/renovate.json b/renovate.json index 0c0983f..a4493f3 100644 --- a/renovate.json +++ b/renovate.json @@ -23,14 +23,9 @@ "matchUpdateTypes": ["minor", "patch"] }, { - "matchManagers": ["dockerfile"], + "matchManagers": ["dockerfile", "docker-compose"], "groupName": "Docker base images", - "automerge": false - }, - { - "matchDepNames": ["postgres"], - "groupName": "PostgreSQL", - "automerge": false + "enabled": false }, { "matchDepNames": ["rust"], From 69f0ad848167879a504f87083eadf7455596df4f Mon Sep 17 00:00:00 2001 From: CodeMaster4711 Date: Fri, 12 Jun 2026 21:06:06 +0200 Subject: [PATCH 3/3] feat: serve frontend statically from api-gateway on port 8000 --- .github/workflows/docker-build.yml | 55 ++++- Cargo.lock | 14 ++ Cargo.toml | 2 +- app/package-lock.json | 11 + app/package.json | 1 + .../settings/update-settings.svelte | 193 ++++++++++++------ .../lib/components/sidebar/app-sidebar.svelte | 20 +- app/svelte.config.js | 11 +- control-plane/api-gateway/src/routes/mod.rs | 8 + 9 files changed, 249 insertions(+), 66 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 070ec9c..84616b5 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -111,6 +111,44 @@ jobs: ${{ matrix.binary }}-${{ matrix.arch }}.sha256 retention-days: 7 + build-frontend: + name: Build frontend + runs-on: ubuntu-latest + needs: prepare + if: needs.prepare.outputs.should_build == 'true' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: app/package-lock.json + + - name: Install dependencies + working-directory: app + run: npm ci + + - name: Build + working-directory: app + env: + VITE_API_URL: "" + run: npm run build + + - name: Archive + run: tar -czf csfx-frontend.tar.gz -C app/build . + + - name: Checksum + run: sha256sum csfx-frontend.tar.gz > csfx-frontend.tar.gz.sha256 + + - uses: actions/upload-artifact@v4 + with: + name: csfx-frontend + path: | + csfx-frontend.tar.gz + csfx-frontend.tar.gz.sha256 + retention-days: 7 + build-cp-binaries: name: Build CP ${{ matrix.binary }} (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} @@ -184,7 +222,7 @@ jobs: attach-binaries-release: name: Attach binaries to release runs-on: ubuntu-latest - needs: [prepare, build-binaries, build-cp-binaries] + needs: [prepare, build-binaries, build-cp-binaries, build-frontend] steps: - uses: actions/checkout@v4 @@ -214,6 +252,8 @@ jobs: csfx-agent-amd64.sha256 \ csfx-agent-arm64 \ csfx-agent-arm64.sha256 \ + csfx-frontend.tar.gz \ + csfx-frontend.tar.gz.sha256 \ ${CP_BINS} \ --clobber env: @@ -222,7 +262,7 @@ jobs: update-infra: name: Update CSFX-Infra versions.nix runs-on: ubuntu-latest - needs: [prepare, build-binaries, build-cp-binaries, attach-binaries-release] + needs: [prepare, build-binaries, build-cp-binaries, build-frontend, attach-binaries-release] steps: - uses: actions/checkout@v4 with: @@ -248,6 +288,11 @@ jobs: path: /tmp/cp-binaries merge-multiple: true + - uses: actions/download-artifact@v4 + with: + name: csfx-frontend + path: /tmp/frontend + - name: Write versions.nix run: | VERSION="${{ needs.prepare.outputs.version }}" @@ -264,6 +309,8 @@ jobs: awk '{print $1}' "/tmp/cp-binaries/${file}.sha256" 2>/dev/null } + FRONTEND_SHA256=$(awk '{print $1}' /tmp/frontend/csfx-frontend.tar.gz.sha256 2>/dev/null) + cat > infra/versions.nix < {#if error} -

{error}

+
+ {error} +
{/if} -
- +
+ - Current version +
+
+ System version + Current installation and update state +
+ {#if status} + + {status.paused ? "paused" : "active"} + + {/if} +
- + {#if statusLoading} -

Loading...

- {:else if status} -
- Installed - {status.current_version} +
+ {#each [1, 2] as _} +
+ {/each}
- {#if status.desired_version && status.desired_version !== status.current_version} -
- Scheduled - {status.desired_version} -
- {/if} - {#if status.build_status} -
- Build status - {buildStatusLabel(status.build_status)} -
- {/if} - {#if status.last_result} -
- Last result - {buildStatusLabel(status.last_result)} -
- {/if} - {#if status.paused} -
- Updates - paused + {:else if status} +
+
+

Installed

+

{status.current_version}

- {/if} + + {#if status.desired_version && status.desired_version !== status.current_version} +
+

Scheduled

+

{status.desired_version}

+
+ {:else} +
+

Target

+

up to date

+
+ {/if} + + {#if status.build_status} +
+
+

Build status

+ {#if isActive(status.build_status)} + + + running + + {/if} +
+

+ {buildStatusLabel(status.build_status)} +

+
+ {/if} + + {#if status.last_result} +
+

Last result

+

+ {buildStatusLabel(status.last_result)} +

+
+ {/if} +
{/if} {#if status} - + +

+ {status.paused ? "Updates are paused. No automatic upgrades will run." : "Updates are enabled. The system checks for new releases automatically."} +

{#if !status.paused} {:else} - {/if} @@ -159,40 +209,64 @@ {/if} - + - Available releases - +
+
+ Available releases + Select a version to update or downgrade +
- +
- + {#if releasesLoading} -

Loading...

+
+ {#each [1, 2, 3] as _} +
+
+
+
+
+
+
+ {/each} +
{:else if releases && releases.releases.length > 0} -
- {#each releases.releases as release (release.tag)} -
-
-
- +
+ {#each releases.releases as release, i (release.tag)} +
+
+
+ {release.version} {#if release.is_current} - current + + installed + {:else if release.is_newer} - newer + + newer + {/if} {#if release.prerelease} - pre-release + + pre-release + {/if}
{#if release.name} @@ -205,15 +279,20 @@ disabled={actionLoading} variant={release.is_newer ? "default" : "outline"} size="sm" + class="shrink-0" > {release.is_newer ? "Update" : "Downgrade"} + {:else} + current {/if}
{/each}
{:else} -

No releases found.

+
+

No releases found

+
{/if} diff --git a/app/src/lib/components/sidebar/app-sidebar.svelte b/app/src/lib/components/sidebar/app-sidebar.svelte index 2bf0d61..f46ba23 100644 --- a/app/src/lib/components/sidebar/app-sidebar.svelte +++ b/app/src/lib/components/sidebar/app-sidebar.svelte @@ -73,12 +73,15 @@ @@ -105,9 +119,9 @@ class="flex flex-col leading-tight group-data-[collapsible=icon]:hidden" > CSFX - Hypervisor v0.1 + + {version ? `v${version}` : "Hypervisor"} +
diff --git a/app/svelte.config.js b/app/svelte.config.js index ba04497..ed778a4 100644 --- a/app/svelte.config.js +++ b/app/svelte.config.js @@ -1,14 +1,19 @@ -import adapter from "@sveltejs/adapter-auto"; +import adapter from "@sveltejs/adapter-static"; /** @type {import('@sveltejs/kit').Config} */ const config = { compilerOptions: { - // Force runes mode for the project, except for libraries. Can be removed in svelte 6. runes: ({ filename }) => filename.split(/[/\\]/).includes("node_modules") ? undefined : true, }, kit: { - adapter: adapter(), + adapter: adapter({ + pages: "build", + assets: "build", + fallback: "index.html", + precompress: false, + strict: false, + }), alias: { $components: "src/components", $lib: "src/lib", diff --git a/control-plane/api-gateway/src/routes/mod.rs b/control-plane/api-gateway/src/routes/mod.rs index 0ee20fb..ab9a394 100644 --- a/control-plane/api-gateway/src/routes/mod.rs +++ b/control-plane/api-gateway/src/routes/mod.rs @@ -10,6 +10,7 @@ use axum::Router; use std::sync::Arc; use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; use tower_http::cors::CorsLayer; +use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; use tracing::{info_span, Span}; @@ -102,10 +103,17 @@ pub fn create_router() -> Router { .merge(update::routes()) .merge(releases::routes()); + let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "app/build".to_string()); + let index_path = format!("{}/index.html", static_dir); + + let serve_dir = ServeDir::new(&static_dir) + .not_found_service(ServeFile::new(&index_path)); + Router::new() .route("/metrics", get(metrics::metrics_handler)) .logged_nest("/api", api_router) .logged_nest("/api", internal_api_router) + .fallback_service(serve_dir) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request| {