From a1cd85b803d80151da88ce4e2ba9500a8dbfdf49 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 13:15:23 +0100 Subject: [PATCH 01/29] feat(cluster): Manage deployments across connected servers --- src/layouts/DashboardLayout.vue | 4 +- src/services/api.ts | 27 +++++++++-- src/views/ClusterView.vue | 10 ++-- src/views/DeploymentsView.vue | 83 +++++++++++++++++++++++---------- 4 files changed, 86 insertions(+), 38 deletions(-) diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index cb5fe0e..01559b1 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -20,11 +20,11 @@ -
+
{{ peer.name }} - {{ peer.status }} + {{ peer.online ? "Online" : "Offline" }}
; +} + export const clusterApi = { getStatus: () => apiClient.get("/cluster/status"), + setup: (serverName: string, advertiseUrl: string) => + apiClient.post("/cluster/setup", { server_name: serverName, advertise_url: advertiseUrl }), listPeers: () => apiClient.get<{ peers: ClusterPeer[] }>("/cluster/peers"), createInvite: () => apiClient.post("/cluster/invite"), acceptInvite: (inviteToken: string, peerUrl: string) => apiClient.post("/cluster/accept", { invite_token: inviteToken, peer_url: peerUrl }), removePeer: (name: string) => apiClient.delete<{ status: string; peer: string }>(`/cluster/peers/${name}`), - getAggregatedDeployments: () => apiClient.get("/cluster/deployments"), + getAggregatedDeployments: () => apiClient.get("/cluster/deployments"), + deploymentAction: (server: string, name: string, action: "start" | "stop" | "restart") => + apiClient.post(`/cluster/peers/${encodeURIComponent(server)}/proxy/deployments/${encodeURIComponent(name)}/${action}`), + deploymentLogs: (server: string, name: string) => + apiClient.get<{ logs: string }>(`/cluster/peers/${encodeURIComponent(server)}/proxy/deployments/${encodeURIComponent(name)}/logs`), getAggregatedStats: () => apiClient.get("/cluster/stats"), }; diff --git a/src/views/ClusterView.vue b/src/views/ClusterView.vue index 33f6deb..01b9b3b 100644 --- a/src/views/ClusterView.vue +++ b/src/views/ClusterView.vue @@ -89,13 +89,12 @@ Name URL Status - Connected Last Seen Actions - +
@@ -106,12 +105,11 @@ {{ peer.url }} - - {{ peer.status }} + + {{ peer.online ? "online" : "offline" }} - {{ formatTime(peer.created_at) }} - {{ peer.last_seen_at ? formatTime(peer.last_seen_at) : "—" }} + {{ peer.last_seen ? formatTime(peer.last_seen) : "—" }}
+ + +
+
+ +
No deployment downtimeSetup changes fleet access only.
+
+ + + Use a short name that identifies this server throughout the fleet. + + + Other servers must be able to reach the agent at this HTTPS URL. +
+ {{ setupError }} +
+
+ +
@@ -227,6 +278,9 @@ import { ref, onMounted } from "vue"; import { clusterApi, type ClusterStatus, type ClusterPeer } from "@/services/api"; import { useNotificationsStore } from "@/stores/notifications"; import { useAuthStore } from "@/stores/auth"; +import BaseButton from "@/components/base/BaseButton.vue"; +import BaseModal from "@/components/base/BaseModal.vue"; +import Icon from "@/components/base/Icon.vue"; const notifications = useNotificationsStore(); const authStore = useAuthStore(); @@ -235,6 +289,10 @@ const canWrite = authStore.hasPermission("cluster:write"); const loading = ref(false); const status = ref(null); const peers = ref([]); +const showSetupModal = ref(false); +const settingUp = ref(false); +const setupError = ref(""); +const setupForm = ref({ serverName: "", advertiseUrl: "" }); const showInviteModal = ref(false); const inviteToken = ref(""); @@ -268,6 +326,27 @@ const fetchAll = async () => { } }; +const closeSetupModal = () => { + if (settingUp.value) return; + showSetupModal.value = false; + setupError.value = ""; +}; + +const setupFleet = async () => { + settingUp.value = true; + setupError.value = ""; + try { + status.value = (await clusterApi.setup(setupForm.value.serverName, setupForm.value.advertiseUrl)).data; + notifications.success("Fleet enabled", `${setupForm.value.serverName} is ready to connect to other servers.`); + showSetupModal.value = false; + await fetchAll(); + } catch (error: any) { + setupError.value = error.response?.data?.error || error.message || "Fleet setup failed"; + } finally { + settingUp.value = false; + } +}; + const createInvite = async () => { creatingInvite.value = true; try { @@ -411,7 +490,7 @@ onMounted(() => { justify-content: center; padding: 4rem; background: var(--surface-raised); - border-radius: var(--radius-sm); + border-radius: var(--radius-xl); border: 1px solid var(--border); text-align: center; gap: 1rem; @@ -432,19 +511,76 @@ onMounted(() => { .disabled-state p { color: var(--text-muted); margin: 0; + max-width: 520px; } -.config-hint { - display: block; - text-align: left; - background: #1f2937; - color: #e5e7eb; - padding: 1rem 1.25rem; - border-radius: var(--radius-sm); - font-size: 0.8125rem; - line-height: 1.6; - white-space: pre; - font-family: "SF Mono", "Fira Code", monospace; +.disabled-icon { + display: grid; + width: 56px; + height: 56px; + place-items: center; + color: var(--color-primary-700); + background: var(--color-primary-50); + border-radius: var(--radius-xl); +} + +.status-chip { + padding: 0.25rem 0.625rem; + color: var(--color-warning-700); + background: var(--color-warning-50); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--font-semibold); +} + +.setup-form { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.setup-form label { + margin-top: var(--space-2); + color: var(--text); + font-size: var(--text-sm); + font-weight: var(--font-medium); +} + +.field-help { + color: var(--text-muted); + font-size: var(--text-xs); +} + +.setup-note, +.setup-error { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3); + border-radius: var(--radius-lg); +} + +.setup-note { + color: var(--color-success-700); + background: var(--color-success-50); +} + +.setup-note div { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.setup-note span { + color: var(--text-muted); + font-size: var(--text-sm); +} + +.setup-error { + margin-top: var(--space-2); + color: var(--color-danger-700); + background: var(--color-danger-50); + font-size: var(--text-sm); } .status-cards { From ed335d62efc51bfbfbb1ff07b01e3577f24d8180 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 22 Aug 2026 14:22:21 +0100 Subject: [PATCH 05/29] ui(cluster): Manage peer access policies --- src/services/api.ts | 26 ++++ src/views/ClusterView.test.ts | 40 +++++ src/views/ClusterView.vue | 285 +++++++++++++++++++++++++++++++++- 3 files changed, 349 insertions(+), 2 deletions(-) diff --git a/src/services/api.ts b/src/services/api.ts index a82509a..d8900ca 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -2021,6 +2021,28 @@ export interface ClusterPeer { error?: string; } +export type ClusterCapability = + | "fleet.read" + | "deployments.read" + | "deployments.run" + | "capacity.read" + | "capacity.offer" + | "events.publish" + | "routing.manage"; + +export interface ClusterGrant { + capability: ClusterCapability; + deployments?: string[]; + max_cpu?: number; + max_memory?: number; + max_replicas?: number; +} + +export interface ClusterPeerPolicy { + peer: string; + grants: ClusterGrant[]; +} + export interface ClusterInvite { invite_token: string; expires_at: string; @@ -2048,6 +2070,10 @@ export const clusterApi = { setup: (serverName: string, advertiseUrl: string) => apiClient.post("/cluster/setup", { server_name: serverName, advertise_url: advertiseUrl }), listPeers: () => apiClient.get<{ peers: ClusterPeer[] }>("/cluster/peers"), + getPeerPolicy: (name: string) => + apiClient.get(`/cluster/peers/${encodeURIComponent(name)}/policy`), + updatePeerPolicy: (name: string, grants: ClusterGrant[]) => + apiClient.put(`/cluster/peers/${encodeURIComponent(name)}/policy`, { grants }), createInvite: () => apiClient.post("/cluster/invite"), acceptInvite: (inviteToken: string, peerUrl: string) => apiClient.post("/cluster/accept", { invite_token: inviteToken, peer_url: peerUrl }), diff --git a/src/views/ClusterView.test.ts b/src/views/ClusterView.test.ts index fff3b44..628b4ae 100644 --- a/src/views/ClusterView.test.ts +++ b/src/views/ClusterView.test.ts @@ -15,6 +15,8 @@ vi.mock("@/services/api", () => ({ getStatus: vi.fn(), setup: vi.fn(), listPeers: vi.fn(), + getPeerPolicy: vi.fn(), + updatePeerPolicy: vi.fn(), createInvite: vi.fn(), acceptInvite: vi.fn(), removePeer: vi.fn(), @@ -32,6 +34,7 @@ describe("ClusterView", () => { data: { enabled: true, server_name: "prod-1", advertise_url: "https://prod-1.example.com:8090" }, } as any); vi.mocked(clusterApi.listPeers).mockResolvedValue({ data: { peers: [] } } as any); + vi.mocked(clusterApi.updatePeerPolicy).mockResolvedValue({ data: { peer: "prod-2", grants: [] } } as any); }); const mountView = () => @@ -70,4 +73,41 @@ describe("ClusterView", () => { expect(clusterApi.listPeers).toHaveBeenCalledOnce(); expect(wrapper.text()).toContain("prod-1"); }); + + it("updates a peer resource lending policy through the modal", async () => { + const { clusterApi } = await import("@/services/api"); + vi.mocked(clusterApi.getStatus).mockReset(); + vi.mocked(clusterApi.getStatus).mockResolvedValue({ + data: { enabled: true, server_name: "prod-1", peer_count: 1 }, + } as any); + vi.mocked(clusterApi.listPeers).mockResolvedValue({ + data: { + peers: [ + { name: "prod-2", url: "https://prod-2.example.com", online: true, last_seen: new Date().toISOString() }, + ], + }, + } as any); + vi.mocked(clusterApi.getPeerPolicy).mockResolvedValue({ + data: { peer: "prod-2", grants: [{ capability: "fleet.read" }] }, + } as any); + const wrapper = mountView(); + await flushPromises(); + + await wrapper + .findAll("button") + .find((button) => button.text().includes("Access"))! + .trigger("click"); + await flushPromises(); + await wrapper.find('input[value="capacity.offer"]').setValue(true); + await wrapper.find("#policy-cpu").setValue(2); + await wrapper.find("#policy-memory").setValue(4); + await wrapper.find("#policy-replicas").setValue(3); + await wrapper.find("#peer-policy-form").trigger("submit"); + await flushPromises(); + + expect(clusterApi.updatePeerPolicy).toHaveBeenCalledWith("prod-2", [ + { capability: "fleet.read" }, + { capability: "capacity.offer", max_cpu: 2, max_memory: 4 * 1024 ** 3, max_replicas: 3 }, + ]); + }); }); diff --git a/src/views/ClusterView.vue b/src/views/ClusterView.vue index e53f307..b4d7d0f 100644 --- a/src/views/ClusterView.vue +++ b/src/views/ClusterView.vue @@ -112,6 +112,10 @@ {{ peer.last_seen ? formatTime(peer.last_seen) : "—" }} +