mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
兼容KVM大多数功能
This commit is contained in:
@@ -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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
+1012
-58
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
Generated
+7
@@ -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",
|
||||||
|
|||||||
@@ -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} -> 22
|
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Vendored
+14
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
target: 'es2022',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+9
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user