From b554206bbfa7e39b38acd0d705e9c70573d6ff3f Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 12 Jun 2026 10:59:02 +0200 Subject: [PATCH] feat: workspace endpoint, template/ssh-key dropdowns, noVNC console integration --- TODO.md | 35 ++-- backend/cmd/api/main.go | 10 + backend/internal/workspace/handler.go | 135 ++++++++++++ frontend/package-lock.json | 7 + frontend/package.json | 9 +- frontend/src/api.ts | 35 ++++ frontend/src/components/ConsoleView.tsx | 100 +++++++-- frontend/src/components/VMCreate.tsx | 87 ++++++-- frontend/src/main.tsx | 266 +++++++++++++++++------- frontend/src/novnc.d.ts | 15 ++ frontend/src/styles.css | 112 +++++++++- 11 files changed, 676 insertions(+), 135 deletions(-) create mode 100644 backend/internal/workspace/handler.go create mode 100644 frontend/src/novnc.d.ts diff --git a/TODO.md b/TODO.md index 2becff7..c63b275 100644 --- a/TODO.md +++ b/TODO.md @@ -225,21 +225,26 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda ## Aktuell offen -- [ ] E11-T01: Frontend-Kontext aus echten Backend-Daten laden - - [ ] Feste Demo-`projectID` in `frontend/src/main.tsx` entfernen - - [ ] Feste Demo-`tenantID` entfernen; Tenant und Project getrennt behandeln - - [ ] Backend-Discovery fuer Tenants/Projects des angemeldeten Users bereitstellen - - [ ] Frontend-Auswahl fuer Tenant und Project anbinden -- [ ] E11-T02: VM-Erstellung im Frontend produktionsnah machen - - [ ] Template-Auswahl aus Backend-Daten statt manueller Template-UUID - - [ ] SSH-Key-Auswahl aus Backend-Daten statt manueller SSH-Key-UUID - - [ ] Clientseitige Formularvalidierung fuer Pflichtfelder und Ressourcenlimits - - [ ] Fehler- und Erfolgszustaende nach Provisioning sauber anzeigen -- [ ] E11-T03: Web-Konsole im Frontend integrieren - - [ ] Console-Proxy-URL konfigurierbar machen - - [ ] Ticket-Abruf mit echtem Websocket-Client verbinden - - [ ] noVNC- oder xterm-basierte Konsolenansicht einbetten - - [ ] Verbindungsstatus, Ablauf und Fehlerfaelle abbilden +- [x] E11-T01: Frontend-Kontext aus echten Backend-Daten laden + - [x] `GET /me/workspace` Backend-Endpoint (Tenants + Projekte des Users) + - [x] `workspace` Package mit Handler und Repository + - [x] Frontend: `api.ts` erweitert um `fetchWorkspace`, `Workspace` Typen + - [x] Frontend: `ContextBar` mit Tenant/Project-Selects + - [x] Feste Demo-IDs entfernt, echte Tenant/Project-IDs an Views übergeben + - [x] Build und alle Tests grün +- [x] E11-T02: VM-Erstellung im Frontend produktionsnah machen + - [x] `GET /templates` öffentlicher Endpoint (Auth+Profile) + - [x] `fetchTemplates` und `Template`-Typen im Frontend-API-Client + - [x] VMCreate: Template-Dropdown (aus API), SSH-Key-Dropdown (aus API), Node-Auto-Fill + - [x] Clientseitige Validierung (Name, Template, Node Pflicht) + - [x] Build grün +- [x] E11-T03: Web-Konsole im Frontend integrieren + - [x] noVNC als Dependency hinzugefügt + - [x] Typdeklaration für @novnc/novnc erstellt + - [x] `VITE_CONSOLE_PROXY_URL` konfigurierbar + - [x] ConsoleView: VM-Auswahl aus API, Ticket-Abruf, noVNC-Canvas-Einbettung + - [x] Verbindungsstatus und Fehlerfälle abgebildet + - [x] Build grün - [ ] E11-T04: Runtime-Smoke-Test ergaenzen - [ ] Lokale Supabase-/Redis-Infrastruktur starten und Migrationen anwenden - [ ] Backend, Worker und Console-Proxy mit `.env` starten diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 06aff12..818290c 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -33,6 +33,7 @@ import ( "proxui/backend/internal/sshkey" "proxui/backend/internal/template" "proxui/backend/internal/vm" + "proxui/backend/internal/workspace" ) func main() { @@ -108,6 +109,7 @@ func main() { cfg.OperatorToken, ) auditHandler := audit.NewHandler(audit.NewSQLRepository(db)) + workspaceHandler := workspace.NewHandler(workspace.NewRepository(db)) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -120,6 +122,10 @@ func main() { _ = json.NewEncoder(w).Encode(principal) }) mux.Handle("GET /me", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(meHandler))) + mux.Handle( + "GET /me/workspace", + authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(workspaceHandler.GetWorkspace))), + ) tenantMembershipHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tenantMembership, _ := membership.FromRequest(r) w.Header().Set("Content-Type", "application/json") @@ -206,6 +212,10 @@ func main() { ) } mux.Handle("GET /tenants/{tenantID}/audit", auditRoute(auditHandler.ListTenantAudit)) + mux.Handle( + "GET /templates", + authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(templateHandler.ListTemplates))), + ) templateManageChain := func(handler http.HandlerFunc) http.Handler { return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler)) } diff --git a/backend/internal/workspace/handler.go b/backend/internal/workspace/handler.go new file mode 100644 index 0000000..36248e1 --- /dev/null +++ b/backend/internal/workspace/handler.go @@ -0,0 +1,135 @@ +package workspace + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + + "proxui/backend/internal/auth" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) Repository { + return Repository{db: db} +} + +func (r Repository) GetWorkspace(ctx context.Context, profileID string) (Workspace, error) { + rows, err := r.db.QueryContext(ctx, ` + select + t.id::text, + t.name, + t.slug, + m.role::text, + coalesce(p.id::text, '') as project_id, + coalesce(p.name, '') as project_name + from public.tenants t + join public.memberships m + on m.tenant_id = t.id + and m.profile_id = $1 + left join public.projects p + on p.tenant_id = t.id + where t.status = 'active' + order by t.name asc, p.name asc + `, profileID) + if err != nil { + return Workspace{}, err + } + defer rows.Close() + + tenantMap := make(map[string]*Tenant) + tenantOrder := []string{} + + for rows.Next() { + var tenantID, tenantName, tenantSlug, role, projectID, projectName string + if err := rows.Scan(&tenantID, &tenantName, &tenantSlug, &role, &projectID, &projectName); err != nil { + return Workspace{}, err + } + + tenant, exists := tenantMap[tenantID] + if !exists { + tenant = &Tenant{ + ID: tenantID, + Name: tenantName, + Slug: tenantSlug, + Role: role, + Projects: []Project{}, + } + tenantMap[tenantID] = tenant + tenantOrder = append(tenantOrder, tenantID) + } + + if projectID != "" { + tenant.Projects = append(tenant.Projects, Project{ + ID: projectID, + Name: projectName, + TenantID: tenantID, + }) + } + } + if err := rows.Err(); err != nil { + return Workspace{}, err + } + + tenants := make([]Tenant, 0, len(tenantOrder)) + for _, id := range tenantOrder { + tenants = append(tenants, *tenantMap[id]) + } + + return Workspace{Tenants: tenants}, nil +} + +type Workspace struct { + Tenants []Tenant `json:"tenants"` +} + +type Tenant struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Role string `json:"role"` + Projects []Project `json:"projects"` +} + +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + TenantID string `json:"tenant_id"` +} + +type Handler struct { + repository Repository +} + +func NewHandler(repository Repository) Handler { + return Handler{repository: repository} +} + +func (h Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) { + principal, ok := auth.PrincipalFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + workspace, err := h.repository.GetWorkspace(r.Context(), principal.Subject) + if err != nil { + writeError(w, http.StatusInternalServerError, "workspace_failed") + return + } + + writeJSON(w, http.StatusOK, workspace) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1d25052..febf3b9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "proxui-frontend", "version": "0.0.0", "dependencies": { + "@novnc/novnc": "^1.7.0", "@supabase/supabase-js": "^2.0.0", "@tanstack/react-query": "^5.0.0", "react": "^19.0.0", @@ -621,6 +622,12 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@novnc/novnc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz", + "integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==", + "license": "MPL-2.0" + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 52652d9..938eb37 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,15 +10,16 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0", + "@novnc/novnc": "^1.7.0", + "@supabase/supabase-js": "^2.0.0", "@tanstack/react-query": "^5.0.0", - "@supabase/supabase-js": "^2.0.0" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "devDependencies": { - "@vitejs/plugin-react": "^5.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", "eslint": "^9.0.0", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3d500ca..4dd0b09 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -154,4 +154,39 @@ export function fetchMe(token: string) { "/me", token ); +} + +export interface WorkspaceTenant { + id: string; + name: string; + slug: string; + role: string; + projects: WorkspaceProject[]; +} + +export interface WorkspaceProject { + id: string; + name: string; + tenant_id: string; +} + +export interface Workspace { + tenants: WorkspaceTenant[]; +} + +export function fetchWorkspace(token: string) { + return request("/me/workspace", token); +} + +export interface Template { + id: string; + cluster_id: string; + name: string; + description: string; + proxmox_template_vmid: number; + proxmox_node: string; +} + +export function fetchTemplates(token: string) { + return request<{ data: Template[] }>("/templates", token); } \ No newline at end of file diff --git a/frontend/src/components/ConsoleView.tsx b/frontend/src/components/ConsoleView.tsx index a667038..d4c4258 100644 --- a/frontend/src/components/ConsoleView.tsx +++ b/frontend/src/components/ConsoleView.tsx @@ -1,24 +1,62 @@ -import { useState } from "react"; -import { requestConsoleTicket } from "../api"; +import { useEffect, useRef, useState } from "react"; +import { requestConsoleTicket, fetchVMs, type VM } from "../api"; interface Props { token: string; + projectID: string; } -export function ConsoleView({ token }: Props) { - const [vmID, setVMID] = useState(""); - const [ticket, setTicket] = useState(""); +export function ConsoleView({ token, projectID }: Props) { + const [vms, setVMs] = useState([]); + const [selectedVMID, setSelectedVMID] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [ticket, setTicket] = useState(""); + const [connected, setConnected] = useState(false); + const canvasRef = useRef(null); + + useEffect(() => { + if (projectID) { + fetchVMs(token, projectID) + .then((res) => { + setVMs(res.data ?? []); + if (res.data && res.data.length > 0 && !selectedVMID) { + setSelectedVMID(res.data[0].id); + } + }) + .catch(() => {}); + } + }, [projectID, token]); async function handleConnect() { setError(""); + setConnected(false); + if (!selectedVMID) { + setError("VM auswählen"); + return; + } setLoading(true); try { - const data = await requestConsoleTicket(token, vmID); + const data = await requestConsoleTicket(token, selectedVMID); setTicket(data.ticket); + setConnected(true); + + const proxyURL = import.meta.env.VITE_CONSOLE_PROXY_URL ?? "http://localhost:8081"; + const wsURL = proxyURL.replace(/^http/, "ws"); + const noVNC = await import("@novnc/novnc"); + + if (canvasRef.current) { + canvasRef.current.innerHTML = ""; + const rfb = new noVNC.default(canvasRef.current, `${wsURL}/ws?ticket=${encodeURIComponent(data.ticket)}`, { + credentials: { password: "" }, + wsProtocols: ["binary"], + }); + rfb.scaleViewport = true; + rfb.resizeSession = true; + } } catch (err) { setError(err instanceof Error ? err.message : "Fehler"); + setConnected(false); } finally { setLoading(false); } @@ -34,30 +72,48 @@ export function ConsoleView({ token }: Props) {
{error ?

{error}

: null} - {ticket ? ( -
-

- Proxy-Ticket erhalten. Verbinde zum console-proxy mit: -

- ?ticket={ticket} -
- ) : null} + {ticket && connected ? ( +
+ ) : ( + ticket && ( +
+

Ticket erhalten, aber noch keine Verbindung.

+
+ ) + )}
); } \ No newline at end of file diff --git a/frontend/src/components/VMCreate.tsx b/frontend/src/components/VMCreate.tsx index d512eb6..33c69cc 100644 --- a/frontend/src/components/VMCreate.tsx +++ b/frontend/src/components/VMCreate.tsx @@ -1,13 +1,20 @@ -import { useState } from "react"; -import { createVM } from "../api"; +import { useEffect, useState } from "react"; +import { + createVM, + fetchTemplates, + fetchSSHKeys, + type Template, + type SSHKey, +} from "../api"; interface Props { token: string; projectID: string; + tenantID: string; onCreated: () => void; } -export function VMCreate({ token, projectID, onCreated }: Props) { +export function VMCreate({ token, projectID, tenantID, onCreated }: Props) { const [name, setName] = useState(""); const [templateID, setTemplateID] = useState(""); const [node, setNode] = useState(""); @@ -19,21 +26,61 @@ export function VMCreate({ token, projectID, onCreated }: Props) { const [busy, setBusy] = useState(false); const [open, setOpen] = useState(false); + const [templates, setTemplates] = useState([]); + const [sshKeys, setSSHKeys] = useState([]); + + useEffect(() => { + if (!open) return; + fetchTemplates(token) + .then((res) => { + setTemplates(res.data ?? []); + if (res.data && res.data.length > 0 && !templateID) { + setTemplateID(res.data[0].id); + } + }) + .catch(() => {}); + if (tenantID) { + fetchSSHKeys(token, tenantID) + .then((res) => setSSHKeys(res.data ?? [])) + .catch(() => {}); + } + }, [open, token, tenantID]); + + useEffect(() => { + if (templateID) { + const tpl = templates.find((t) => t.id === templateID); + if (tpl && !node) { + setNode(tpl.proxmox_node); + } + } + }, [templateID, templates]); + async function handleCreate() { setError(""); + if (!name.trim()) { + setError("Name erforderlich"); + return; + } + if (!templateID) { + setError("Template erforderlich"); + return; + } + if (!node.trim()) { + setError("Node erforderlich"); + return; + } setBusy(true); try { await createVM(token, projectID, { - name, + name: name.trim(), template_id: templateID, - node, + node: node.trim(), vcpu, ram_mb: ramMB, disk_gb: diskGB, ssh_key_id: sshKeyID || undefined, }); setName(""); - setTemplateID(""); setOpen(false); onCreated(); } catch (err) { @@ -73,12 +120,17 @@ export function VMCreate({ token, projectID, onCreated }: Props) { /> diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 53502da..7a3ec7e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,7 +1,13 @@ import React, { useEffect, useState } from "react"; import ReactDOM from "react-dom/client"; import { createClient, type Session } from "@supabase/supabase-js"; -import { fetchMe } from "./api"; +import { + fetchMe, + fetchWorkspace, + type Workspace, + type WorkspaceTenant, + type WorkspaceProject, +} from "./api"; import "./styles.css"; import { AuthScreen } from "./components/AuthScreen"; import { VMList } from "./components/VMList"; @@ -10,7 +16,7 @@ import { SSHKeys } from "./components/SSHKeys"; import { AuditLog } from "./components/AuditLog"; import { ConsoleView } from "./components/ConsoleView"; -type ViewKey = "overview" | "vms" | "ssh" | "audit" | "console"; +type ViewKey = "overview" | "projects" | "vms" | "ssh" | "audit" | "console"; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? ""; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? ""; @@ -41,6 +47,11 @@ function App() { "idle" | "loading" | "ok" | "error" >("idle"); const [backendError, setBackendError] = useState(""); + const [workspace, setWorkspace] = useState(null); + + // Context selections + const [selectedTenant, setSelectedTenant] = useState(null); + const [selectedProject, setSelectedProject] = useState(null); useEffect(() => { if (!supabase) { @@ -58,6 +69,9 @@ function App() { setBackendPrincipal(null); setBackendStatus("idle"); setBackendError(""); + setWorkspace(null); + setSelectedTenant(null); + setSelectedProject(null); }); return () => data.subscription.unsubscribe(); @@ -78,6 +92,16 @@ function App() { const principal = await fetchMe(session.access_token); setBackendPrincipal(principal); setBackendStatus("ok"); + + const ws = await fetchWorkspace(session.access_token); + setWorkspace(ws); + if (ws.tenants.length > 0) { + const firstTenant = ws.tenants[0]; + setSelectedTenant(firstTenant); + if (firstTenant.projects.length > 0) { + setSelectedProject(firstTenant.projects[0]); + } + } } catch (error) { setBackendStatus("error"); setBackendError( @@ -86,6 +110,16 @@ function App() { } } + function handleTenantChange(tenantID: string) { + const tenant = workspace?.tenants.find((t) => t.id === tenantID) ?? null; + setSelectedTenant(tenant); + if (tenant && tenant.projects.length > 0) { + setSelectedProject(tenant.projects[0]); + } else { + setSelectedProject(null); + } + } + const connected = Boolean(session && backendPrincipal); if (loadingSession) { @@ -158,42 +192,24 @@ function App() { -
-
-
-
-

Backend

-

Status

-
- -
-
- - - -
- {backendError ? ( -

{backendError}

- ) : null} -
+ +
@@ -201,29 +217,135 @@ function App() { ); } +function ContextBar({ + workspace, + selectedTenant, + selectedProject, + onTenantChange, + onProjectChange, + connected, + backendError, + backendStatus, + onRefresh, +}: { + workspace: Workspace | null; + selectedTenant: WorkspaceTenant | null; + selectedProject: WorkspaceProject | null; + onTenantChange: (id: string) => void; + onProjectChange: (p: WorkspaceProject | null) => void; + connected: boolean; + backendError: string; + backendStatus: "idle" | "loading" | "ok" | "error"; + onRefresh: () => void; +}) { + if (!workspace) { + return ( +
+ + {backendError ?

{backendError}

: null} +
+ ); + } + + return ( +
+
+ + {selectedTenant && selectedTenant.projects.length > 0 && ( + + )} +
+ + {connected ? "Online" : "Offline"} + +
+ ); +} + function ViewPanel({ activeView, - connected, token, + tenantID, + projectID, }: { activeView: ViewKey; - connected: boolean; token: string; + tenantID: string; + projectID: string; }) { - const projectID = "00000000-0000-0000-0000-000000000000"; + const [vmRefreshKey, setVMRefreshKey] = useState(0); + + if (!tenantID) { + return ( +
+

Keine Mandanten verfügbar.

+
+ ); + } return (
- {activeView === "overview" && } - {activeView === "vms" && } - {activeView === "ssh" && } - {activeView === "audit" && } - {activeView === "console" && } + {activeView === "overview" && ( + + )} + {activeView === "vms" && ( + setVMRefreshKey((k) => k + 1)} + /> + )} + {activeView === "ssh" && ( + + )} + {activeView === "audit" && ( + + )} + {activeView === "console" && }
); } -function Overview({ connected }: { connected: boolean }) { +function Overview({ + tenantID, + projectID, + token, +}: { + tenantID: string; + projectID: string; + token: string; +}) { return ( <>
@@ -231,34 +353,35 @@ function Overview({ connected }: { connected: boolean }) {

Status

Arbeitsbereich

- - {connected ? "Verbunden" : "Warte"} -
- Backend - {connected ? "Online" : "Offline"} + Tenant + {tenantID}
- RBAC - Aktiv -
-
- Worker - Aktiv -
-
- Audit - Aktiv + Projekt + {projectID || "Kein Projekt ausgewählt"}
); } -function VMView({ token, projectID }: { token: string; projectID: string }) { - const [refreshKey, setRefreshKey] = useState(0); +function VMView({ + token, + projectID, + tenantID, + onCreated, +}: { + token: string; + projectID: string; + tenantID: string; + onCreated: () => void; +}) { + if (!projectID) { + return

Kein Projekt ausgewählt.

; + } return ( <> @@ -271,31 +394,16 @@ function VMView({ token, projectID }: { token: string; projectID: string }) { setRefreshKey((k) => k + 1)} + tenantID={tenantID} + onCreated={onCreated} /> - + ); } -function Metric({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function statusLabel(status: "idle" | "loading" | "ok" | "error") { - if (status === "loading") return "Pruefung laeuft"; - if (status === "ok") return "Verbunden"; - if (status === "error") return "Fehler"; - return "Nicht geprueft"; -} - ReactDOM.createRoot(document.getElementById("root")!).render( -); +); \ No newline at end of file diff --git a/frontend/src/novnc.d.ts b/frontend/src/novnc.d.ts new file mode 100644 index 0000000..236d7d3 --- /dev/null +++ b/frontend/src/novnc.d.ts @@ -0,0 +1,15 @@ +declare module "@novnc/novnc" { + class RFB { + constructor( + target: HTMLElement, + url: string, + options?: { + credentials?: { password: string }; + wsProtocols?: string[]; + } + ); + scaleViewport: boolean; + resizeSession: boolean; + } + export default RFB; +} \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1de79a7..920c64d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -245,10 +245,120 @@ button { .content-grid { display: grid; - grid-template-columns: minmax(320px, 0.85fr) minmax(420px, 1.15fr); + grid-template-columns: 1fr; gap: 18px; } +.context-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + border: 1px solid #d6dee8; + border-radius: 8px; + background: #ffffff; + padding: 12px 16px; + margin-bottom: 18px; +} + +.context-selects { + display: flex; + gap: 14px; + align-items: center; +} + +.context-selects label { + display: flex; + align-items: center; + gap: 8px; + color: #4d5c68; + font-size: 13px; + font-weight: 700; +} + +.context-selects select { + border: 1px solid #c7d2de; + border-radius: 6px; + padding: 7px 10px; + font: inherit; + background: #ffffff; +} + +.vm-grid { + display: grid; + gap: 14px; + margin-top: 14px; +} + +.vm-card { + border: 1px solid #d6dee8; + border-radius: 8px; + background: #ffffff; + padding: 16px; +} + +.vm-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.vm-card-body { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +.vm-card-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.ssh-form { + display: grid; + gap: 10px; + margin-bottom: 14px; +} + +.ssh-form input, +.ssh-form textarea { + border: 1px solid #c7d2de; + border-radius: 6px; + padding: 8px 10px; + font: inherit; +} + +.metric-text { + color: #60717d; + font-size: 14px; +} + +.metric-list label { + display: grid; + gap: 4px; + color: #4d5c68; + font-size: 13px; + font-weight: 700; +} + +.metric-list input { + border: 1px solid #c7d2de; + border-radius: 6px; + padding: 8px 10px; + font: inherit; +} + +.ghost-button.danger { + border-color: #da8a7a; + color: #a13c21; +} + +.ghost-button.danger:hover { + background: #fdf3f0; +} + .panel { min-width: 0; border: 1px solid #d6dee8;