feat: refine cloud console UI

This commit is contained in:
Philipp
2026-06-12 15:11:55 +02:00
parent 452d1fb8b3
commit d8a12a41c8
4 changed files with 515 additions and 244 deletions
+11 -8
View File
@@ -18,9 +18,9 @@ export function VMCreate({ token, projectID, tenantID, 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 [vcpu, setVCPU] = useState(4);
const [ramMB, setRAMMB] = useState(6144);
const [diskGB, setDiskGB] = useState(30);
const [sshKeyID, setSSHKeyID] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@@ -92,14 +92,17 @@ export function VMCreate({ token, projectID, tenantID, onCreated }: Props) {
if (!open) {
return (
<button className="primary-button" onClick={() => setOpen(true)}>
Neue VM erstellen
</button>
<div className="create-launch">
<span>Server aus einem Template bereitstellen</span>
<button className="primary-button" onClick={() => setOpen(true)}>
Server erstellen
</button>
</div>
);
}
return (
<div className="panel">
<div className="provision-box">
<div className="panel-header">
<div>
<p className="eyebrow">Provisioning</p>
@@ -110,7 +113,7 @@ export function VMCreate({ token, projectID, tenantID, onCreated }: Props) {
</button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="metric-list">
<div className="provision-grid">
<label>
Name
<input
+60 -53
View File
@@ -48,59 +48,66 @@ export function VMList({ token, projectID }: Props) {
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 className="table-wrap">
<table className="resource-table">
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>Node</th>
<th>Ressourcen</th>
<th>VMID</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{vms.map((vm) => (
<tr key={vm.id}>
<td>
<span className={`pill ${vm.status === "running" ? "ok" : ""}`}>
{vm.status}
</span>
</td>
<td>
<strong className="resource-name">{vm.name}</strong>
</td>
<td>{vm.node}</td>
<td>
{vm.vcpu} vCPU · {vm.ram_mb} MB · {vm.disk_gb} GB
</td>
<td>{vm.proxmox_vmid}</td>
<td>
<div className="resource-actions">
<button
className="ghost-button compact"
onClick={() => handlePower(vm.id, "start")}
>
Start
</button>
<button
className="ghost-button compact"
onClick={() => handlePower(vm.id, "stop")}
>
Stop
</button>
<button
className="ghost-button compact"
onClick={() => handlePower(vm.id, "reboot")}
>
Reboot
</button>
<button
className="ghost-button compact danger"
onClick={() => handleDelete(vm.id)}
>
Löschen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+47 -12
View File
@@ -27,10 +27,10 @@ const supabase =
: undefined;
const navItems: Array<{ key: ViewKey; label: string }> = [
{ key: "overview", label: "Uebersicht" },
{ key: "vms", label: "VMs" },
{ key: "overview", label: "Projekt" },
{ key: "vms", label: "Server" },
{ key: "ssh", label: "SSH-Keys" },
{ key: "audit", label: "Audit" },
{ key: "audit", label: "Audit Logs" },
{ key: "console", label: "Konsole" },
];
@@ -121,6 +121,7 @@ function App() {
}
const connected = Boolean(session && backendPrincipal);
const activeTitle = pageTitle(activeView);
if (loadingSession) {
return (
@@ -152,8 +153,19 @@ function App() {
return (
<main className="app-frame">
<aside className="sidebar">
<div className="brand-mark">P</div>
<div className="sidebar-brand">
<div className="brand-mark">P</div>
<div className="brand-copy">
<strong>ProxUI</strong>
<span>Cloud Console</span>
</div>
</div>
<div className="project-chip">
<span>Projekt</span>
<strong>{selectedProject?.name ?? "Nicht gewählt"}</strong>
</div>
<nav className="main-nav" aria-label="Hauptnavigation">
<span className="nav-section-label">Resources</span>
{navItems.map((item) => (
<button
className={
@@ -173,7 +185,7 @@ function App() {
<header className="topbar">
<div>
<p className="eyebrow">ProxUI</p>
<h1>Proxmox Multi-Tenant Console</h1>
<h1>{activeTitle}</h1>
</div>
<div className="session-box">
<span className={connected ? "status-dot ok" : "status-dot"} />
@@ -209,7 +221,9 @@ function App() {
activeView={activeView}
token={session.access_token}
tenantID={selectedTenant?.id ?? ""}
tenantName={selectedTenant?.name ?? ""}
projectID={selectedProject?.id ?? ""}
projectName={selectedProject?.name ?? ""}
/>
</section>
</section>
@@ -295,12 +309,16 @@ function ViewPanel({
activeView,
token,
tenantID,
tenantName,
projectID,
projectName,
}: {
activeView: ViewKey;
token: string;
tenantID: string;
tenantName: string;
projectID: string;
projectName: string;
}) {
const [vmRefreshKey, setVMRefreshKey] = useState(0);
@@ -315,7 +333,12 @@ function ViewPanel({
return (
<section className="panel view-panel">
{activeView === "overview" && (
<Overview tenantID={tenantID} projectID={projectID} />
<Overview
tenantID={tenantID}
tenantName={tenantName}
projectID={projectID}
projectName={projectName}
/>
)}
{activeView === "vms" && (
<VMView
@@ -339,27 +362,31 @@ function ViewPanel({
function Overview({
tenantID,
tenantName,
projectID,
projectName,
}: {
tenantID: string;
tenantName: string;
projectID: string;
projectName: string;
}) {
return (
<>
<div className="panel-header">
<div>
<p className="eyebrow">Status</p>
<p className="eyebrow">Projekt</p>
<h2>Arbeitsbereich</h2>
</div>
</div>
<div className="placeholder-table">
<div className="placeholder-row">
<span>Tenant</span>
<strong>{tenantID}</strong>
<span>Mandant</span>
<strong>{tenantName || tenantID}</strong>
</div>
<div className="placeholder-row">
<span>Projekt</span>
<strong>{projectID || "Kein Projekt ausgewählt"}</strong>
<strong>{projectName || projectID || "Kein Projekt ausgewählt"}</strong>
</div>
</div>
</>
@@ -385,8 +412,8 @@ function VMView({
<>
<div className="panel-header">
<div>
<p className="eyebrow">VMs</p>
<h2>Virtuelle Maschinen</h2>
<p className="eyebrow">Compute</p>
<h2>Server</h2>
</div>
</div>
<VMCreate
@@ -400,6 +427,14 @@ function VMView({
);
}
function pageTitle(view: ViewKey) {
if (view === "vms") return "Server";
if (view === "ssh") return "SSH Keys";
if (view === "audit") return "Audit Logs";
if (view === "console") return "Konsole";
return "Projektübersicht";
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
+395 -169
View File
@@ -1,9 +1,11 @@
:root {
color: #172026;
background: #f4f7fb;
color: #e8edf0;
background: #0b0d0e;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
@@ -12,10 +14,17 @@
body {
margin: 0;
background:
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px),
#0b0d0e;
background-size: 32px 32px;
}
button,
input {
input,
select,
textarea {
font: inherit;
}
@@ -34,26 +43,27 @@ button {
.loader {
width: 32px;
height: 32px;
border: 3px solid #cfdae7;
border-top-color: #147d64;
border: 3px solid #263135;
border-top-color: #ff583d;
border-radius: 999px;
animation: spin 900ms linear infinite;
}
.auth-panel {
width: min(420px, 100%);
border: 1px solid #d6dee8;
border: 1px solid #283236;
border-radius: 8px;
background: #ffffff;
background: #111517;
padding: 28px;
box-shadow: 0 18px 50px rgba(23, 32, 38, 0.08);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.34);
}
.auth-panel h1,
.topbar h1,
.panel h2 {
.panel h2,
.provision-box h2 {
margin: 0;
line-height: 1.15;
line-height: 1.12;
letter-spacing: 0;
}
@@ -67,21 +77,43 @@ button {
margin-top: 24px;
}
.auth-form label {
display: grid;
gap: 8px;
color: #4d5c68;
font-size: 14px;
.auth-form label,
.metric-list label,
.context-selects label {
color: #9aa6aa;
font-size: 13px;
font-weight: 700;
}
.auth-form input {
width: 100%;
border: 1px solid #c7d2de;
.auth-form label,
.metric-list label {
display: grid;
gap: 7px;
}
.auth-form input,
.metric-list input,
.metric-list select,
.ssh-form input,
.ssh-form textarea,
.context-selects select {
min-height: 38px;
border: 1px solid #313c41;
border-radius: 6px;
color: #172026;
background: #ffffff;
padding: 11px 12px;
color: #e8edf0;
background: #0d1113;
padding: 8px 10px;
outline: none;
}
.auth-form input:focus,
.metric-list input:focus,
.metric-list select:focus,
.ssh-form input:focus,
.ssh-form textarea:focus,
.context-selects select:focus {
border-color: #ff583d;
box-shadow: 0 0 0 3px rgba(255, 88, 61, 0.16);
}
.auth-actions,
@@ -101,13 +133,17 @@ button {
min-height: 38px;
border-radius: 6px;
padding: 0 14px;
font-weight: 700;
font-weight: 800;
}
.primary-button {
border: 1px solid #147d64;
color: #ffffff;
background: #147d64;
border: 1px solid #ff583d;
color: #140d0b;
background: #ff583d;
}
.primary-button:hover {
background: #ff6b53;
}
.primary-button:disabled {
@@ -116,14 +152,28 @@ button {
}
.ghost-button {
border: 1px solid #c7d2de;
color: #23313a;
background: #ffffff;
border: 1px solid #313c41;
color: #d5dcdf;
background: #121719;
}
.ghost-button:hover {
border-color: #4a575c;
background: #171d20;
}
.ghost-button.danger {
border-color: #793a31;
color: #ff8b78;
}
.ghost-button.danger:hover {
background: #221312;
}
.form-error {
margin: 0;
color: #a13c21;
color: #ff8b78;
font-size: 14px;
line-height: 1.45;
}
@@ -131,82 +181,142 @@ button {
.app-frame {
min-height: 100vh;
display: grid;
grid-template-columns: 224px minmax(0, 1fr);
grid-template-columns: 236px minmax(0, 1fr);
}
.sidebar {
border-right: 1px solid #dbe3ec;
background: #ffffff;
padding: 20px 14px;
border-right: 1px solid #20282b;
background: rgba(12, 15, 16, 0.94);
padding: 22px 14px;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-mark {
width: 40px;
height: 40px;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid #2e3b3f;
border-radius: 8px;
color: #ffffff;
background: #147d64;
font-weight: 800;
color: #ff583d;
background: #101416;
font-weight: 900;
box-shadow: inset 0 0 0 1px rgba(255, 88, 61, 0.1);
}
.brand-copy {
display: grid;
gap: 2px;
}
.brand-copy strong {
color: #f3f6f7;
font-size: 15px;
}
.brand-copy span,
.project-chip span,
.nav-section-label {
color: #6f7b80;
font-size: 11px;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.project-chip {
display: grid;
gap: 4px;
border: 1px solid #242e32;
border-radius: 8px;
background: #0d1113;
margin-top: 20px;
padding: 10px;
}
.project-chip strong {
overflow: hidden;
color: #e8edf0;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.main-nav {
display: grid;
gap: 6px;
margin-top: 28px;
gap: 5px;
margin-top: 24px;
}
.nav-section-label {
display: block;
padding: 0 10px 6px;
}
.nav-item {
width: 100%;
border: 0;
border: 1px solid transparent;
border-radius: 6px;
color: #40515d;
color: #9aa6aa;
background: transparent;
padding: 10px 12px;
padding: 11px 12px;
text-align: left;
font-weight: 750;
}
.nav-item:hover {
color: #e8edf0;
background: #121719;
}
.nav-item.active {
color: #0f2c26;
background: #e5f4ef;
font-weight: 800;
border-color: #314047;
color: #ffffff;
background: #182024;
box-shadow: inset 3px 0 0 #ff583d;
}
.workspace {
min-width: 0;
padding: 28px;
padding: 24px 32px 40px;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
margin-bottom: 24px;
align-items: center;
margin-bottom: 18px;
}
.topbar h1 {
font-size: 28px;
max-width: 780px;
font-size: 26px;
color: #f3f6f7;
}
.eyebrow {
margin: 0 0 8px;
color: #147d64;
color: #ff583d;
font-size: 12px;
font-weight: 800;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.session-box {
max-width: 520px;
min-width: 280px;
min-width: 292px;
justify-content: flex-end;
gap: 12px;
border: 1px solid #d6dee8;
border: 1px solid #283236;
border-radius: 8px;
background: #ffffff;
background: #111517;
padding: 10px;
}
@@ -223,11 +333,12 @@ button {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #f3f6f7;
font-size: 14px;
}
.session-box span {
color: #60717d;
color: #8e9aa0;
font-size: 13px;
}
@@ -236,17 +347,21 @@ button {
height: 10px;
flex: 0 0 auto;
border-radius: 999px;
background: #d99b33;
background: #c9952e;
}
.status-dot.ok {
background: #147d64;
background: #28c28b;
}
.content-grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
.context-bar,
.panel,
.provision-box,
.vm-card {
border: 1px solid #283236;
border-radius: 8px;
background: rgba(17, 21, 23, 0.96);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.2);
}
.context-bar {
@@ -254,9 +369,6 @@ button {
justify-content: space-between;
align-items: center;
gap: 14px;
border: 1px solid #d6dee8;
border-radius: 8px;
background: #ffffff;
padding: 12px 16px;
margin-bottom: 18px;
}
@@ -265,35 +377,210 @@ button {
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
}
.context-selects label {
display: flex;
align-items: center;
gap: 8px;
color: #4d5c68;
}
.content-grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
}
.panel {
min-width: 0;
padding: 20px;
}
.view-panel {
min-height: 168px;
}
.panel-header {
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.panel h2,
.provision-box h2 {
color: #f3f6f7;
font-size: 21px;
}
.metric-list,
.placeholder-table {
display: grid;
gap: 10px;
}
.metric,
.placeholder-row {
display: flex;
justify-content: space-between;
gap: 16px;
border: 1px solid #222b2f;
border-radius: 6px;
background: #0d1113;
padding: 12px;
}
.metric span,
.placeholder-row span,
.metric-text {
color: #8e9aa0;
}
.metric strong,
.placeholder-row strong {
min-width: 0;
overflow-wrap: anywhere;
color: #edf2f3;
text-align: right;
}
.pill {
border: 1px solid #7d5a20;
border-radius: 999px;
color: #e5b559;
background: #1d170d;
padding: 5px 10px;
font-size: 12px;
font-weight: 900;
}
.pill.ok {
border-color: #2a795f;
color: #52d7a7;
background: #0d201a;
}
.provision-box {
display: grid;
gap: 14px;
margin: 14px 0 16px;
padding: 16px;
}
.create-launch {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
border: 1px solid #242e32;
border-radius: 8px;
background: #0d1113;
margin-bottom: 14px;
padding: 12px;
}
.create-launch span {
color: #9aa6aa;
font-size: 14px;
}
.provision-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.provision-grid label {
display: grid;
gap: 7px;
color: #9aa6aa;
font-size: 13px;
font-weight: 700;
}
.context-selects select {
border: 1px solid #c7d2de;
.provision-grid input,
.provision-grid select {
min-height: 38px;
border: 1px solid #313c41;
border-radius: 6px;
padding: 7px 10px;
font: inherit;
background: #ffffff;
color: #e8edf0;
background: #0d1113;
padding: 8px 10px;
outline: none;
}
.provision-grid input:focus,
.provision-grid select:focus {
border-color: #ff583d;
box-shadow: 0 0 0 3px rgba(255, 88, 61, 0.16);
}
.vm-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
margin-top: 14px;
}
.vm-card {
border: 1px solid #d6dee8;
.table-wrap {
overflow-x: auto;
border: 1px solid #242e32;
border-radius: 8px;
background: #ffffff;
background: #0d1113;
}
.resource-table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
}
.resource-table th,
.resource-table td {
border-bottom: 1px solid #20282b;
padding: 12px;
text-align: left;
vertical-align: middle;
}
.resource-table th {
color: #7b878c;
font-size: 11px;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.resource-table td {
color: #c9d2d6;
font-size: 14px;
}
.resource-table tbody tr:hover {
background: #111719;
}
.resource-table tbody tr:last-child td {
border-bottom: 0;
}
.resource-name {
color: #f3f6f7;
}
.resource-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.compact {
min-height: 32px;
padding: 0 10px;
font-size: 13px;
}
.vm-card {
padding: 16px;
}
@@ -301,12 +588,21 @@ button {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
gap: 12px;
margin-bottom: 12px;
}
.vm-card-header strong {
min-width: 0;
overflow: hidden;
color: #f3f6f7;
text-overflow: ellipsis;
white-space: nowrap;
}
.vm-card-body {
display: grid;
gap: 6px;
gap: 8px;
margin-bottom: 12px;
}
@@ -322,103 +618,18 @@ button {
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;
border-radius: 8px;
background: #ffffff;
padding: 22px;
}
.panel-header {
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.panel h2 {
font-size: 21px;
}
.metric-list,
.placeholder-table {
display: grid;
gap: 10px;
}
.metric,
.placeholder-row {
display: flex;
justify-content: space-between;
gap: 16px;
border: 1px solid #edf1f5;
border-radius: 6px;
padding: 12px;
}
.metric span,
.placeholder-row span {
color: #60717d;
}
.metric strong,
.placeholder-row strong {
min-width: 0;
code {
display: block;
overflow-wrap: anywhere;
text-align: right;
}
.pill {
border: 1px solid #d7a44d;
border-radius: 999px;
color: #8a5c0b;
background: #fff8e8;
padding: 5px 10px;
font-size: 12px;
font-weight: 800;
}
.pill.ok {
border-color: #6fb79f;
color: #126b55;
background: #e8f6f1;
border: 1px solid #283236;
border-radius: 6px;
color: #d7f7ec;
background: #090c0d;
padding: 10px;
}
@keyframes spin {
@@ -434,13 +645,18 @@ button {
.sidebar {
border-right: 0;
border-bottom: 1px solid #dbe3ec;
border-bottom: 1px solid #20282b;
}
.main-nav {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.project-chip,
.nav-section-label {
display: none;
}
.topbar,
.content-grid {
grid-template-columns: 1fr;
@@ -470,6 +686,16 @@ button {
flex-direction: column;
}
.create-launch,
.context-bar {
align-items: flex-start;
flex-direction: column;
}
.provision-grid {
grid-template-columns: 1fr;
}
.metric strong,
.placeholder-row strong {
text-align: left;