feat: workspace endpoint, template/ssh-key dropdowns, noVNC console integration

This commit is contained in:
Philipp
2026-06-12 10:59:02 +02:00
parent e54523040a
commit b554206bbf
11 changed files with 676 additions and 135 deletions
+20 -15
View File
@@ -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
+10
View File
@@ -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))
}
+135
View File
@@ -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})
}
+7
View File
@@ -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",
+5 -4
View File
@@ -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"
+35
View File
@@ -155,3 +155,38 @@ export function fetchMe(token: string) {
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<Workspace>("/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);
}
+77 -21
View File
@@ -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<VM[]>([]);
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<HTMLDivElement>(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) {
</div>
<div className="metric-list">
<label>
VM-ID
<input
value={vmID}
onChange={(e) => setVMID(e.target.value)}
placeholder="UUID der VM"
/>
VM
<select
value={selectedVMID}
onChange={(e) => setSelectedVMID(e.target.value)}
>
<option value="">VM auswählen</option>
{vms.map((vm) => (
<option key={vm.id} value={vm.id}>
{vm.name} ({vm.status})
</option>
))}
</select>
</label>
</div>
<button
className="primary-button"
disabled={loading || !vmID}
disabled={loading || !selectedVMID}
onClick={handleConnect}
style={{ marginTop: 12, marginBottom: 12 }}
>
{loading ? "Verbinde..." : "Konsole öffnen"}
{loading ? "Verbinde..." : connected ? "Neu verbinden" : "Konsole öffnen"}
</button>
{error ? <p className="form-error">{error}</p> : null}
{ticket ? (
<div className="metric-list" style={{ marginTop: "1rem" }}>
<p className="metric-text">
Proxy-Ticket erhalten. Verbinde zum console-proxy mit:
</p>
<code>?ticket={ticket}</code>
{ticket && connected ? (
<div
ref={canvasRef}
style={{
width: "100%",
height: "480px",
border: "1px solid #d6dee8",
borderRadius: "6px",
background: "#000",
marginTop: 14,
}}
/>
) : (
ticket && (
<div className="metric-list" style={{ marginTop: 14 }}>
<p className="metric-text">Ticket erhalten, aber noch keine Verbindung.</p>
</div>
) : null}
)
)}
</div>
);
}
+73 -14
View File
@@ -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<Template[]>([]);
const [sshKeys, setSSHKeys] = useState<SSHKey[]>([]);
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) {
/>
</label>
<label>
Template-ID
<input
Template
<select
value={templateID}
onChange={(e) => setTemplateID(e.target.value)}
placeholder="UUID des Templates"
/>
>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.name} (VMID {t.proxmox_template_vmid}, {t.proxmox_node})
</option>
))}
</select>
</label>
<label>
Node
@@ -113,18 +165,25 @@ export function VMCreate({ token, projectID, onCreated }: Props) {
/>
</label>
<label>
SSH-Key-ID (optional)
<input
SSH-Key (optional)
<select
value={sshKeyID}
onChange={(e) => setSSHKeyID(e.target.value)}
placeholder="UUID"
/>
>
<option value="">Kein SSH-Key</option>
{sshKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name}
</option>
))}
</select>
</label>
</div>
<button
className="primary-button"
disabled={busy}
onClick={handleCreate}
style={{ marginTop: 14 }}
>
{busy ? "Erstelle..." : "VM erstellen"}
</button>
+188 -80
View File
@@ -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<Workspace | null>(null);
// Context selections
const [selectedTenant, setSelectedTenant] = useState<WorkspaceTenant | null>(null);
const [selectedProject, setSelectedProject] = useState<WorkspaceProject | null>(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() {
</div>
</header>
<section className="content-grid">
<section className="panel backend-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Backend</p>
<h2>Status</h2>
</div>
<button
className="primary-button"
disabled={backendStatus === "loading"}
onClick={refreshBackendPrincipal}
type="button"
>
Pruefen
</button>
</div>
<div className="metric-list">
<Metric label="Status" value={statusLabel(backendStatus)} />
<Metric
label="User-ID"
value={backendPrincipal?.Subject ?? "-"}
<ContextBar
workspace={workspace}
selectedTenant={selectedTenant}
selectedProject={selectedProject}
onTenantChange={handleTenantChange}
onProjectChange={setSelectedProject}
connected={connected}
backendError={backendError}
backendStatus={backendStatus}
onRefresh={refreshBackendPrincipal}
/>
<Metric
label="Rolle"
value={backendPrincipal?.Role ?? "-"}
/>
</div>
{backendError ? (
<p className="form-error">{backendError}</p>
) : null}
</section>
<section className="content-grid">
<ViewPanel
activeView={activeView}
connected={connected}
token={session.access_token}
tenantID={selectedTenant?.id ?? ""}
projectID={selectedProject?.id ?? ""}
/>
</section>
</section>
@@ -201,29 +217,135 @@ function App() {
);
}
function ViewPanel({
activeView,
function ContextBar({
workspace,
selectedTenant,
selectedProject,
onTenantChange,
onProjectChange,
connected,
token,
backendError,
backendStatus,
onRefresh,
}: {
activeView: ViewKey;
workspace: Workspace | null;
selectedTenant: WorkspaceTenant | null;
selectedProject: WorkspaceProject | null;
onTenantChange: (id: string) => void;
onProjectChange: (p: WorkspaceProject | null) => void;
connected: boolean;
token: string;
backendError: string;
backendStatus: "idle" | "loading" | "ok" | "error";
onRefresh: () => void;
}) {
const projectID = "00000000-0000-0000-0000-000000000000";
if (!workspace) {
return (
<div className="context-bar">
<button className="primary-button" onClick={onRefresh} type="button">
{backendStatus === "loading" ? "Lade..." : "Verbinden"}
</button>
{backendError ? <p className="form-error">{backendError}</p> : null}
</div>
);
}
return (
<div className="context-bar">
<div className="context-selects">
<label>
Mandant
<select
value={selectedTenant?.id ?? ""}
onChange={(e) => onTenantChange(e.target.value)}
>
{workspace.tenants.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.role})
</option>
))}
</select>
</label>
{selectedTenant && selectedTenant.projects.length > 0 && (
<label>
Projekt
<select
value={selectedProject?.id ?? ""}
onChange={(e) => {
const p = selectedTenant.projects.find((pr) => pr.id === e.target.value) ?? null;
onProjectChange(p);
}}
>
{selectedTenant.projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
)}
</div>
<span className={`pill ${connected ? "ok" : ""}`}>
{connected ? "Online" : "Offline"}
</span>
</div>
);
}
function ViewPanel({
activeView,
token,
tenantID,
projectID,
}: {
activeView: ViewKey;
token: string;
tenantID: string;
projectID: string;
}) {
const [vmRefreshKey, setVMRefreshKey] = useState(0);
if (!tenantID) {
return (
<section className="panel view-panel">
{activeView === "overview" && <Overview connected={connected} />}
{activeView === "vms" && <VMView token={token} projectID={projectID} />}
{activeView === "ssh" && <SSHKeys token={token} tenantID={projectID} />}
{activeView === "audit" && <AuditLog token={token} tenantID={projectID} />}
{activeView === "console" && <ConsoleView token={token} />}
<p className="metric-text">Keine Mandanten verfügbar.</p>
</section>
);
}
function Overview({ connected }: { connected: boolean }) {
return (
<section className="panel view-panel">
{activeView === "overview" && (
<Overview tenantID={tenantID} projectID={projectID} token={token} />
)}
{activeView === "vms" && (
<VMView
key={vmRefreshKey}
token={token}
projectID={projectID}
tenantID={tenantID}
onCreated={() => setVMRefreshKey((k) => k + 1)}
/>
)}
{activeView === "ssh" && (
<SSHKeys token={token} tenantID={tenantID} />
)}
{activeView === "audit" && (
<AuditLog token={token} tenantID={tenantID} />
)}
{activeView === "console" && <ConsoleView token={token} projectID={projectID} />}
</section>
);
}
function Overview({
tenantID,
projectID,
token,
}: {
tenantID: string;
projectID: string;
token: string;
}) {
return (
<>
<div className="panel-header">
@@ -231,34 +353,35 @@ function Overview({ connected }: { connected: boolean }) {
<p className="eyebrow">Status</p>
<h2>Arbeitsbereich</h2>
</div>
<span className={connected ? "pill ok" : "pill"}>
{connected ? "Verbunden" : "Warte"}
</span>
</div>
<div className="placeholder-table">
<div className="placeholder-row">
<span>Backend</span>
<strong>{connected ? "Online" : "Offline"}</strong>
<span>Tenant</span>
<strong>{tenantID}</strong>
</div>
<div className="placeholder-row">
<span>RBAC</span>
<strong>Aktiv</strong>
</div>
<div className="placeholder-row">
<span>Worker</span>
<strong>Aktiv</strong>
</div>
<div className="placeholder-row">
<span>Audit</span>
<strong>Aktiv</strong>
<span>Projekt</span>
<strong>{projectID || "Kein Projekt ausgewählt"}</strong>
</div>
</div>
</>
);
}
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 <p className="metric-text">Kein Projekt ausgewählt.</p>;
}
return (
<>
@@ -271,29 +394,14 @@ function VMView({ token, projectID }: { token: string; projectID: string }) {
<VMCreate
token={token}
projectID={projectID}
onCreated={() => setRefreshKey((k) => k + 1)}
tenantID={tenantID}
onCreated={onCreated}
/>
<VMList key={refreshKey} token={token} projectID={projectID} />
<VMList token={token} projectID={projectID} />
</>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
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(
<React.StrictMode>
<App />
+15
View File
@@ -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;
}
+111 -1
View File
@@ -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;