302 lines
8.3 KiB
TypeScript
302 lines
8.3 KiB
TypeScript
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 ViewKey = "overview" | "vms" | "ssh" | "audit" | "console";
|
|
|
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? "";
|
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? "";
|
|
|
|
const supabase =
|
|
supabaseUrl && supabaseAnonKey
|
|
? createClient(supabaseUrl, supabaseAnonKey)
|
|
: undefined;
|
|
|
|
const navItems: Array<{ key: ViewKey; label: string }> = [
|
|
{ key: "overview", label: "Uebersicht" },
|
|
{ key: "vms", label: "VMs" },
|
|
{ key: "ssh", label: "SSH-Keys" },
|
|
{ key: "audit", label: "Audit" },
|
|
{ key: "console", label: "Konsole" },
|
|
];
|
|
|
|
function App() {
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
const [loadingSession, setLoadingSession] = useState(true);
|
|
const [activeView, setActiveView] = useState<ViewKey>("overview");
|
|
const [backendPrincipal, setBackendPrincipal] = useState<{
|
|
Subject: string;
|
|
Email: string;
|
|
Role: string;
|
|
} | null>(null);
|
|
const [backendStatus, setBackendStatus] = useState<
|
|
"idle" | "loading" | "ok" | "error"
|
|
>("idle");
|
|
const [backendError, setBackendError] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!supabase) {
|
|
setLoadingSession(false);
|
|
return;
|
|
}
|
|
|
|
supabase.auth.getSession().then(({ data }) => {
|
|
setSession(data.session);
|
|
setLoadingSession(false);
|
|
});
|
|
|
|
const { data } = supabase.auth.onAuthStateChange((_event, nextSession) => {
|
|
setSession(nextSession);
|
|
setBackendPrincipal(null);
|
|
setBackendStatus("idle");
|
|
setBackendError("");
|
|
});
|
|
|
|
return () => data.subscription.unsubscribe();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (session) {
|
|
refreshBackendPrincipal();
|
|
}
|
|
}, [session]);
|
|
|
|
async function refreshBackendPrincipal() {
|
|
if (!session) return;
|
|
setBackendStatus("loading");
|
|
setBackendError("");
|
|
|
|
try {
|
|
const principal = await fetchMe(session.access_token);
|
|
setBackendPrincipal(principal);
|
|
setBackendStatus("ok");
|
|
} catch (error) {
|
|
setBackendStatus("error");
|
|
setBackendError(
|
|
error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
);
|
|
}
|
|
}
|
|
|
|
const connected = Boolean(session && backendPrincipal);
|
|
|
|
if (loadingSession) {
|
|
return (
|
|
<main className="startup-screen">
|
|
<div className="loader" />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (!supabase) {
|
|
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 supabase={supabase} />;
|
|
}
|
|
|
|
return (
|
|
<main className="app-frame">
|
|
<aside className="sidebar">
|
|
<div className="brand-mark">P</div>
|
|
<nav className="main-nav" aria-label="Hauptnavigation">
|
|
{navItems.map((item) => (
|
|
<button
|
|
className={
|
|
item.key === activeView ? "nav-item active" : "nav-item"
|
|
}
|
|
key={item.key}
|
|
onClick={() => setActiveView(item.key)}
|
|
type="button"
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</aside>
|
|
|
|
<section className="workspace">
|
|
<header className="topbar">
|
|
<div>
|
|
<p className="eyebrow">ProxUI</p>
|
|
<h1>Proxmox Multi-Tenant Console</h1>
|
|
</div>
|
|
<div className="session-box">
|
|
<span className={connected ? "status-dot ok" : "status-dot"} />
|
|
<div>
|
|
<strong>{session.user.email}</strong>
|
|
<span>
|
|
{connected ? "Backend verbunden" : "Session aktiv"}
|
|
</span>
|
|
</div>
|
|
<button
|
|
className="ghost-button"
|
|
onClick={() => supabase.auth.signOut()}
|
|
>
|
|
Abmelden
|
|
</button>
|
|
</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 ?? "-"}
|
|
/>
|
|
<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}
|
|
/>
|
|
</section>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function ViewPanel({
|
|
activeView,
|
|
connected,
|
|
token,
|
|
}: {
|
|
activeView: ViewKey;
|
|
connected: boolean;
|
|
token: string;
|
|
}) {
|
|
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">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>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 />
|
|
</React.StrictMode>
|
|
); |