feat: workspace endpoint, template/ssh-key dropdowns, noVNC console integration
This commit is contained in:
@@ -225,21 +225,26 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
|
|||||||
|
|
||||||
## Aktuell offen
|
## Aktuell offen
|
||||||
|
|
||||||
- [ ] E11-T01: Frontend-Kontext aus echten Backend-Daten laden
|
- [x] E11-T01: Frontend-Kontext aus echten Backend-Daten laden
|
||||||
- [ ] Feste Demo-`projectID` in `frontend/src/main.tsx` entfernen
|
- [x] `GET /me/workspace` Backend-Endpoint (Tenants + Projekte des Users)
|
||||||
- [ ] Feste Demo-`tenantID` entfernen; Tenant und Project getrennt behandeln
|
- [x] `workspace` Package mit Handler und Repository
|
||||||
- [ ] Backend-Discovery fuer Tenants/Projects des angemeldeten Users bereitstellen
|
- [x] Frontend: `api.ts` erweitert um `fetchWorkspace`, `Workspace` Typen
|
||||||
- [ ] Frontend-Auswahl fuer Tenant und Project anbinden
|
- [x] Frontend: `ContextBar` mit Tenant/Project-Selects
|
||||||
- [ ] E11-T02: VM-Erstellung im Frontend produktionsnah machen
|
- [x] Feste Demo-IDs entfernt, echte Tenant/Project-IDs an Views übergeben
|
||||||
- [ ] Template-Auswahl aus Backend-Daten statt manueller Template-UUID
|
- [x] Build und alle Tests grün
|
||||||
- [ ] SSH-Key-Auswahl aus Backend-Daten statt manueller SSH-Key-UUID
|
- [x] E11-T02: VM-Erstellung im Frontend produktionsnah machen
|
||||||
- [ ] Clientseitige Formularvalidierung fuer Pflichtfelder und Ressourcenlimits
|
- [x] `GET /templates` öffentlicher Endpoint (Auth+Profile)
|
||||||
- [ ] Fehler- und Erfolgszustaende nach Provisioning sauber anzeigen
|
- [x] `fetchTemplates` und `Template`-Typen im Frontend-API-Client
|
||||||
- [ ] E11-T03: Web-Konsole im Frontend integrieren
|
- [x] VMCreate: Template-Dropdown (aus API), SSH-Key-Dropdown (aus API), Node-Auto-Fill
|
||||||
- [ ] Console-Proxy-URL konfigurierbar machen
|
- [x] Clientseitige Validierung (Name, Template, Node Pflicht)
|
||||||
- [ ] Ticket-Abruf mit echtem Websocket-Client verbinden
|
- [x] Build grün
|
||||||
- [ ] noVNC- oder xterm-basierte Konsolenansicht einbetten
|
- [x] E11-T03: Web-Konsole im Frontend integrieren
|
||||||
- [ ] Verbindungsstatus, Ablauf und Fehlerfaelle abbilden
|
- [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
|
- [ ] E11-T04: Runtime-Smoke-Test ergaenzen
|
||||||
- [ ] Lokale Supabase-/Redis-Infrastruktur starten und Migrationen anwenden
|
- [ ] Lokale Supabase-/Redis-Infrastruktur starten und Migrationen anwenden
|
||||||
- [ ] Backend, Worker und Console-Proxy mit `.env` starten
|
- [ ] Backend, Worker und Console-Proxy mit `.env` starten
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"proxui/backend/internal/sshkey"
|
"proxui/backend/internal/sshkey"
|
||||||
"proxui/backend/internal/template"
|
"proxui/backend/internal/template"
|
||||||
"proxui/backend/internal/vm"
|
"proxui/backend/internal/vm"
|
||||||
|
"proxui/backend/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -108,6 +109,7 @@ func main() {
|
|||||||
cfg.OperatorToken,
|
cfg.OperatorToken,
|
||||||
)
|
)
|
||||||
auditHandler := audit.NewHandler(audit.NewSQLRepository(db))
|
auditHandler := audit.NewHandler(audit.NewSQLRepository(db))
|
||||||
|
workspaceHandler := workspace.NewHandler(workspace.NewRepository(db))
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -120,6 +122,10 @@ func main() {
|
|||||||
_ = json.NewEncoder(w).Encode(principal)
|
_ = json.NewEncoder(w).Encode(principal)
|
||||||
})
|
})
|
||||||
mux.Handle("GET /me", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(meHandler)))
|
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) {
|
tenantMembershipHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantMembership, _ := membership.FromRequest(r)
|
tenantMembership, _ := membership.FromRequest(r)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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 /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 {
|
templateManageChain := func(handler http.HandlerFunc) http.Handler {
|
||||||
return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler))
|
return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "proxui-frontend",
|
"name": "proxui-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@novnc/novnc": "^1.7.0",
|
||||||
"@supabase/supabase-js": "^2.0.0",
|
"@supabase/supabase-js": "^2.0.0",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -621,6 +622,12 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@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": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.133.0",
|
"version": "0.133.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||||
|
|||||||
@@ -10,15 +10,16 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.0.0",
|
"@novnc/novnc": "^1.7.0",
|
||||||
"react-dom": "^19.0.0",
|
"@supabase/supabase-js": "^2.0.0",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@supabase/supabase-js": "^2.0.0"
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"typescript-eslint": "^8.0.0"
|
"typescript-eslint": "^8.0.0"
|
||||||
|
|||||||
@@ -154,4 +154,39 @@ export function fetchMe(token: string) {
|
|||||||
"/me",
|
"/me",
|
||||||
token
|
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);
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,62 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { requestConsoleTicket } from "../api";
|
import { requestConsoleTicket, fetchVMs, type VM } from "../api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
token: string;
|
token: string;
|
||||||
|
projectID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConsoleView({ token }: Props) {
|
export function ConsoleView({ token, projectID }: Props) {
|
||||||
const [vmID, setVMID] = useState("");
|
const [vms, setVMs] = useState<VM[]>([]);
|
||||||
const [ticket, setTicket] = useState("");
|
const [selectedVMID, setSelectedVMID] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
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() {
|
async function handleConnect() {
|
||||||
setError("");
|
setError("");
|
||||||
|
setConnected(false);
|
||||||
|
if (!selectedVMID) {
|
||||||
|
setError("VM auswählen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await requestConsoleTicket(token, vmID);
|
const data = await requestConsoleTicket(token, selectedVMID);
|
||||||
setTicket(data.ticket);
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Fehler");
|
setError(err instanceof Error ? err.message : "Fehler");
|
||||||
|
setConnected(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -34,30 +72,48 @@ export function ConsoleView({ token }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="metric-list">
|
<div className="metric-list">
|
||||||
<label>
|
<label>
|
||||||
VM-ID
|
VM
|
||||||
<input
|
<select
|
||||||
value={vmID}
|
value={selectedVMID}
|
||||||
onChange={(e) => setVMID(e.target.value)}
|
onChange={(e) => setSelectedVMID(e.target.value)}
|
||||||
placeholder="UUID der VM"
|
>
|
||||||
/>
|
<option value="">VM auswählen</option>
|
||||||
|
{vms.map((vm) => (
|
||||||
|
<option key={vm.id} value={vm.id}>
|
||||||
|
{vm.name} ({vm.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={loading || !vmID}
|
disabled={loading || !selectedVMID}
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
|
style={{ marginTop: 12, marginBottom: 12 }}
|
||||||
>
|
>
|
||||||
{loading ? "Verbinde..." : "Konsole öffnen"}
|
{loading ? "Verbinde..." : connected ? "Neu verbinden" : "Konsole öffnen"}
|
||||||
</button>
|
</button>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
{ticket ? (
|
{ticket && connected ? (
|
||||||
<div className="metric-list" style={{ marginTop: "1rem" }}>
|
<div
|
||||||
<p className="metric-text">
|
ref={canvasRef}
|
||||||
Proxy-Ticket erhalten. Verbinde zum console-proxy mit:
|
style={{
|
||||||
</p>
|
width: "100%",
|
||||||
<code>?ticket={ticket}</code>
|
height: "480px",
|
||||||
</div>
|
border: "1px solid #d6dee8",
|
||||||
) : null}
|
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>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createVM } from "../api";
|
import {
|
||||||
|
createVM,
|
||||||
|
fetchTemplates,
|
||||||
|
fetchSSHKeys,
|
||||||
|
type Template,
|
||||||
|
type SSHKey,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
token: string;
|
token: string;
|
||||||
projectID: string;
|
projectID: string;
|
||||||
|
tenantID: string;
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VMCreate({ token, projectID, onCreated }: Props) {
|
export function VMCreate({ token, projectID, tenantID, onCreated }: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [templateID, setTemplateID] = useState("");
|
const [templateID, setTemplateID] = useState("");
|
||||||
const [node, setNode] = useState("");
|
const [node, setNode] = useState("");
|
||||||
@@ -19,21 +26,61 @@ export function VMCreate({ token, projectID, onCreated }: Props) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [open, setOpen] = 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() {
|
async function handleCreate() {
|
||||||
setError("");
|
setError("");
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Name erforderlich");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!templateID) {
|
||||||
|
setError("Template erforderlich");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!node.trim()) {
|
||||||
|
setError("Node erforderlich");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await createVM(token, projectID, {
|
await createVM(token, projectID, {
|
||||||
name,
|
name: name.trim(),
|
||||||
template_id: templateID,
|
template_id: templateID,
|
||||||
node,
|
node: node.trim(),
|
||||||
vcpu,
|
vcpu,
|
||||||
ram_mb: ramMB,
|
ram_mb: ramMB,
|
||||||
disk_gb: diskGB,
|
disk_gb: diskGB,
|
||||||
ssh_key_id: sshKeyID || undefined,
|
ssh_key_id: sshKeyID || undefined,
|
||||||
});
|
});
|
||||||
setName("");
|
setName("");
|
||||||
setTemplateID("");
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -73,12 +120,17 @@ export function VMCreate({ token, projectID, onCreated }: Props) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Template-ID
|
Template
|
||||||
<input
|
<select
|
||||||
value={templateID}
|
value={templateID}
|
||||||
onChange={(e) => setTemplateID(e.target.value)}
|
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>
|
||||||
<label>
|
<label>
|
||||||
Node
|
Node
|
||||||
@@ -113,18 +165,25 @@ export function VMCreate({ token, projectID, onCreated }: Props) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
SSH-Key-ID (optional)
|
SSH-Key (optional)
|
||||||
<input
|
<select
|
||||||
value={sshKeyID}
|
value={sshKeyID}
|
||||||
onChange={(e) => setSSHKeyID(e.target.value)}
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
|
style={{ marginTop: 14 }}
|
||||||
>
|
>
|
||||||
{busy ? "Erstelle..." : "VM erstellen"}
|
{busy ? "Erstelle..." : "VM erstellen"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+187
-79
@@ -1,7 +1,13 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { createClient, type Session } from "@supabase/supabase-js";
|
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 "./styles.css";
|
||||||
import { AuthScreen } from "./components/AuthScreen";
|
import { AuthScreen } from "./components/AuthScreen";
|
||||||
import { VMList } from "./components/VMList";
|
import { VMList } from "./components/VMList";
|
||||||
@@ -10,7 +16,7 @@ import { SSHKeys } from "./components/SSHKeys";
|
|||||||
import { AuditLog } from "./components/AuditLog";
|
import { AuditLog } from "./components/AuditLog";
|
||||||
import { ConsoleView } from "./components/ConsoleView";
|
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 supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? "";
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? "";
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? "";
|
||||||
@@ -41,6 +47,11 @@ function App() {
|
|||||||
"idle" | "loading" | "ok" | "error"
|
"idle" | "loading" | "ok" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
const [backendError, setBackendError] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
if (!supabase) {
|
if (!supabase) {
|
||||||
@@ -58,6 +69,9 @@ function App() {
|
|||||||
setBackendPrincipal(null);
|
setBackendPrincipal(null);
|
||||||
setBackendStatus("idle");
|
setBackendStatus("idle");
|
||||||
setBackendError("");
|
setBackendError("");
|
||||||
|
setWorkspace(null);
|
||||||
|
setSelectedTenant(null);
|
||||||
|
setSelectedProject(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => data.subscription.unsubscribe();
|
return () => data.subscription.unsubscribe();
|
||||||
@@ -78,6 +92,16 @@ function App() {
|
|||||||
const principal = await fetchMe(session.access_token);
|
const principal = await fetchMe(session.access_token);
|
||||||
setBackendPrincipal(principal);
|
setBackendPrincipal(principal);
|
||||||
setBackendStatus("ok");
|
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) {
|
} catch (error) {
|
||||||
setBackendStatus("error");
|
setBackendStatus("error");
|
||||||
setBackendError(
|
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);
|
const connected = Boolean(session && backendPrincipal);
|
||||||
|
|
||||||
if (loadingSession) {
|
if (loadingSession) {
|
||||||
@@ -158,42 +192,24 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="content-grid">
|
<ContextBar
|
||||||
<section className="panel backend-panel">
|
workspace={workspace}
|
||||||
<div className="panel-header">
|
selectedTenant={selectedTenant}
|
||||||
<div>
|
selectedProject={selectedProject}
|
||||||
<p className="eyebrow">Backend</p>
|
onTenantChange={handleTenantChange}
|
||||||
<h2>Status</h2>
|
onProjectChange={setSelectedProject}
|
||||||
</div>
|
connected={connected}
|
||||||
<button
|
backendError={backendError}
|
||||||
className="primary-button"
|
backendStatus={backendStatus}
|
||||||
disabled={backendStatus === "loading"}
|
onRefresh={refreshBackendPrincipal}
|
||||||
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>
|
|
||||||
|
|
||||||
|
<section className="content-grid">
|
||||||
<ViewPanel
|
<ViewPanel
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
connected={connected}
|
|
||||||
token={session.access_token}
|
token={session.access_token}
|
||||||
|
tenantID={selectedTenant?.id ?? ""}
|
||||||
|
projectID={selectedProject?.id ?? ""}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@@ -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 (
|
||||||
|
<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({
|
function ViewPanel({
|
||||||
activeView,
|
activeView,
|
||||||
connected,
|
|
||||||
token,
|
token,
|
||||||
|
tenantID,
|
||||||
|
projectID,
|
||||||
}: {
|
}: {
|
||||||
activeView: ViewKey;
|
activeView: ViewKey;
|
||||||
connected: boolean;
|
|
||||||
token: string;
|
token: string;
|
||||||
|
tenantID: string;
|
||||||
|
projectID: string;
|
||||||
}) {
|
}) {
|
||||||
const projectID = "00000000-0000-0000-0000-000000000000";
|
const [vmRefreshKey, setVMRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
if (!tenantID) {
|
||||||
|
return (
|
||||||
|
<section className="panel view-panel">
|
||||||
|
<p className="metric-text">Keine Mandanten verfügbar.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel view-panel">
|
<section className="panel view-panel">
|
||||||
{activeView === "overview" && <Overview connected={connected} />}
|
{activeView === "overview" && (
|
||||||
{activeView === "vms" && <VMView token={token} projectID={projectID} />}
|
<Overview tenantID={tenantID} projectID={projectID} token={token} />
|
||||||
{activeView === "ssh" && <SSHKeys token={token} tenantID={projectID} />}
|
)}
|
||||||
{activeView === "audit" && <AuditLog token={token} tenantID={projectID} />}
|
{activeView === "vms" && (
|
||||||
{activeView === "console" && <ConsoleView token={token} />}
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Overview({ connected }: { connected: boolean }) {
|
function Overview({
|
||||||
|
tenantID,
|
||||||
|
projectID,
|
||||||
|
token,
|
||||||
|
}: {
|
||||||
|
tenantID: string;
|
||||||
|
projectID: string;
|
||||||
|
token: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
@@ -231,34 +353,35 @@ function Overview({ connected }: { connected: boolean }) {
|
|||||||
<p className="eyebrow">Status</p>
|
<p className="eyebrow">Status</p>
|
||||||
<h2>Arbeitsbereich</h2>
|
<h2>Arbeitsbereich</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className={connected ? "pill ok" : "pill"}>
|
|
||||||
{connected ? "Verbunden" : "Warte"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="placeholder-table">
|
<div className="placeholder-table">
|
||||||
<div className="placeholder-row">
|
<div className="placeholder-row">
|
||||||
<span>Backend</span>
|
<span>Tenant</span>
|
||||||
<strong>{connected ? "Online" : "Offline"}</strong>
|
<strong>{tenantID}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="placeholder-row">
|
<div className="placeholder-row">
|
||||||
<span>RBAC</span>
|
<span>Projekt</span>
|
||||||
<strong>Aktiv</strong>
|
<strong>{projectID || "Kein Projekt ausgewählt"}</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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VMView({ token, projectID }: { token: string; projectID: string }) {
|
function VMView({
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -271,31 +394,16 @@ function VMView({ token, projectID }: { token: string; projectID: string }) {
|
|||||||
<VMCreate
|
<VMCreate
|
||||||
token={token}
|
token={token}
|
||||||
projectID={projectID}
|
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(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
Vendored
+15
@@ -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
@@ -245,10 +245,120 @@ button {
|
|||||||
|
|
||||||
.content-grid {
|
.content-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 0.85fr) minmax(420px, 1.15fr);
|
grid-template-columns: 1fr;
|
||||||
gap: 18px;
|
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 {
|
.panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border: 1px solid #d6dee8;
|
border: 1px solid #d6dee8;
|
||||||
|
|||||||
Reference in New Issue
Block a user