diff --git a/src/components/ContainerResourcesModal.vue b/src/components/ContainerResourcesModal.vue new file mode 100644 index 0000000..4fa2733 --- /dev/null +++ b/src/components/ContainerResourcesModal.vue @@ -0,0 +1,485 @@ + + + + + diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index d441bd4..4ccb1e5 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -6,10 +6,36 @@
-
+
- Local Environment - + {{ currentServerName }} + +
+
+
+ +
+ {{ currentServerName }} + Current server +
+ +
+
+ +
+ {{ peer.name }} + {{ peer.status }} +
+
+ + + Manage Cluster +
@@ -31,8 +57,8 @@
@@ -62,8 +88,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 }} @@ -108,7 +134,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" > @@ -122,14 +149,31 @@ /> @@ -222,8 +266,8 @@ class="nav-subitem" active-class="active" > - {{ stats.certificates }} Certificates + {{ stats.certificates }} @@ -240,8 +284,8 @@ @@ -388,6 +432,7 @@ import { ref, reactive, computed, onMounted } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useStatsStore } from "@/stores/stats"; import { useAuthStore } from "@/stores/auth"; +import { clusterApi, type ClusterPeer } from "@/services/api"; import Logo from "@/components/base/Logo.vue"; const route = useRoute(); @@ -396,6 +441,9 @@ const statsStore = useStatsStore(); const authStore = useAuthStore(); const sidebarCollapsed = ref(false); const isRefreshing = ref(false); +const envDropdownOpen = ref(false); +const currentServerName = ref("Local Server"); +const clusterPeers = ref([]); const expandedGroups = reactive({ stacks: true, @@ -453,6 +501,8 @@ const currentPageTitle = computed(() => { "system-ports": "System Ports", services: "System Services", "cron-jobs": "Cron Jobs", + "server-info": "Server Info", + cluster: "Cluster", databases: "Database Servers", security: "Security & Monitoring", certificates: "SSL Certificates", @@ -478,7 +528,9 @@ 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"].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") { @@ -526,9 +578,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); }); @@ -600,6 +666,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; @@ -700,7 +840,7 @@ onMounted(() => { border-radius: 9999px; font-size: 0.6875rem; font-weight: 600; - margin-right: auto; + margin-left: auto; } .sidebar-footer { diff --git a/src/router/index.ts b/src/router/index.ts index 110ee9f..149d1ca 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -123,6 +123,18 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/InfrastructureView.vue"), meta: { permission: "infrastructure:read" }, }, + { + path: "server-info", + name: "server-info", + 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 5b42335..e5e4be8 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -275,6 +275,21 @@ export const composeApi = { ), }; +export interface ResourceLimits { + memory_limit: number; + memory_swap: number; + cpus: number; + cpu_shares: number; + restart_policy: string; +} + +export interface ResourceUpdate { + memory_limit?: number; + memory_swap?: number; + cpus?: number; + cpu_shares?: number; +} + export const containersApi = { list: () => apiClient.get<{ containers: any[] }>("/containers"), start: (id: string) => apiClient.post(`/containers/${id}/start`), @@ -296,6 +311,9 @@ export const containersApi = { pids: number; }>; }>("/containers/stats"), + getResources: (id: string) => apiClient.get<{ resources: ResourceLimits }>(`/containers/${id}/resources`), + updateResources: (id: string, update: ResourceUpdate) => + apiClient.put<{ message: string; resources: ResourceLimits }>(`/containers/${id}/resources`, update), }; export const imagesApi = { @@ -321,6 +339,43 @@ export const healthApi = { stats: () => apiClient.get("/stats"), }; +export interface NetworkInterface { + name: string; + addresses: string[]; + flags: string; +} + +export interface ServerInfo { + hostname: string; + public_ipv4: string; + public_ipv6: string; + interfaces: NetworkInterface[]; +} + +export interface ResolverCheck { + server: string; + healthy: boolean; + latency_ms: number; + error?: string; +} + +export interface DNSHealthInfo { + healthy: boolean; + resolvers: ResolverCheck[]; +} + +export interface NetworkHealth { + external_access: boolean; + dns: DNSHealthInfo; + interfaces: NetworkInterface[]; + checked_at: string; +} + +export const serverApi = { + getInfo: () => apiClient.get<{ server: ServerInfo }>("/server/info"), + getNetworkHealth: () => apiClient.get<{ network_health: NetworkHealth }>("/server/network-health"), +}; + export interface FileInfo { name: string; path: string; @@ -1111,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..c18223f --- /dev/null +++ b/src/views/ClusterView.vue @@ -0,0 +1,859 @@ + + + + + diff --git a/src/views/ContainersView.vue b/src/views/ContainersView.vue index c50ad9d..63be290 100644 --- a/src/views/ContainersView.vue +++ b/src/views/ContainersView.vue @@ -87,6 +87,9 @@ + @@ -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"; 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
+