From 019c43d79bc5c035e0b626ffbf5241134d829e6c Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 18 Mar 2026 14:50:12 +0100 Subject: [PATCH 1/7] feat(ui): Add server info view with network health Display server hostname, public IPs, network health status, DNS resolver checks, and network interfaces under System nav. Signed-off-by: nfebe --- src/layouts/DashboardLayout.vue | 11 +- src/router/index.ts | 6 + src/services/api.ts | 37 +++ src/views/ServerInfoView.vue | 419 ++++++++++++++++++++++++++++++++ 4 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 src/views/ServerInfoView.vue diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index d441bd4..ec9309a 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -122,6 +122,14 @@ /> @@ -62,8 +62,8 @@ class="nav-subitem" active-class="active" > - {{ stats.containers }} Containers + {{ stats.containers }} - {{ stats.images }} Images + {{ stats.images }} - {{ stats.volumes }} Volumes + {{ stats.volumes }} - {{ stats.networks }} Networks + {{ stats.networks }} - {{ stats.dockerPorts }} Port Mappings + {{ stats.dockerPorts }} @@ -136,8 +136,8 @@ class="nav-subitem" active-class="active" > - {{ stats.infrastructure }} Infrastructure + {{ stats.infrastructure }} - {{ stats.ports }} Ports + {{ stats.ports }} - {{ stats.services }} Services + {{ stats.services }} @@ -230,8 +230,8 @@ class="nav-subitem" active-class="active" > - {{ stats.certificates }} Certificates + {{ stats.certificates }} @@ -248,8 +248,8 @@ @@ -709,7 +709,7 @@ onMounted(() => { border-radius: 9999px; font-size: 0.6875rem; font-weight: 600; - margin-right: auto; + margin-left: auto; } .sidebar-footer { From ea60b2c5b4e4cf095ae6e5b6ee20f336f2158a83 Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 18 Mar 2026 14:54:51 +0100 Subject: [PATCH 3/7] enhance(ui): Rename Quick Actions to Shortcuts and add Server Info Renamed the dashboard section to avoid confusion with deployment quick actions. Added Server Info shortcut linking to the new view. Signed-off-by: nfebe --- src/views/HomeView.vue | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 9c92e1b..f324d4a 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -117,15 +117,24 @@ -
+
- - Quick Actions + + Shortcuts
+ + @@ -140,6 +143,15 @@ @confirm="confirmBulkRemove" @cancel="showBulkDeleteModal = false" /> + +
@@ -151,7 +163,8 @@ import { useAuthStore } from "@/stores/auth"; import DataTable from "@/components/DataTable.vue"; import LogsModal from "@/components/LogsModal.vue"; import ConfirmModal from "@/components/ConfirmModal.vue"; -import { RefreshCw, Play, Square, RotateCw, FileText, Trash2, Package, Link } from "lucide-vue-next"; +import ContainerResourcesModal from "@/components/ContainerResourcesModal.vue"; +import { RefreshCw, Play, Square, RotateCw, FileText, Trash2, Package, Link, Gauge } from "lucide-vue-next"; const router = useRouter(); const authStore = useAuthStore(); @@ -204,6 +217,12 @@ const bulkDeleteIds = ref([]); const bulkDeleteClearFn = ref<(() => void) | null>(null); const bulkDeleting = ref(false); +const resourcesModal = ref({ + visible: false, + containerId: "", + containerName: "", +}); + const columns = [ { key: "status", label: "Status", width: "60px" }, { key: "name", label: "Container", sortable: true }, @@ -211,7 +230,7 @@ const columns = [ { key: "image", label: "Image", sortable: true }, { key: "ports", label: "Ports" }, { key: "created", label: "Created", sortable: true }, - { key: "actions", label: "Actions", width: "160px" }, + { key: "actions", label: "Actions", width: "192px" }, ]; const statusFilters = computed(() => [ @@ -301,6 +320,14 @@ const confirmDeleteContainer = async () => { } }; +const showResources = (id: string, name: string) => { + resourcesModal.value = { visible: true, containerId: id, containerName: name }; +}; + +const onResourcesUpdated = () => { + resourcesModal.value.visible = false; +}; + const showLogs = async (id: string, name: string) => { selectedContainerName.value = name; const response = await containersApi.logs(id); @@ -514,6 +541,14 @@ onMounted(() => { background: var(--color-info-100); } +.action-btn.resources { + background: rgba(139, 92, 246, 0.1); + color: #7c3aed; +} +.action-btn.resources:hover { + background: rgba(139, 92, 246, 0.2); +} + .action-btn.logs { background: var(--color-gray-100); color: var(--color-gray-600); diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index dbdd9c9..956aed2 100644 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -310,6 +310,9 @@
+ @@ -1384,6 +1387,15 @@ @save="handleAddDomain" @cancel="showAddDomainModal = false" /> + +
@@ -1413,6 +1425,7 @@ import ContainerTerminal from "@/components/ContainerTerminal.vue"; import BackupsTab from "@/components/BackupsTab.vue"; import DomainsManager from "@/components/DomainsManager.vue"; import DomainFormModal from "@/components/DomainFormModal.vue"; +import ContainerResourcesModal from "@/components/ContainerResourcesModal.vue"; const route = useRoute(); const router = useRouter(); @@ -1440,6 +1453,12 @@ const disablingSSL = ref(false); const showAddDomainModal = ref(false); const addingDomain = ref(false); +const serviceResourcesModal = ref({ + visible: false, + containerId: "", + containerName: "", +}); + const securityConfig = ref({ enabled: false, blocked_ips: [], @@ -2102,6 +2121,14 @@ const migrateToInfrastructure = async () => { } }; +const openServiceResources = (service: any) => { + serviceResourcesModal.value = { + visible: true, + containerId: service.container_id, + containerName: service.name, + }; +}; + const openTerminal = (service: any) => { terminalService.value = service.container_id; activeTab.value = "terminal"; From f5ba57ec3ae02ccf26fc003fd62e2ddae26e6045 Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 18 Mar 2026 15:13:45 +0100 Subject: [PATCH 5/7] feat(ui): Add cluster management view Cluster page showing status, peer list, invite generation, join flow, and peer removal. Added cluster permissions to type system and navigation under System group. Signed-off-by: nfebe --- src/layouts/DashboardLayout.vue | 14 +- src/router/index.ts | 6 + src/services/api.ts | 38 ++ src/types/index.ts | 4 +- src/views/ClusterView.vue | 861 ++++++++++++++++++++++++++++++++ 5 files changed, 920 insertions(+), 3 deletions(-) create mode 100644 src/views/ClusterView.vue diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index 804a1fd..76d35d6 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -108,7 +108,8 @@ v-if=" authStore.hasPermission('system:read') || authStore.hasPermission('infrastructure:read') || - authStore.hasPermission('scheduler:read') + authStore.hasPermission('scheduler:read') || + authStore.hasPermission('cluster:read') " class="nav-group" > @@ -130,6 +131,14 @@ > Server Info + + Cluster + { services: "System Services", "cron-jobs": "Cron Jobs", "server-info": "Server Info", + cluster: "Cluster", databases: "Database Servers", security: "Security & Monitoring", certificates: "SSL Certificates", @@ -487,7 +497,7 @@ const breadcrumbs = computed(() => { } else if (["containers", "images", "volumes", "networks", "docker-ports"].includes(routeName)) { crumbs.push({ label: "Docker", path: "" }); crumbs.push({ label: currentPageTitle.value, path: "" }); - } else if (["infrastructure", "system-ports", "services", "cron-jobs", "server-info"].includes(routeName)) { + } else if (["infrastructure", "system-ports", "services", "cron-jobs", "server-info", "cluster"].includes(routeName)) { crumbs.push({ label: "System", path: "" }); crumbs.push({ label: currentPageTitle.value, path: "" }); } else if (routeName === "databases") { diff --git a/src/router/index.ts b/src/router/index.ts index affa2c4..149d1ca 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -129,6 +129,12 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/ServerInfoView.vue"), meta: { permission: "system:read" }, }, + { + path: "cluster", + name: "cluster", + component: () => import("@/views/ClusterView.vue"), + meta: { permission: "cluster:read" }, + }, { path: "security", name: "security", diff --git a/src/services/api.ts b/src/services/api.ts index aa094da..e5e4be8 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1166,6 +1166,44 @@ export const powerDnsApi = { apiClient.patch(`/dns/powerdns/zones/${zoneId}`, { rrsets }), }; +export interface ClusterStatus { + enabled: boolean; + server_name?: string; + peer_count?: number; + version?: { version: string; build_time: string; git_commit: string }; +} + +export interface ClusterPeer { + id: number; + name: string; + url: string; + status: string; + created_at: string; + last_seen_at?: string; +} + +export interface ClusterInvite { + invite_token: string; + expires_at: string; +} + +export interface ClusterAcceptResult { + peer_name: string; + peer_url: string; + status: string; +} + +export const clusterApi = { + getStatus: () => apiClient.get("/cluster/status"), + 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"), + getAggregatedStats: () => apiClient.get("/cluster/stats"), +}; + import type { User, APIKey, UserRole, UserDeploymentAccess } from "@/types"; export const usersApi = { diff --git a/src/types/index.ts b/src/types/index.ts index b4039d7..5e9870f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -332,4 +332,6 @@ export type Permission = | "templates:read" | "templates:write" | "traffic:read" - | "traffic:write"; + | "traffic:write" + | "cluster:read" + | "cluster:write"; diff --git a/src/views/ClusterView.vue b/src/views/ClusterView.vue new file mode 100644 index 0000000..4b6e849 --- /dev/null +++ b/src/views/ClusterView.vue @@ -0,0 +1,861 @@ + + + + + From 4a02511c8866ca1b4defef9233c0b14fa5118cb5 Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 18 Mar 2026 15:20:06 +0100 Subject: [PATCH 6/7] enhance(ui): Add cluster-aware environment selector to sidebar Replace static "Local Environment" with dynamic dropdown showing current server name fetched from cluster API, listing cluster peers and linking to cluster management. Also show node count badge on Cluster nav item. Signed-off-by: nfebe --- src/layouts/DashboardLayout.vue | 133 +++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 3 deletions(-) diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index 76d35d6..9dddfc0 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -6,10 +6,44 @@
-
+
- Local Environment - + {{ currentServerName }} + +
+
+
+ +
+ {{ currentServerName }} + Current server +
+ +
+
+ +
+ {{ peer.name }} + {{ peer.status }} +
+
+ + + Manage Cluster +
@@ -138,6 +172,7 @@ active-class="active" > Cluster + {{ clusterPeers.length + 1 }} ([]); const expandedGroups = reactive({ stacks: true, @@ -545,9 +584,23 @@ const handleLogout = () => { router.push("/login"); }; +const fetchClusterInfo = async () => { + try { + const res = await clusterApi.getStatus(); + if (res.data.enabled && res.data.server_name) { + currentServerName.value = res.data.server_name; + const peersRes = await clusterApi.listPeers(); + clusterPeers.value = peersRes.data.peers || []; + } + } catch { + // cluster not available + } +}; + onMounted(() => { statsStore.fetchAll(); authStore.fetchCurrentUser(); + fetchClusterInfo(); setInterval(() => statsStore.fetchAll(), 15000); }); @@ -619,6 +672,80 @@ onMounted(() => { font-size: 0.75rem; } +.env-dropdown { + margin-top: 0.375rem; + background: #1e293b; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.1); + overflow: hidden; +} + +.env-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + cursor: pointer; + transition: background 0.15s; + color: #94a3b8; + font-size: 0.8125rem; + text-decoration: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.env-option:last-child { + border-bottom: none; +} + +.env-option:hover { + background: rgba(255, 255, 255, 0.08); + color: white; +} + +.env-option.active { + color: #60a5fa; +} + +.env-option.active .pi-check { + margin-left: auto; + font-size: 0.6875rem; +} + +.env-option i:first-child { + font-size: 0.8125rem; + width: 16px; + text-align: center; +} + +.env-option-info { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.env-option-name { + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.env-option-hint { + font-size: 0.625rem; + color: #64748b; +} + +.env-manage { + border-top: 1px solid rgba(255, 255, 255, 0.1); + color: #64748b; +} + +.env-manage:hover { + color: #94a3b8; +} + .nav-menu { flex: 1; padding: 0.75rem 0; From 0fc08d245f94efe9a0e05f5b54d8c20a8f6d2074 Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 18 Mar 2026 15:25:48 +0100 Subject: [PATCH 7/7] fix(ui): Apply prettier formatting to new views Signed-off-by: nfebe --- src/components/ContainerResourcesModal.vue | 17 ++++++++++++----- src/layouts/DashboardLayout.vue | 16 +++++----------- src/views/ClusterView.vue | 22 ++++++++++------------ src/views/ServerInfoView.vue | 12 +++++++++--- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/components/ContainerResourcesModal.vue b/src/components/ContainerResourcesModal.vue index c9890d9..4fa2733 100644 --- a/src/components/ContainerResourcesModal.vue +++ b/src/components/ContainerResourcesModal.vue @@ -27,11 +27,15 @@
Memory - {{ resources.memory_limit > 0 ? formatBytes(resources.memory_limit) : "Unlimited" }} + {{ + resources.memory_limit > 0 ? formatBytes(resources.memory_limit) : "Unlimited" + }}
Memory + Swap - {{ resources.memory_swap > 0 ? formatBytes(resources.memory_swap) : "Unlimited" }} + {{ + resources.memory_swap > 0 ? formatBytes(resources.memory_swap) : "Unlimited" + }}
CPU Shares @@ -229,9 +233,12 @@ const saveResources = async () => { } }; -watch(() => props.visible, (val) => { - if (val) fetchResources(); -}); +watch( + () => props.visible, + (val) => { + if (val) fetchResources(); + }, +);