兼容KVM大多数功能

This commit is contained in:
MengMengCode
2026-06-07 18:57:55 +08:00
parent c99f3f6d55
commit 7e5da67de4
20 changed files with 1676 additions and 123 deletions
+4
View File
@@ -177,6 +177,10 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return return
} }
if c.IsKVM() && c.Status == "running" {
_, _ = kvmManager.RefreshVNCPort(c.ID)
_, _ = kvmManager.RefreshNetwork(c.ID)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c})
} }
+9 -1
View File
@@ -27,6 +27,7 @@ type ImageInfo struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Downloading bool `json:"downloading"` Downloading bool `json:"downloading"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
ManualPath string `json:"manual_path,omitempty"`
} }
var imageDownloadsMu sync.Mutex var imageDownloadsMu sync.Mutex
@@ -122,6 +123,10 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
for _, t := range kvm.GetImages() { for _, t := range kvm.GetImages() {
_, downloading := imageDownloads[t.ID] _, downloading := imageDownloads[t.ID]
downloaded, size := kvm.ImageDownloadedInfo(t.ID) downloaded, size := kvm.ImageDownloadedInfo(t.ID)
manualPath := ""
if t.Distro == "windows" {
manualPath = kvm.ImagePath(t.ID)
}
images = append(images, ImageInfo{ images = append(images, ImageInfo{
ID: t.ID, ID: t.ID,
Name: t.Name, Name: t.Name,
@@ -134,6 +139,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
Enabled: enabledSet[t.ID], Enabled: enabledSet[t.ID],
Downloading: downloading, Downloading: downloading,
SizeBytes: size, SizeBytes: size,
ManualPath: manualPath,
}) })
} }
@@ -182,7 +188,9 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
}() }()
ensureImageEnabled(image.ID) ensureImageEnabled(image.ID)
if err := kvm.DownloadImage(*image); err != nil { if err := kvm.DownloadImage(*image); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Download failed: " + err.Error()}) message := "Download failed: " + err.Error()
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message})
return return
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
+228
View File
@@ -0,0 +1,228 @@
package api
import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"sync"
"time"
"clicd/internal/config"
"github.com/gorilla/websocket"
)
type webVNCTicket struct {
ContainerName string
ContainerUUID string
ExpiresAt time.Time
}
var webVNCTickets = struct {
sync.Mutex
items map[string]webVNCTicket
}{items: map[string]webVNCTicket{}}
func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
var req struct {
ContainerName string `json:"container_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ContainerName == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"})
return
}
if !isContainerAllowedForRequest(r, req.ContainerName) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
c := config.FindContainerByName(req.ContainerName)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
if !c.IsKVM() {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"})
return
}
ticket := randomHex(32)
webVNCTickets.Lock()
cleanupExpiredWebVNCTicketsLocked(time.Now())
webVNCTickets.items[ticket] = webVNCTicket{
ContainerName: c.Name,
ContainerUUID: c.UUID,
ExpiresAt: time.Now().Add(60 * time.Second),
}
webVNCTickets.Unlock()
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: map[string]string{"ticket": ticket},
})
}
// HandleVNCProxy proxies a KVM VM's local libvirt VNC socket to the browser.
func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
ticket := webVNCTicketFromRequest(r)
if ticket == "" {
http.Error(w, "ticket required", http.StatusUnauthorized)
return
}
containerName := r.URL.Query().Get("container")
if containerName == "" {
http.Error(w, "container name required", http.StatusBadRequest)
return
}
item, ok := consumeWebVNCTicket(ticket, containerName)
if !ok {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return
}
c := config.FindContainerByName(containerName)
if c == nil || c.UUID != item.ContainerUUID {
http.Error(w, "container not found", http.StatusNotFound)
return
}
if !c.IsKVM() {
http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest)
return
}
if c.Status != "running" {
http.Error(w, "container is not running", http.StatusBadRequest)
return
}
vncPort, err := kvmManager.RefreshVNCPort(c.ID)
if err != nil {
http.Error(w, fmt.Sprintf("VNC display is not available: %v", err), http.StatusBadRequest)
return
}
vncConn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", vncPort)), 5*time.Second)
if err != nil {
http.Error(w, fmt.Sprintf("VNC connection failed: %v", err), http.StatusBadRequest)
return
}
defer vncConn.Close()
responseHeader := http.Header{}
if protocol := webVNCResponseProtocol(r); protocol != "" {
responseHeader.Set("Sec-WebSocket-Protocol", protocol)
}
ws, err := upgrader.Upgrade(w, r, responseHeader)
if err != nil {
log.Printf("WebVNC upgrade failed: %v", err)
return
}
defer ws.Close()
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort)
done := make(chan string, 2)
var writeMu sync.Mutex
go streamVNCToWebSocket(ws, &writeMu, vncConn, done)
go streamWebSocketToVNC(ws, vncConn, done)
reason := <-done
_ = vncConn.Close()
_ = ws.Close()
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason)
}
func webVNCTicketFromRequest(r *http.Request) string {
for _, protocol := range websocket.Subprotocols(r) {
const prefix = "clicd-vnc-ticket."
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
return protocol[len(prefix):]
}
}
return r.URL.Query().Get("ticket")
}
func webVNCResponseProtocol(r *http.Request) string {
for _, protocol := range websocket.Subprotocols(r) {
if protocol == "binary" {
return protocol
}
}
for _, protocol := range websocket.Subprotocols(r) {
const prefix = "clicd-vnc-ticket."
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
return protocol
}
}
return ""
}
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
now := time.Now()
webVNCTickets.Lock()
defer webVNCTickets.Unlock()
cleanupExpiredWebVNCTicketsLocked(now)
item, ok := webVNCTickets.items[ticket]
if !ok {
return webVNCTicket{}, false
}
delete(webVNCTickets.items, ticket)
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
}
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
for ticket, item := range webVNCTickets.items {
if !now.Before(item.ExpiresAt) {
delete(webVNCTickets.items, ticket)
}
}
}
func streamVNCToWebSocket(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- string) {
buf := make([]byte, 32*1024)
for {
n, err := src.Read(buf)
if n > 0 {
writeMu.Lock()
writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n])
writeMu.Unlock()
if writeErr != nil {
done <- fmt.Sprintf("browser websocket write failed: %v", writeErr)
return
}
}
if err != nil {
if err == io.EOF {
done <- "VNC server closed connection"
} else {
done <- fmt.Sprintf("VNC server read failed: %v", err)
}
return
}
}
}
func streamWebSocketToVNC(ws *websocket.Conn, dst net.Conn, done chan<- string) {
for {
messageType, msg, err := ws.ReadMessage()
if err != nil {
done <- fmt.Sprintf("browser websocket read failed: %v", err)
return
}
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
continue
}
if _, err := dst.Write(msg); err != nil {
done <- fmt.Sprintf("VNC server write failed: %v", err)
return
}
}
}
+7
View File
@@ -58,6 +58,13 @@ type AuditLog struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type VMReadinessCheck struct {
Key string `json:"key"`
Label string `json:"label"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
}
// Container represents an LXC container configuration // Container represents an LXC container configuration
type Container struct { type Container struct {
ID int `json:"id"` ID int `json:"id"`
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -70,6 +70,12 @@ func GetImages() []Image {
Description: "Rocky Linux 9 GenericCloud image for KVM", Description: "Rocky Linux 9 GenericCloud image for KVM",
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2", URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
}, },
{
ID: "kvm-windows-10", Name: "Windows 10 KVM",
Distro: "windows", Release: "10", Arch: "amd64",
Description: "Windows ISO for KVM (automatic unattended install from image index 1, network, Administrator password, RDP, firewall, and QEMU Guest Agent initialization)",
URL: "https://go.microsoft.com/fwlink/?LinkID=2195404",
},
} }
} }
@@ -87,5 +93,20 @@ func CacheDir() string {
} }
func ImagePath(id string) string { func ImagePath(id string) string {
return filepath.Join(CacheDir(), id+".qcow2") img := FindImage(id)
ext := ".qcow2"
if img != nil && img.Distro == "windows" {
ext = ".iso"
}
return filepath.Join(CacheDir(), id+ext)
}
// IsWindowsImage returns true if the image distro is "windows".
func IsWindowsImage(id string) bool {
img := FindImage(id)
return img != nil && img.Distro == "windows"
}
func virtioWinISOPath() string {
return filepath.Join(CacheDir(), "virtio-win.iso")
} }
+2 -1
View File
@@ -104,6 +104,8 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary))) mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket))) mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
mux.HandleFunc("/api/vnc", api.HandleVNCProxy) // WebSocket
// API Key management // API Key management
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys))) mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
@@ -161,4 +163,3 @@ func Run() error {
return server.ListenAndServe() return server.ListenAndServe()
} }
-1
View File
@@ -1 +0,0 @@
+1
View File
@@ -64,6 +64,7 @@ func main() {
// Start usage monitors (computes CPU/network/disk rates every 5s) // Start usage monitors (computes CPU/network/disk rates every 5s)
manager.StartUsageMonitor() manager.StartUsageMonitor()
kvmManager.StartUsageMonitor() kvmManager.StartUsageMonitor()
kvmManager.StartNetworkSyncMonitor()
kvmManager.StartIPv6Guard() kvmManager.StartIPv6Guard()
// Start scheduled snapshot scanners. // Start scheduled snapshot scanners.
+7
View File
@@ -8,6 +8,7 @@
"name": "clicd-frontend", "name": "clicd-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@novnc/novnc": "1.6.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"axios": "^1.7.7", "axios": "^1.7.7",
@@ -801,6 +802,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@novnc/novnc": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.6.0.tgz",
"integrity": "sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==",
"license": "MPL-2.0"
},
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.23.3", "version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@novnc/novnc": "1.6.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"axios": "^1.7.7", "axios": "^1.7.7",
@@ -48,7 +48,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
.then((res) => { .then((res) => {
const data = res.data.data || [] const data = res.data.data || []
setTemplates(data) setTemplates(data)
setForm((prev) => ({ ...prev, template_id: data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') })) setForm((prev) => {
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
return applyTemplateDefaults({ ...prev, template_id: templateID })
})
}) })
.catch(console.error) .catch(console.error)
@@ -190,14 +193,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
onClick={() => setForm((prev) => ({ ...prev, virtualization: 'lxc', template_id: '' }))} onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`} className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
> >
LXC LXC
</button> </button>
<button <button
type="button" type="button"
onClick={() => setForm((prev) => ({ ...prev, virtualization: 'kvm', template_id: '' }))} onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`} className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
> >
KVM KVM
@@ -213,7 +216,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
) : ( ) : (
<select <select
value={form.template_id} value={form.template_id}
onChange={(event) => setForm({ ...form, template_id: event.target.value })} onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
className={inputClass} className={inputClass}
> >
{templates.map((template) => ( {templates.map((template) => (
@@ -223,6 +226,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
))} ))}
</select> </select>
)} )}
</Field> </Field>
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}> <label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
@@ -324,7 +328,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
/> />
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono"> <span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
SSH: {sshPortPreview} -&gt; 22 {isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
</span> </span>
{autoPorts.map((port) => ( {autoPorts.map((port) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono"> <span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
@@ -435,7 +439,10 @@ function NumberInput({
function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) { function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) {
const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {} const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {}
const minVCPU = form.virtualization === 'kvm' ? 1 : 0.25 const windows = isWindowsTemplate(form.template_id)
const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25)
const minRAMMB = windows ? 2048 : 128
const minDiskGB = windows ? 30 : 1
if (!Number.isFinite(form.vcpu)) { if (!Number.isFinite(form.vcpu)) {
errors.vcpu = '请输入 vCPU' errors.vcpu = '请输入 vCPU'
@@ -449,16 +456,16 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
if (!Number.isFinite(form.ram_mb)) { if (!Number.isFinite(form.ram_mb)) {
errors.ram_mb = '请输入内存' errors.ram_mb = '请输入内存'
} else if (form.ram_mb < 128) { } else if (form.ram_mb < minRAMMB) {
errors.ram_mb = '不能小于 128 MB' errors.ram_mb = `不能小于 ${minRAMMB} MB`
} else if (maxRAMMB && form.ram_mb > maxRAMMB) { } else if (maxRAMMB && form.ram_mb > maxRAMMB) {
errors.ram_mb = `不能大于 ${maxRAMMB} MB` errors.ram_mb = `不能大于 ${maxRAMMB} MB`
} }
if (!Number.isFinite(form.disk_gb)) { if (!Number.isFinite(form.disk_gb)) {
errors.disk_gb = '请输入磁盘' errors.disk_gb = '请输入磁盘'
} else if (form.disk_gb < 1) { } else if (form.disk_gb < minDiskGB) {
errors.disk_gb = '不能小于 1 GB' errors.disk_gb = `不能小于 ${minDiskGB} GB`
} else if (maxDiskGB && form.disk_gb > maxDiskGB) { } else if (maxDiskGB && form.disk_gb > maxDiskGB) {
errors.disk_gb = `不能大于 ${maxDiskGB} GB` errors.disk_gb = `不能大于 ${maxDiskGB} GB`
} }
@@ -467,15 +474,31 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
} }
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest { function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
const normalized = applyTemplateDefaults(form)
return {
...normalized,
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
ram_mb: Math.round(normalized.ram_mb),
disk_gb: Math.round(normalized.disk_gb),
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
}
}
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
if (!isWindowsTemplate(form.template_id)) return form
return { return {
...form, ...form,
vcpu: form.virtualization === 'kvm' ? Math.round(form.vcpu) : normalizeLXCvCPU(form.vcpu), virtualization: 'kvm',
ram_mb: Math.round(form.ram_mb), vcpu: Math.max(2, Math.round(Number.isFinite(form.vcpu) ? form.vcpu : 2)),
disk_gb: Math.round(form.disk_gb), ram_mb: Math.max(2048, Math.round(Number.isFinite(form.ram_mb) ? form.ram_mb : 2048)),
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3), disk_gb: Math.max(30, Math.round(Number.isFinite(form.disk_gb) ? form.disk_gb : 30)),
} }
} }
function isWindowsTemplate(templateID: string) {
return templateID.toLowerCase().includes('windows')
}
function normalizeLXCvCPU(value: number) { function normalizeLXCvCPU(value: number) {
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4 const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
return Number(rounded.toFixed(2)) return Number(rounded.toFixed(2))
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from 'react'
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
import RFB from '@novnc/novnc'
import { createVNCTicket, getWebVNCUrl } from '../services/api'
interface WebVNCViewerProps {
containerName: string
onClose: () => void
}
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
const screenRef = useRef<HTMLDivElement>(null)
const rfbRef = useRef<RFB | null>(null)
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('')
const cleanup = () => {
if (rfbRef.current) {
rfbRef.current.disconnect()
rfbRef.current = null
}
}
const connect = async () => {
const target = screenRef.current
if (!target) return
cleanup()
target.innerHTML = ''
setStatus('connecting')
setErrorMsg('')
let ticket = ''
try {
const response = await createVNCTicket(containerName)
ticket = response.data.data?.ticket || ''
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
setStatus('error')
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
return
}
if (!ticket) {
setStatus('error')
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
return
}
try {
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket))
rfb.scaleViewport = true
rfb.resizeSession = false
rfb.focusOnClick = true
rfb.qualityLevel = 6
rfb.compressionLevel = 2
rfb.background = '#050505'
rfb.addEventListener('connect', () => {
setStatus('connected')
})
rfb.addEventListener('disconnect', (event) => {
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
setStatus((current) => current === 'error' ? current : 'disconnected')
if (detail && detail.clean === false) {
setErrorMsg('WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用')
}
})
rfb.addEventListener('securityfailure', () => {
setStatus('error')
setErrorMsg('VNC 安全协商失败')
})
rfb.addEventListener('credentialsrequired', () => {
setStatus('error')
setErrorMsg('当前 VNC 控制台要求密码,暂不支持自动输入')
})
rfbRef.current = rfb
} catch (err) {
console.error(err)
setStatus('error')
setErrorMsg('WebVNC 初始化失败')
}
}
useEffect(() => {
const timer = window.setTimeout(connect, 100)
return () => {
window.clearTimeout(timer)
cleanup()
}
}, [containerName])
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-gray-600" />
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700"></span>}
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">...</span>}
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"></span>}
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700"></span>}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => rfbRef.current?.sendCtrlAltDel()}
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
title="发送 Ctrl+Alt+Del"
>
<Send className="w-3.5 h-3.5" />
Ctrl+Alt+Del
</button>
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
{status === 'connecting' ? '正在连接 KVM VNC 控制台...' : (errorMsg || 'WebVNC 已断开')}
</div>
)}
</div>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
declare module '@novnc/novnc' {
export default class RFB extends EventTarget {
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
scaleViewport: boolean
resizeSession: boolean
focusOnClick: boolean
viewOnly: boolean
qualityLevel: number
compressionLevel: number
background: string
disconnect(): void
sendCtrlAltDel(): void
}
}
+151 -40
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react' import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { import {
ArrowLeft, ArrowLeft,
@@ -8,7 +8,10 @@ import {
Cpu, Cpu,
HardDrive, HardDrive,
Key, Key,
Maximize2,
MemoryStick, MemoryStick,
Minimize2,
Monitor,
Network, Network,
Pencil, Pencil,
Play, Play,
@@ -65,6 +68,7 @@ import {
import { useDialog } from '../components/Dialog' import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import WebSSHViewer from '../components/WebSSHViewer' import WebSSHViewer from '../components/WebSSHViewer'
import WebVNCViewer from '../components/WebVNCViewer'
import { RingStat } from '../components/RingStats' import { RingStat } from '../components/RingStats'
import { copyToClipboard } from '../utils/clipboard' import { copyToClipboard } from '../utils/clipboard'
import ResourceStatsPanel, { import ResourceStatsPanel, {
@@ -115,6 +119,9 @@ export default function ContainerDetail() {
const [actionLoading, setActionLoading] = useState<string | null>(null) const [actionLoading, setActionLoading] = useState<string | null>(null)
const [taskStatus, setTaskStatus] = useState('') // current task type for this container const [taskStatus, setTaskStatus] = useState('') // current task type for this container
const [showSSH, setShowSSH] = useState(false) const [showSSH, setShowSSH] = useState(false)
const [showVNC, setShowVNC] = useState(false)
const vncFullscreenRef = useRef<HTMLDivElement>(null)
const [vncFullscreen, setVncFullscreen] = useState(false)
const [showNat, setShowNat] = useState(false) const [showNat, setShowNat] = useState(false)
const [showNatAdd, setShowNatAdd] = useState(false) const [showNatAdd, setShowNatAdd] = useState(false)
const [showExpiryEdit, setShowExpiryEdit] = useState(false) const [showExpiryEdit, setShowExpiryEdit] = useState(false)
@@ -180,15 +187,18 @@ export default function ContainerDetail() {
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => { const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
if (!containerIdentifier || !currentContainer) return if (!containerIdentifier || !currentContainer) return
const memoryPct = currentContainer.ram_mb > 0 const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0
? (nextUsage.memory_usage_bytes / (currentContainer.ram_mb * 1024 * 1024)) * 100 ? nextUsage.memory_total_bytes
: currentContainer.ram_mb * 1024 * 1024
const memoryPct = memoryTotalBytes > 0
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
: 0 : 0
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0) const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0) const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
const point: MetricPoint = { const point: MetricPoint = {
ts: Date.now(), ts: Date.now(),
cpu: clamp(nextUsage.cpu_usage_pct || 0), cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
memory: clamp(memoryPct), memory: clamp(memoryPct),
network: networkBps, network: networkBps,
diskIO: diskIOBps, diskIO: diskIOBps,
@@ -227,6 +237,28 @@ export default function ContainerDetail() {
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [fetchContainer]) }, [fetchContainer])
useEffect(() => {
const handleFullscreenChange = () => {
setVncFullscreen(document.fullscreenElement === vncFullscreenRef.current)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [])
const toggleVNCFullscreen = async () => {
const target = vncFullscreenRef.current
if (!target) return
try {
if (document.fullscreenElement === target) {
await document.exitFullscreen()
} else {
await target.requestFullscreen()
}
} catch {
setVncFullscreen((value) => !value)
}
}
useEffect(() => { useEffect(() => {
fetchUsage() fetchUsage()
const timer = window.setInterval(fetchUsage, 5000) const timer = window.setInterval(fetchUsage, 5000)
@@ -278,6 +310,7 @@ export default function ContainerDetail() {
case 'stop': case 'stop':
await stopContainer(containerIdentifier) await stopContainer(containerIdentifier)
setShowSSH(false) setShowSSH(false)
setShowVNC(false)
break break
case 'restart': case 'restart':
await restartContainer(containerIdentifier) await restartContainer(containerIdentifier)
@@ -390,6 +423,7 @@ export default function ContainerDetail() {
await reinstallContainer(containerIdentifier, selectedTemplate) await reinstallContainer(containerIdentifier, selectedTemplate)
setShowReinstall(false) setShowReinstall(false)
setShowSSH(false) setShowSSH(false)
setShowVNC(false)
await fetchContainer() await fetchContainer()
} catch (err) { } catch (err) {
console.error('Reinstall failed:', err) console.error('Reinstall failed:', err)
@@ -643,17 +677,24 @@ export default function ContainerDetail() {
} }
const isRunning = container.status === 'running' const isRunning = container.status === 'running'
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const isWindows = container.template?.includes('windows')
const canOpenVNC = isKVM && isRunning
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
const maxVCPU = hostInfo?.cpu.cores || 64 const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}` const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && ( const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
) )
const filtered = filterHistory(history, range) const filtered = filterHistory(history, range)
const cpuPct = clamp(usage?.cpu_usage_pct || 0) const cpuPct = clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1)))
const ramPct = container.ram_mb > 0 ? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) : 0 const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
? usage.memory_total_bytes
: container.ram_mb * 1024 * 1024
const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0
const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0 const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0
const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0 const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0) const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
@@ -670,7 +711,7 @@ export default function ContainerDetail() {
{ {
title: 'CPU 使用率', title: 'CPU 使用率',
icon: <Cpu className="w-5 h-5" />, icon: <Cpu className="w-5 h-5" />,
current: usage?.cpu_usage_pct || 0, current: clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))),
points: toChartPoints(filtered, 'cpu'), points: toChartPoints(filtered, 'cpu'),
max: 100, max: 100,
formatValue: formatPercent, formatValue: formatPercent,
@@ -683,7 +724,7 @@ export default function ContainerDetail() {
points: toChartPoints(filtered, 'memory'), points: toChartPoints(filtered, 'memory'),
max: 100, max: 100,
formatValue: formatPercent, formatValue: formatPercent,
detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${container.ram_mb} MB`, detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${formatBytes(ramTotalBytes)}`,
}, },
{ {
title: '网络流量', title: '网络流量',
@@ -732,7 +773,7 @@ export default function ContainerDetail() {
<InfoTag color="slate"> {(container.virtualization || 'lxc').toUpperCase()}</InfoTag> <InfoTag color="slate"> {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag> <InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">NAT {mappingCount} </InfoTag> <InfoTag color="amber">NAT {mappingCount} </InfoTag>
<InfoTag color="violet">{publicHost}:{container.ssh_port}</InfoTag> <InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
</div> </div>
</div> </div>
</div> </div>
@@ -753,10 +794,18 @@ export default function ContainerDetail() {
<RefreshCw className="w-3.5 h-3.5" /> <RefreshCw className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'} {isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
</ActionButton> </ActionButton>
<ActionButton dark onClick={() => setShowSSH(true)}> {!isWindows && (
<TerminalSquare className="w-3.5 h-3.5" /> <ActionButton dark onClick={() => setShowSSH(true)}>
WebSSH <TerminalSquare className="w-3.5 h-3.5" />
</ActionButton> WebSSH
</ActionButton>
)}
{isKVM && (
<ActionButton dark disabled={!canOpenVNC} onClick={() => setShowVNC(true)}>
<Monitor className="w-3.5 h-3.5" />
WebVNC
</ActionButton>
)}
</> </>
)} )}
{!isSubUser && ( {!isSubUser && (
@@ -793,30 +842,59 @@ export default function ContainerDetail() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Panel title="连接信息"> <Panel title="连接信息">
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} /> {isWindows ? (
<PlainRow label="用户名" value="root" mono /> <>
<div className="flex items-center justify-between gap-3"> <PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
<span className="text-gray-500">SSH </span> <PlainRow label="用户名" value="Administrator" mono />
<div className="flex items-center gap-1 min-w-0"> <div className="flex items-center justify-between gap-3">
<span <span className="text-gray-500"></span>
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`} <div className="flex items-center gap-1 min-w-0">
onClick={() => setShowPassword(!showPassword)} <span
title={showPassword ? '点击隐藏' : '点击显示'} className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
> onClick={() => setShowPassword(!showPassword)}
{showPassword ? (container.ssh_password || '-') : '••••••••'} title={showPassword ? '点击隐藏' : '点击显示'}
</span> >
{container.ssh_password && ( {container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制"> </span>
<Copy className="w-3 h-3" /> {container.ssh_password && (
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
)}
</div>
</div>
{container.vnc_port > 0 && (
<PlainRow label="VNC 端口" value={`127.0.0.1:${container.vnc_port}`} mono />
)}
</>
) : (
<>
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
<PlainRow label="用户名" value="root" mono />
<div className="flex items-center justify-between gap-3">
<span className="text-gray-500">SSH </span>
<div className="flex items-center gap-1 min-w-0">
<span
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? '点击隐藏' : '点击显示'}
>
{container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
</span>
{container.ssh_password && (
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
)}
</div>
</div>
{!isSubUser && (
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
<Key className="w-3 h-3" />
SSH
</button> </button>
)} )}
</div> </>
</div>
{!isSubUser && (
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
<Key className="w-3 h-3" />
SSH
</button>
)} )}
</Panel> </Panel>
@@ -871,7 +949,7 @@ export default function ContainerDetail() {
<RingStat <RingStat
value={ramPct} value={ramPct}
label="内存" label="内存"
subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(container.ram_mb * 1024 * 1024)}`} subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(ramTotalBytes)}`}
/> />
<RingStat <RingStat
value={loadPct} value={loadPct}
@@ -980,6 +1058,38 @@ export default function ContainerDetail() {
</Modal> </Modal>
)} )}
{showVNC && (
<Modal
title={`WebVNC - ${container.name}`}
onClose={() => setShowVNC(false)}
wide
flush
extra={
<button
onClick={toggleVNCFullscreen}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-gray-600 hover:text-black hover:bg-gray-100 rounded"
title={vncFullscreen ? '退出全屏' : '全屏显示'}
>
{vncFullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
{vncFullscreen ? '退出全屏' : '全屏'}
</button>
}
>
<div
ref={vncFullscreenRef}
className={`bg-white ${vncFullscreen ? 'fixed inset-0 z-[70] p-3' : 'h-[calc(92vh-112px)] min-h-[420px] p-5'}`}
>
<div className="h-full">
{canOpenVNC ? (
<WebVNCViewer containerName={container.name} onClose={() => setShowVNC(false)} />
) : (
<div className="h-full flex items-center justify-center bg-gray-950 text-gray-400 rounded-md">VNC KVM </div>
)}
</div>
</div>
</Modal>
)}
{showSnapshots && ( {showSnapshots && (
<Modal <Modal
title="快照" title="快照"
@@ -1573,7 +1683,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{mappings.map((pm, index) => { {mappings.map((pm, index) => {
const isSSH = pm.description === 'SSH' || pm.container_port === 22 const isSSH = pm.description === 'SSH' || pm.container_port === 22 || pm.description === 'RDP' || pm.container_port === 3389
return ( return (
<tr key={`${pm.host_port}-${pm.container_port}-${index}`}> <tr key={`${pm.host_port}-${pm.container_port}-${index}`}>
<td className="px-3 py-2 text-sm"> <td className="px-3 py-2 text-sm">
@@ -1619,7 +1729,7 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
) )
} }
function Modal({ title, children, onClose, wide = false, extra }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode }) { function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className={`bg-white rounded-lg shadow-xl border border-gray-200 w-full ${wide ? 'max-w-5xl' : 'max-w-md'} max-h-[92vh] overflow-hidden flex flex-col`}> <div className={`bg-white rounded-lg shadow-xl border border-gray-200 w-full ${wide ? 'max-w-5xl' : 'max-w-md'} max-h-[92vh] overflow-hidden flex flex-col`}>
@@ -1632,7 +1742,7 @@ function Modal({ title, children, onClose, wide = false, extra }: { title: strin
</button> </button>
</div> </div>
</div> </div>
<div className="p-5 overflow-y-auto">{children}</div> <div className={flush ? "overflow-hidden" : "p-5 overflow-y-auto"}>{children}</div>
</div> </div>
</div> </div>
) )
@@ -1817,5 +1927,6 @@ function getTemplateIcon(id: string): ReactNode {
if (id.startsWith('nixos')) return <svg className={size} viewBox="0 0 60 60"><g fillRule="evenodd"><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z" fill="#5277c3"/></g><defs><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg> if (id.startsWith('nixos')) return <svg className={size} viewBox="0 0 60 60"><g fillRule="evenodd"><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z" fill="#5277c3"/></g><defs><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg>
if (id.startsWith('kali')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M545.194667 253.568s-84.053333-5.546667-227.285334 39.253333c-145.92 45.653333-228.693333 110.378667-228.693333 110.378667s217.514667-121.472 463.018667-128.341333z m313.642666 132.053333l10.965334-0.725333s-62.634667-75.946667-182.528-112.981333c67.413333 27.392 126.037333 63.701333 171.562666 113.706666z m17.92 31.573334c1.664-2.901333 7.082667 9.258667 11.221334 14.378666 0.170667 1.024 0.426667 1.664-1.92 1.152-0.213333-1.066667-0.554667-1.365333-0.554667-1.365333s-5.76-3.413333-7.552-5.845333c-1.749333-2.432-2.090667-6.698667-1.194667-8.32z m147.114667 361.770666s13.312-152.661333-226.56-187.861333a779.818667 779.818667 0 0 0-107.690667-7.978667c-192.256 2.56-199.253333-221.738667-54.4-233.045333 60.032-4.949333 131.712 27.434667 201.813334 60.074667-0.298667 8.704 0.085333 16.426667 5.802666 23.552 5.717333 7.168 27.648 14.933333 34.688 18.986666 6.997333 4.010667 29.482667 18.346667 43.264 36.266667 2.986667-5.589333 27.904-21.845333 27.904-21.845333s-5.973333 0.128-19.84-5.077334c-13.909333-5.205333-30.421333-20.906667-30.805333-21.802666-0.426667-0.938667-0.64-2.346667 2.56-2.986667 2.517333-2.090667-3.072-8.832-5.546667-11.306667-2.474667-2.474667-18.986667-30.549333-19.370666-31.146666-0.384-0.682667-0.512-1.322667-1.706667-2.133334-3.626667-1.152-19.626667 1.706667-19.626667 1.706667s-24.533333-12.074667-33.024-38.101333c0.128 4.565333-4.224 9.557333 0 20.010666-12.8-5.418667-23.808-14.677333-32.512-37.546666-5.12 13.013333 0 21.290667 0 21.290666s-30.165333-8.448-34.986666-36.266666c-5.290667 12.501333 0 20.010667 0 20.010666s-49.194667-25.685333-130.944-26.026666c-54.741333-5.034667-66.133333-101.290667-61.013334-117.504 0 0-78.933333-41.6-234.368-59.989334-155.392-18.346667-282.794667-2.773333-282.794666-2.773333s275.2-13.226667 495.658666 76.074667c7.509333 33.493333 30.037333 89.344 42.197334 116.181333-34.773333 24.021333-73.941333 46.592-80.042667 126.72-6.101333 80.128 62.805333 150.613333 148.224 152.746667 81.066667 4.352 137.130667 4.949333 205.056 40.192 64.853333 35.84 118.016 145.066667 123.306667 243.328 5.632-72.917333-21.717333-229.674667-149.333334-277.248 178.389333 31.232 194.090667 163.498667 194.090667 163.498666zM541.013333 241.621333l-6.4-20.693333s-105.984-18.816-248.405333-8.704C143.786667 222.336 0 272.213333 0 272.213333s294.229333-74.026667 541.013333-30.592z" fill="#557C94"/></svg> if (id.startsWith('kali')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M545.194667 253.568s-84.053333-5.546667-227.285334 39.253333c-145.92 45.653333-228.693333 110.378667-228.693333 110.378667s217.514667-121.472 463.018667-128.341333z m313.642666 132.053333l10.965334-0.725333s-62.634667-75.946667-182.528-112.981333c67.413333 27.392 126.037333 63.701333 171.562666 113.706666z m17.92 31.573334c1.664-2.901333 7.082667 9.258667 11.221334 14.378666 0.170667 1.024 0.426667 1.664-1.92 1.152-0.213333-1.066667-0.554667-1.365333-0.554667-1.365333s-5.76-3.413333-7.552-5.845333c-1.749333-2.432-2.090667-6.698667-1.194667-8.32z m147.114667 361.770666s13.312-152.661333-226.56-187.861333a779.818667 779.818667 0 0 0-107.690667-7.978667c-192.256 2.56-199.253333-221.738667-54.4-233.045333 60.032-4.949333 131.712 27.434667 201.813334 60.074667-0.298667 8.704 0.085333 16.426667 5.802666 23.552 5.717333 7.168 27.648 14.933333 34.688 18.986666 6.997333 4.010667 29.482667 18.346667 43.264 36.266667 2.986667-5.589333 27.904-21.845333 27.904-21.845333s-5.973333 0.128-19.84-5.077334c-13.909333-5.205333-30.421333-20.906667-30.805333-21.802666-0.426667-0.938667-0.64-2.346667 2.56-2.986667 2.517333-2.090667-3.072-8.832-5.546667-11.306667-2.474667-2.474667-18.986667-30.549333-19.370666-31.146666-0.384-0.682667-0.512-1.322667-1.706667-2.133334-3.626667-1.152-19.626667 1.706667-19.626667 1.706667s-24.533333-12.074667-33.024-38.101333c0.128 4.565333-4.224 9.557333 0 20.010666-12.8-5.418667-23.808-14.677333-32.512-37.546666-5.12 13.013333 0 21.290667 0 21.290666s-30.165333-8.448-34.986666-36.266666c-5.290667 12.501333 0 20.010667 0 20.010666s-49.194667-25.685333-130.944-26.026666c-54.741333-5.034667-66.133333-101.290667-61.013334-117.504 0 0-78.933333-41.6-234.368-59.989334-155.392-18.346667-282.794667-2.773333-282.794666-2.773333s275.2-13.226667 495.658666 76.074667c7.509333 33.493333 30.037333 89.344 42.197334 116.181333-34.773333 24.021333-73.941333 46.592-80.042667 126.72-6.101333 80.128 62.805333 150.613333 148.224 152.746667 81.066667 4.352 137.130667 4.949333 205.056 40.192 64.853333 35.84 118.016 145.066667 123.306667 243.328 5.632-72.917333-21.717333-229.674667-149.333334-277.248 178.389333 31.232 194.090667 163.498667 194.090667 163.498666zM541.013333 241.621333l-6.4-20.693333s-105.984-18.816-248.405333-8.704C143.786667 222.336 0 272.213333 0 272.213333s294.229333-74.026667 541.013333-30.592z" fill="#557C94"/></svg>
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg> if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
return null return null
} }
+13 -3
View File
@@ -393,10 +393,16 @@ export default function Containers() {
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder const isPlaceholder = !!container.isPlaceholder
const usage = usageByName[container.name] const usage = usageByName[container.name]
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0 const cpuPct = isRunning
const ramPct = isRunning && container.ram_mb > 0 ? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) : 0
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
? usage.memory_total_bytes
: container.ram_mb * 1024 * 1024
const ramPct = isRunning && ramTotalBytes > 0
? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100)
: 0 : 0
const diskPct = container.disk_gb > 0 const diskPct = container.disk_gb > 0
? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100)
@@ -803,6 +809,7 @@ function getSystemFilterValue(template: string) {
if (normalized.startsWith('archlinux')) return 'archlinux' if (normalized.startsWith('archlinux')) return 'archlinux'
if (normalized.startsWith('fedora')) return 'fedora' if (normalized.startsWith('fedora')) return 'fedora'
if (normalized.startsWith('rockylinux')) return 'rockylinux' if (normalized.startsWith('rockylinux')) return 'rockylinux'
if (normalized.startsWith('windows')) return 'windows'
return normalized || 'unknown' return normalized || 'unknown'
} }
@@ -815,6 +822,7 @@ function getSystemFilterLabel(system: string) {
archlinux: 'Arch Linux', archlinux: 'Arch Linux',
fedora: 'Fedora', fedora: 'Fedora',
rockylinux: 'Rocky Linux', rockylinux: 'Rocky Linux',
windows: 'Windows',
unknown: '未知系统', unknown: '未知系统',
} }
return labels[system] || system return labels[system] || system
@@ -944,6 +952,7 @@ function getTemplateName(id: string) {
'kvm-debian-bookworm': 'Debian 12', 'kvm-debian-bookworm': 'Debian 12',
'kvm-debian-bullseye': 'Debian 11', 'kvm-debian-bullseye': 'Debian 11',
'kvm-rockylinux-9': 'Rocky 9', 'kvm-rockylinux-9': 'Rocky 9',
'kvm-windows-10': 'Windows 10',
} }
return map[id] || id return map[id] || id
} }
@@ -958,6 +967,7 @@ function getTemplateIcon(id: string): ReactNode {
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg> if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg> if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg> if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
return null return null
} }
+15 -3
View File
@@ -11,8 +11,10 @@ import {
AlertCircle, AlertCircle,
} from 'lucide-react' } from 'lucide-react'
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api' import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api'
import { useDialog } from '../components/Dialog'
export default function ImageManagement() { export default function ImageManagement() {
const dialog = useDialog()
const [images, setImages] = useState<ImageInfo[]>([]) const [images, setImages] = useState<ImageInfo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null) const [actionLoading, setActionLoading] = useState<string | null>(null)
@@ -43,15 +45,14 @@ export default function ImageManagement() {
await downloadImage(templateId) await downloadImage(templateId)
await fetchImages() await fetchImages()
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : '下载失败' setError(apiErrorMessage(err, '下载失败'))
setError(msg)
} finally { } finally {
setActionLoading(null) setActionLoading(null)
} }
} }
const handleDelete = async (templateId: string) => { const handleDelete = async (templateId: string) => {
if (!window.confirm('确定要删除该镜像缓存吗?删除后需要重新下载才能使用。')) return if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
setActionLoading(templateId) setActionLoading(templateId)
setError('') setError('')
try { try {
@@ -207,6 +208,7 @@ function ImageTable({
<div> <div>
<span className="font-medium text-gray-900 text-sm">{img.name}</span> <span className="font-medium text-gray-900 text-sm">{img.name}</span>
<p className="text-[11px] text-gray-400">{img.description}</p> <p className="text-[11px] text-gray-400">{img.description}</p>
</div> </div>
</div> </div>
</td> </td>
@@ -316,6 +318,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
) )
} }
function isWindowsImage(img: ImageInfo) {
return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
}
function getTemplateIcon(id: string): ReactNode { function getTemplateIcon(id: string): ReactNode {
const size = 'w-5 h-5' const size = 'w-5 h-5'
id = id.startsWith('kvm-') ? id.slice(4) : id id = id.startsWith('kvm-') ? id.slice(4) : id
@@ -326,9 +332,15 @@ function getTemplateIcon(id: string): ReactNode {
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg> if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg> if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg> if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
return null return null
} }
function apiErrorMessage(err: unknown, fallback: string) {
const error = err as { response?: { data?: { message?: string } }; message?: string }
return error.response?.data?.message || error.message || fallback
}
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes <= 0) return '-' if (bytes <= 0) return '-'
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
+13
View File
@@ -159,6 +159,7 @@ export interface HostInfo {
export interface ContainerUsage { export interface ContainerUsage {
memory_usage_bytes: number memory_usage_bytes: number
memory_total_bytes?: number
cpu_usage_usec: number cpu_usage_usec: number
cpu_usage_pct: number cpu_usage_pct: number
disk_usage_bytes: number disk_usage_bytes: number
@@ -173,6 +174,7 @@ export interface ContainerUsage {
load1: number load1: number
load5: number load5: number
load15: number load15: number
guest_metrics?: boolean
} }
export interface APIResponse<T = unknown> { export interface APIResponse<T = unknown> {
@@ -353,6 +355,7 @@ export interface ImageInfo {
enabled: boolean enabled: boolean
downloading: boolean downloading: boolean
size_bytes: number size_bytes: number
manual_path?: string
} }
export const getImages = () => export const getImages = () =>
@@ -440,6 +443,13 @@ export const getWebSSHUrl = (containerName: string) => {
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
} }
export const getWebVNCUrl = (containerName: string, ticket?: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({ container: containerName })
if (ticket) params.set('ticket', ticket)
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
}
// Task Queue // Task Queue
export interface Task { export interface Task {
id: string id: string
@@ -529,6 +539,9 @@ export const getSecuritySummary = () =>
export const createWebSSHTicket = (containerName: string) => export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName }) api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
export const createVNCTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
// Version // Version
export const getVersion = () => export const getVersion = () =>
api.get<APIResponse<{ version: string }>>('/version') api.get<APIResponse<{ version: string }>>('/version')
+1
View File
@@ -14,5 +14,6 @@ export default defineConfig({
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',
target: 'es2022',
} }
}) })
+9 -1
View File
@@ -249,7 +249,12 @@ install_apt() {
quota \ quota \
e2fsprogs \ e2fsprogs \
xfsprogs \ xfsprogs \
dnsmasq-base dnsmasq-base \
qemu-kvm \
libvirt-daemon-system \
libvirt-clients \
virtinst \
virt-manager
} }
enable_el_repos() { enable_el_repos() {
@@ -372,6 +377,7 @@ setup_lxc_services() {
systemctl enable --now lxcfs >/dev/null 2>&1 || true systemctl enable --now lxcfs >/dev/null 2>&1 || true
systemctl enable --now lxc-net >/dev/null 2>&1 || true systemctl enable --now lxc-net >/dev/null 2>&1 || true
systemctl enable --now lxc >/dev/null 2>&1 || true systemctl enable --now lxc >/dev/null 2>&1 || true
systemctl enable --now libvirtd >/dev/null 2>&1 || true
return return
fi fi
@@ -382,6 +388,8 @@ setup_lxc_services() {
rc-service lxc start >/dev/null 2>&1 || true rc-service lxc start >/dev/null 2>&1 || true
rc-update add lxcfs default >/dev/null 2>&1 || true rc-update add lxcfs default >/dev/null 2>&1 || true
rc-service lxcfs start >/dev/null 2>&1 || true rc-service lxcfs start >/dev/null 2>&1 || true
rc-update add libvirtd default >/dev/null 2>&1 || true
rc-service libvirtd start >/dev/null 2>&1 || true
return return
fi fi