feat: complete MVP epics E7-E10 (provisioning, console proxy, audit, frontend)
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
token: string,
|
||||
method = "GET",
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
method,
|
||||
headers: authHeaders(token),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
let message = `API error ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody);
|
||||
message = parsed.error ?? message;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export interface VM {
|
||||
id: string;
|
||||
project_id: string;
|
||||
tenant_id: string;
|
||||
cluster_id: string;
|
||||
proxmox_vmid: number;
|
||||
node: string;
|
||||
name: string;
|
||||
status: string;
|
||||
vcpu: number;
|
||||
ram_mb: number;
|
||||
disk_gb: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
project_name: string;
|
||||
membership_role: string;
|
||||
}
|
||||
|
||||
export interface SSHKey {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
public_key: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
profile_id: string;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectInfo {
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function fetchVMs(token: string, projectID: string) {
|
||||
return request<{ data: VM[] }>(`/projects/${projectID}/vms`, token);
|
||||
}
|
||||
|
||||
export function fetchSSHKeys(token: string, tenantID: string) {
|
||||
return request<{ data: SSHKey[] }>(`/tenants/${tenantID}/ssh-keys`, token);
|
||||
}
|
||||
|
||||
export function createSSHKey(
|
||||
token: string,
|
||||
tenantID: string,
|
||||
name: string,
|
||||
publicKey: string
|
||||
) {
|
||||
return request<SSHKey>(`/tenants/${tenantID}/ssh-keys`, token, "POST", {
|
||||
name,
|
||||
public_key: publicKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSSHKey(token: string, keyID: string) {
|
||||
return request<void>(`/ssh-keys/${keyID}`, token, "DELETE");
|
||||
}
|
||||
|
||||
export function createVM(
|
||||
token: string,
|
||||
projectID: string,
|
||||
data: {
|
||||
name: string;
|
||||
template_id: string;
|
||||
node: string;
|
||||
vcpu: number;
|
||||
ram_mb: number;
|
||||
disk_gb: number;
|
||||
ssh_key_id?: string;
|
||||
ci_user?: string;
|
||||
ip_config0?: string;
|
||||
}
|
||||
) {
|
||||
return request<{ vm: VM; upid: string; status: string }>(
|
||||
`/projects/${projectID}/vms`,
|
||||
token,
|
||||
"POST",
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
export function powerVM(token: string, vmID: string, action: "start" | "stop" | "reboot") {
|
||||
return request<{ upid: string; status: string }>(
|
||||
`/vms/${vmID}/${action}`,
|
||||
token,
|
||||
"POST"
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteVM(token: string, vmID: string) {
|
||||
return request<{ upid: string; status: string }>(
|
||||
`/vms/${vmID}`,
|
||||
token,
|
||||
"DELETE"
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAuditLog(token: string, tenantID: string) {
|
||||
return request<{ data: AuditEntry[] }>(`/tenants/${tenantID}/audit`, token);
|
||||
}
|
||||
|
||||
export function requestConsoleTicket(token: string, vmID: string) {
|
||||
return request<{ ticket: string }>(`/vms/${vmID}/console`, token, "POST");
|
||||
}
|
||||
|
||||
export function fetchMe(token: string) {
|
||||
return request<{ Subject: string; Email: string; Role: string }>(
|
||||
"/me",
|
||||
token
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AuditEntry } from "../api";
|
||||
import { fetchAuditLog } from "../api";
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
tenantID: string;
|
||||
}
|
||||
|
||||
export function AuditLog({ token, tenantID }: Props) {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchAuditLog(token, tenantID)
|
||||
.then((res) => setEntries((res as unknown as { data: AuditEntry[] }).data ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token, tenantID]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Audit</p>
|
||||
<h2>Log</h2>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{loading ? (
|
||||
<p className="metric-text">Lade...</p>
|
||||
) : (
|
||||
<div className="placeholder-table">
|
||||
{entries.map((e) => (
|
||||
<div className="placeholder-row" key={e.id}>
|
||||
<span>{new Date(e.created_at).toLocaleString()}</span>
|
||||
<strong>{e.action}</strong>
|
||||
<span>{e.target_type}/{e.target_id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useState } from "react";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
interface Props {
|
||||
supabase: SupabaseClient;
|
||||
}
|
||||
|
||||
export function AuthScreen({ supabase }: Props) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [mode, setMode] = useState<"signin" | "signup">("signin");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setMessage("");
|
||||
|
||||
const result =
|
||||
mode === "signin"
|
||||
? await supabase.auth.signInWithPassword({ email, password })
|
||||
: await supabase.auth.signUp({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setMessage(result.error.message);
|
||||
}
|
||||
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-screen">
|
||||
<section className="auth-panel">
|
||||
<p className="eyebrow">ProxUI</p>
|
||||
<h1>Anmelden</h1>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label>
|
||||
E-Mail
|
||||
<input
|
||||
autoComplete="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input
|
||||
autoComplete={
|
||||
mode === "signin" ? "current-password" : "new-password"
|
||||
}
|
||||
minLength={6}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</label>
|
||||
{message ? <p className="form-error">{message}</p> : null}
|
||||
<div className="auth-actions">
|
||||
<button className="primary-button" disabled={busy} type="submit">
|
||||
{mode === "signin" ? "Einloggen" : "Account erstellen"}
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() =>
|
||||
setMode(mode === "signin" ? "signup" : "signin")
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{mode === "signin" ? "Registrieren" : "Zum Login"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import { requestConsoleTicket } from "../api";
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function ConsoleView({ token }: Props) {
|
||||
const [vmID, setVMID] = useState("");
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleConnect() {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await requestConsoleTicket(token, vmID);
|
||||
setTicket(data.ticket);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fehler");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Konsole</p>
|
||||
<h2>Web-Konsole</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="metric-list">
|
||||
<label>
|
||||
VM-ID
|
||||
<input
|
||||
value={vmID}
|
||||
onChange={(e) => setVMID(e.target.value)}
|
||||
placeholder="UUID der VM"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading || !vmID}
|
||||
onClick={handleConnect}
|
||||
>
|
||||
{loading ? "Verbinde..." : "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>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SSHKey } from "../api";
|
||||
import { fetchSSHKeys, createSSHKey, deleteSSHKey } from "../api";
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
tenantID: string;
|
||||
}
|
||||
|
||||
export function SSHKeys({ token, tenantID }: Props) {
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchSSHKeys(token, tenantID)
|
||||
.then((res) => setKeys(res.data ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token, tenantID]);
|
||||
|
||||
async function handleCreate() {
|
||||
setError("");
|
||||
try {
|
||||
await createSSHKey(token, tenantID, newName, newKey);
|
||||
setNewName("");
|
||||
setNewKey("");
|
||||
const res = await fetchSSHKeys(token, tenantID);
|
||||
setKeys(res.data ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fehler");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(keyID: string) {
|
||||
try {
|
||||
await deleteSSHKey(token, keyID);
|
||||
const res = await fetchSSHKeys(token, tenantID);
|
||||
setKeys(res.data ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fehler");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">SSH-Keys</p>
|
||||
<h2>Verwaltung</h2>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="ssh-form">
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="ssh-ed25519 AAA..."
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<button className="primary-button" onClick={handleCreate}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="metric-text">Lade...</p>
|
||||
) : (
|
||||
<div className="placeholder-table">
|
||||
{keys.map((k) => (
|
||||
<div className="placeholder-row" key={k.id}>
|
||||
<span>{k.name}</span>
|
||||
<strong>{k.public_key.slice(0, 40)}...</strong>
|
||||
<button
|
||||
className="ghost-button danger"
|
||||
onClick={() => handleDelete(k.id)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from "react";
|
||||
import { createVM } from "../api";
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
projectID: string;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function VMCreate({ token, projectID, onCreated }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [templateID, setTemplateID] = useState("");
|
||||
const [node, setNode] = useState("");
|
||||
const [vcpu, setVCPU] = useState(2);
|
||||
const [ramMB, setRAMMB] = useState(2048);
|
||||
const [diskGB, setDiskGB] = useState(10);
|
||||
const [sshKeyID, setSSHKeyID] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await createVM(token, projectID, {
|
||||
name,
|
||||
template_id: templateID,
|
||||
node,
|
||||
vcpu,
|
||||
ram_mb: ramMB,
|
||||
disk_gb: diskGB,
|
||||
ssh_key_id: sshKeyID || undefined,
|
||||
});
|
||||
setName("");
|
||||
setTemplateID("");
|
||||
setOpen(false);
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fehler");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button className="primary-button" onClick={() => setOpen(true)}>
|
||||
Neue VM erstellen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Provisioning</p>
|
||||
<h2>VM erstellen</h2>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={() => setOpen(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="metric-list">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="meine-vm"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Template-ID
|
||||
<input
|
||||
value={templateID}
|
||||
onChange={(e) => setTemplateID(e.target.value)}
|
||||
placeholder="UUID des Templates"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Node
|
||||
<input
|
||||
value={node}
|
||||
onChange={(e) => setNode(e.target.value)}
|
||||
placeholder="pve"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
vCPU
|
||||
<input
|
||||
type="number"
|
||||
value={vcpu}
|
||||
onChange={(e) => setVCPU(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
RAM (MB)
|
||||
<input
|
||||
type="number"
|
||||
value={ramMB}
|
||||
onChange={(e) => setRAMMB(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Disk (GB)
|
||||
<input
|
||||
type="number"
|
||||
value={diskGB}
|
||||
onChange={(e) => setDiskGB(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
SSH-Key-ID (optional)
|
||||
<input
|
||||
value={sshKeyID}
|
||||
onChange={(e) => setSSHKeyID(e.target.value)}
|
||||
placeholder="UUID"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={busy}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{busy ? "Erstelle..." : "VM erstellen"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { VM } from "../api";
|
||||
import { fetchVMs, powerVM, deleteVM } from "../api";
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
projectID: string;
|
||||
}
|
||||
|
||||
export function VMList({ token, projectID }: Props) {
|
||||
const [vms, setVMs] = useState<VM[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetchVMs(token, projectID)
|
||||
.then((res) => setVMs(res.data ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token, projectID]);
|
||||
|
||||
async function handlePower(vmID: string, action: "start" | "stop" | "reboot") {
|
||||
try {
|
||||
await powerVM(token, vmID, action);
|
||||
const res = await fetchVMs(token, projectID);
|
||||
setVMs(res.data ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fehler");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(vmID: string) {
|
||||
if (!confirm("VM wirklich löschen?")) return;
|
||||
try {
|
||||
await deleteVM(token, vmID);
|
||||
const res = await fetchVMs(token, projectID);
|
||||
setVMs(res.data ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fehler");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p className="metric-text">Lade VMs...</p>;
|
||||
if (error) return <p className="form-error">{error}</p>;
|
||||
if (vms.length === 0)
|
||||
return <p className="metric-text">Keine VMs in diesem Projekt.</p>;
|
||||
|
||||
return (
|
||||
<div className="vm-grid">
|
||||
{vms.map((vm) => (
|
||||
<div className="vm-card" key={vm.id}>
|
||||
<div className="vm-card-header">
|
||||
<strong>{vm.name}</strong>
|
||||
<span className={`pill ${vm.status === "running" ? "ok" : ""}`}>
|
||||
{vm.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vm-card-body">
|
||||
<div className="metric">
|
||||
<span>Node</span>
|
||||
<strong>{vm.node}</strong>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span>vCPU / RAM / Disk</span>
|
||||
<strong>
|
||||
{vm.vcpu} / {vm.ram_mb} MB / {vm.disk_gb} GB
|
||||
</strong>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span>Proxmox VMID</span>
|
||||
<strong>{vm.proxmox_vmid}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vm-card-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => handlePower(vm.id, "start")}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => handlePower(vm.id, "stop")}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => handlePower(vm.id, "reboot")}
|
||||
>
|
||||
Reboot
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button danger"
|
||||
onClick={() => handleDelete(vm.id)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+144
-248
@@ -1,19 +1,20 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { createClient, type Session } from "@supabase/supabase-js";
|
||||
import type { VM } from "./api";
|
||||
import { fetchMe } from "./api";
|
||||
import "./styles.css";
|
||||
import { AuthScreen } from "./components/AuthScreen";
|
||||
import { VMList } from "./components/VMList";
|
||||
import { VMCreate } from "./components/VMCreate";
|
||||
import { SSHKeys } from "./components/SSHKeys";
|
||||
import { AuditLog } from "./components/AuditLog";
|
||||
import { ConsoleView } from "./components/ConsoleView";
|
||||
|
||||
type BackendPrincipal = {
|
||||
Subject: string;
|
||||
Email: string;
|
||||
Role: string;
|
||||
};
|
||||
|
||||
type ViewKey = "overview" | "projects" | "vms" | "ssh" | "audit" | "console";
|
||||
type ViewKey = "overview" | "vms" | "ssh" | "audit" | "console";
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? "";
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? "";
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
const supabase =
|
||||
supabaseUrl && supabaseAnonKey
|
||||
@@ -22,7 +23,6 @@ const supabase =
|
||||
|
||||
const navItems: Array<{ key: ViewKey; label: string }> = [
|
||||
{ key: "overview", label: "Uebersicht" },
|
||||
{ key: "projects", label: "Projekte" },
|
||||
{ key: "vms", label: "VMs" },
|
||||
{ key: "ssh", label: "SSH-Keys" },
|
||||
{ key: "audit", label: "Audit" },
|
||||
@@ -33,8 +33,11 @@ function App() {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loadingSession, setLoadingSession] = useState(true);
|
||||
const [activeView, setActiveView] = useState<ViewKey>("overview");
|
||||
const [backendPrincipal, setBackendPrincipal] =
|
||||
useState<BackendPrincipal | null>(null);
|
||||
const [backendPrincipal, setBackendPrincipal] = useState<{
|
||||
Subject: string;
|
||||
Email: string;
|
||||
Role: string;
|
||||
} | null>(null);
|
||||
const [backendStatus, setBackendStatus] = useState<
|
||||
"idle" | "loading" | "ok" | "error"
|
||||
>("idle");
|
||||
@@ -61,36 +64,31 @@ function App() {
|
||||
return () => data.subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const connected = Boolean(session && backendPrincipal);
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
refreshBackendPrincipal();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
async function refreshBackendPrincipal() {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session) return;
|
||||
setBackendStatus("loading");
|
||||
setBackendError("");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Backend antwortet mit ${response.status}`);
|
||||
}
|
||||
|
||||
const principal = (await response.json()) as BackendPrincipal;
|
||||
const principal = await fetchMe(session.access_token);
|
||||
setBackendPrincipal(principal);
|
||||
setBackendStatus("ok");
|
||||
} catch (error) {
|
||||
setBackendStatus("error");
|
||||
setBackendError(error instanceof Error ? error.message : "Unbekannter Fehler");
|
||||
setBackendError(
|
||||
error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const connected = Boolean(session && backendPrincipal);
|
||||
|
||||
if (loadingSession) {
|
||||
return (
|
||||
<main className="startup-screen">
|
||||
@@ -100,11 +98,22 @@ function App() {
|
||||
}
|
||||
|
||||
if (!supabase) {
|
||||
return <MissingConfig />;
|
||||
return (
|
||||
<main className="startup-screen">
|
||||
<section className="auth-panel">
|
||||
<p className="eyebrow">Konfiguration</p>
|
||||
<h1>Frontend Env fehlt</h1>
|
||||
<p>
|
||||
`VITE_SUPABASE_URL` und `VITE_SUPABASE_ANON_KEY` mussen gesetzt
|
||||
sein.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <AuthScreen />;
|
||||
return <AuthScreen supabase={supabase} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -114,7 +123,9 @@ function App() {
|
||||
<nav className="main-nav" aria-label="Hauptnavigation">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
className={item.key === activeView ? "nav-item active" : "nav-item"}
|
||||
className={
|
||||
item.key === activeView ? "nav-item active" : "nav-item"
|
||||
}
|
||||
key={item.key}
|
||||
onClick={() => setActiveView(item.key)}
|
||||
type="button"
|
||||
@@ -135,176 +146,136 @@ function App() {
|
||||
<span className={connected ? "status-dot ok" : "status-dot"} />
|
||||
<div>
|
||||
<strong>{session.user.email}</strong>
|
||||
<span>{connected ? "Backend verbunden" : "Session aktiv"}</span>
|
||||
<span>
|
||||
{connected ? "Backend verbunden" : "Session aktiv"}
|
||||
</span>
|
||||
</div>
|
||||
<button className="ghost-button" onClick={() => supabase.auth.signOut()}>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => supabase.auth.signOut()}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="content-grid">
|
||||
<BackendPanel
|
||||
backendError={backendError}
|
||||
backendPrincipal={backendPrincipal}
|
||||
backendStatus={backendStatus}
|
||||
onRefresh={refreshBackendPrincipal}
|
||||
<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 ?? "-"}
|
||||
/>
|
||||
<Metric
|
||||
label="Rolle"
|
||||
value={backendPrincipal?.Role ?? "-"}
|
||||
/>
|
||||
</div>
|
||||
{backendError ? (
|
||||
<p className="form-error">{backendError}</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<ViewPanel
|
||||
activeView={activeView}
|
||||
connected={connected}
|
||||
token={session.access_token}
|
||||
/>
|
||||
<ViewPanel activeView={activeView} connected={connected} />
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MissingConfig() {
|
||||
return (
|
||||
<main className="startup-screen">
|
||||
<section className="auth-panel">
|
||||
<p className="eyebrow">Konfiguration</p>
|
||||
<h1>Frontend Env fehlt</h1>
|
||||
<p>
|
||||
`VITE_SUPABASE_URL` und `VITE_SUPABASE_ANON_KEY` muessen gesetzt sein.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthScreen() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [mode, setMode] = useState<"signin" | "signup">("signin");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setMessage("");
|
||||
|
||||
const result =
|
||||
mode === "signin"
|
||||
? await supabase!.auth.signInWithPassword({ email, password })
|
||||
: await supabase!.auth.signUp({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setMessage(result.error.message);
|
||||
}
|
||||
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-screen">
|
||||
<section className="auth-panel">
|
||||
<p className="eyebrow">ProxUI</p>
|
||||
<h1>Anmelden</h1>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label>
|
||||
E-Mail
|
||||
<input
|
||||
autoComplete="email"
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input
|
||||
autoComplete={mode === "signin" ? "current-password" : "new-password"}
|
||||
minLength={6}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</label>
|
||||
{message ? <p className="form-error">{message}</p> : null}
|
||||
<div className="auth-actions">
|
||||
<button className="primary-button" disabled={busy} type="submit">
|
||||
{mode === "signin" ? "Einloggen" : "Account erstellen"}
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setMode(mode === "signin" ? "signup" : "signin")}
|
||||
type="button"
|
||||
>
|
||||
{mode === "signin" ? "Registrieren" : "Zum Login"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendPanel({
|
||||
backendError,
|
||||
backendPrincipal,
|
||||
backendStatus,
|
||||
onRefresh,
|
||||
}: {
|
||||
backendError: string;
|
||||
backendPrincipal: BackendPrincipal | null;
|
||||
backendStatus: "idle" | "loading" | "ok" | "error";
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="panel backend-panel">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Backend</p>
|
||||
<h2>Profil-Sync</h2>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={backendStatus === "loading"}
|
||||
onClick={onRefresh}
|
||||
type="button"
|
||||
>
|
||||
Pruefen
|
||||
</button>
|
||||
</div>
|
||||
<div className="metric-list">
|
||||
<Metric label="Status" value={statusLabel(backendStatus)} />
|
||||
<Metric label="User-ID" value={backendPrincipal?.Subject ?? "-"} />
|
||||
<Metric label="Rolle" value={backendPrincipal?.Role ?? "-"} />
|
||||
</div>
|
||||
{backendError ? <p className="form-error">{backendError}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewPanel({
|
||||
activeView,
|
||||
connected,
|
||||
token,
|
||||
}: {
|
||||
activeView: ViewKey;
|
||||
connected: boolean;
|
||||
token: string;
|
||||
}) {
|
||||
const content = useMemo(() => viewContent(activeView), [activeView]);
|
||||
const projectID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
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} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Overview({ connected }: { connected: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">{content.state}</p>
|
||||
<h2>{content.title}</h2>
|
||||
<p className="eyebrow">Status</p>
|
||||
<h2>Arbeitsbereich</h2>
|
||||
</div>
|
||||
<span className={connected ? "pill ok" : "pill"}>{content.badge}</span>
|
||||
<span className={connected ? "pill ok" : "pill"}>
|
||||
{connected ? "Verbunden" : "Warte"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="placeholder-table">
|
||||
{content.rows.map((row) => (
|
||||
<div className="placeholder-row" key={row.label}>
|
||||
<span>{row.label}</span>
|
||||
<strong>{row.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
<div className="placeholder-row">
|
||||
<span>Backend</span>
|
||||
<strong>{connected ? "Online" : "Offline"}</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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VMView({ token, projectID }: { token: string; projectID: string }) {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">VMs</p>
|
||||
<h2>Virtuelle Maschinen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<VMCreate
|
||||
token={token}
|
||||
projectID={projectID}
|
||||
onCreated={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
<VMList key={refreshKey} token={token} projectID={projectID} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,83 +295,8 @@ function statusLabel(status: "idle" | "loading" | "ok" | "error") {
|
||||
return "Nicht geprueft";
|
||||
}
|
||||
|
||||
function viewContent(activeView: ViewKey) {
|
||||
const values: Record<
|
||||
ViewKey,
|
||||
{
|
||||
badge: string;
|
||||
rows: Array<{ label: string; value: string }>;
|
||||
state: string;
|
||||
title: string;
|
||||
}
|
||||
> = {
|
||||
overview: {
|
||||
badge: "Live",
|
||||
state: "Status",
|
||||
title: "Arbeitsbereich",
|
||||
rows: [
|
||||
{ label: "Auth", value: "angebunden" },
|
||||
{ label: "Profil-Sync", value: "angebunden" },
|
||||
{ label: "RLS", value: "aktiv" },
|
||||
],
|
||||
},
|
||||
projects: {
|
||||
badge: "Platzhalter",
|
||||
state: "E2-T03",
|
||||
title: "Projekte",
|
||||
rows: [
|
||||
{ label: "Schema", value: "projects" },
|
||||
{ label: "Quotas", value: "project_quotas" },
|
||||
{ label: "API", value: "offen" },
|
||||
],
|
||||
},
|
||||
vms: {
|
||||
badge: "Platzhalter",
|
||||
state: "E2-T05",
|
||||
title: "Virtuelle Maschinen",
|
||||
rows: [
|
||||
{ label: "Schema", value: "vms" },
|
||||
{ label: "VMID-Allokator", value: "bereit" },
|
||||
{ label: "Proxmox Client", value: "offen" },
|
||||
],
|
||||
},
|
||||
ssh: {
|
||||
badge: "Platzhalter",
|
||||
state: "E2-T05",
|
||||
title: "SSH-Keys",
|
||||
rows: [
|
||||
{ label: "Schema", value: "ssh_keys" },
|
||||
{ label: "RLS", value: "aktiv" },
|
||||
{ label: "Upload/API", value: "offen" },
|
||||
],
|
||||
},
|
||||
audit: {
|
||||
badge: "Platzhalter",
|
||||
state: "E2-T07",
|
||||
title: "Audit Log",
|
||||
rows: [
|
||||
{ label: "Schema", value: "audit_log" },
|
||||
{ label: "Append-only", value: "aktiv" },
|
||||
{ label: "Anzeige", value: "offen" },
|
||||
],
|
||||
},
|
||||
console: {
|
||||
badge: "Platzhalter",
|
||||
state: "E8",
|
||||
title: "Web-Konsole",
|
||||
rows: [
|
||||
{ label: "Proxy", value: "Grundgeruest" },
|
||||
{ label: "Tickets", value: "offen" },
|
||||
{ label: "noVNC/xterm", value: "offen" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return values[activeView];
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
Reference in New Issue
Block a user