Hard-cut banger away from source-checkout runtime bundles as an implicit source of\nimage and host defaults. Managed images now own their full boot set,\nimage build starts from an existing registered image, and daemon startup\nno longer synthesizes a default image from host paths.\n\nResolve Firecracker from PATH or firecracker_bin, make SSH keys config-owned\nwith an auto-managed XDG default, replace the external name generator and\npackage manifests with Go code, and keep the vsock helper as a companion\nbinary instead of a user-managed runtime asset.\n\nUpdate the manual scripts, web/CLI forms, config surface, and docs around\nthe new build/manual flow and explicit image registration semantics.\n\nValidation: GOCACHE=/tmp/banger-gocache go test ./..., bash -n scripts/*.sh,\nand make build.
1229 lines
35 KiB
Go
1229 lines
35 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
)
|
|
|
|
type Backend interface {
|
|
Config() model.DaemonConfig
|
|
Layout() paths.Layout
|
|
DashboardSummary(context.Context) (api.DashboardSummary, error)
|
|
ListVMs(context.Context) ([]model.VMRecord, error)
|
|
FindVM(context.Context, string) (model.VMRecord, error)
|
|
GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error)
|
|
BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error)
|
|
VMCreateStatus(context.Context, string) (api.VMCreateOperation, error)
|
|
StartVM(context.Context, string) (model.VMRecord, error)
|
|
StopVM(context.Context, string) (model.VMRecord, error)
|
|
RestartVM(context.Context, string) (model.VMRecord, error)
|
|
DeleteVM(context.Context, string) (model.VMRecord, error)
|
|
SetVM(context.Context, api.VMSetParams) (model.VMRecord, error)
|
|
PortsVM(context.Context, string) (api.VMPortsResult, error)
|
|
ListImages(context.Context) ([]model.Image, error)
|
|
FindImage(context.Context, string) (model.Image, error)
|
|
BeginImageBuild(context.Context, api.ImageBuildParams) (api.ImageBuildOperation, error)
|
|
ImageBuildStatus(context.Context, string) (api.ImageBuildOperation, error)
|
|
RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error)
|
|
PromoteImage(context.Context, string) (model.Image, error)
|
|
DeleteImage(context.Context, string) (model.Image, error)
|
|
}
|
|
|
|
type Server struct {
|
|
backend Backend
|
|
templates *template.Template
|
|
pickerFS fs.FS
|
|
}
|
|
|
|
type pickerRoot struct {
|
|
Label string
|
|
Path string
|
|
}
|
|
|
|
type flashMessage struct {
|
|
Kind string
|
|
Message string
|
|
}
|
|
|
|
type vmCreateForm struct {
|
|
Name string
|
|
ImageName string
|
|
VCPU string
|
|
Memory string
|
|
SystemOverlaySize string
|
|
WorkDiskSize string
|
|
NATEnabled bool
|
|
NoStart bool
|
|
}
|
|
|
|
type vmSetForm struct {
|
|
VCPU string
|
|
Memory string
|
|
WorkDiskSize string
|
|
NATEnabled bool
|
|
}
|
|
|
|
type imageBuildForm struct {
|
|
Name string
|
|
FromImage string
|
|
Size string
|
|
KernelPath string
|
|
InitrdPath string
|
|
ModulesDir string
|
|
Docker bool
|
|
}
|
|
|
|
type imageRegisterForm struct {
|
|
Name string
|
|
RootfsPath string
|
|
WorkSeedPath string
|
|
KernelPath string
|
|
InitrdPath string
|
|
ModulesDir string
|
|
Docker bool
|
|
}
|
|
|
|
type pageData struct {
|
|
Title string
|
|
BodyTemplate string
|
|
BodyHTML template.HTML
|
|
Section string
|
|
Summary api.DashboardSummary
|
|
Flash *flashMessage
|
|
CSRFToken string
|
|
PickerRoots []pickerRoot
|
|
MutationAllowed bool
|
|
ErrorMessage string
|
|
VMs []model.VMRecord
|
|
VM model.VMRecord
|
|
VMImage model.Image
|
|
VMStats model.VMStats
|
|
VMPorts api.VMPortsResult
|
|
VMPortsError string
|
|
VMCreateForm vmCreateForm
|
|
VMSetForm vmSetForm
|
|
Images []model.Image
|
|
Image model.Image
|
|
ImageUsers int
|
|
ImageBuildForm imageBuildForm
|
|
ImageRegisterForm imageRegisterForm
|
|
LogText string
|
|
VMCreateOperation *api.VMCreateOperation
|
|
ImageBuildOperation *api.ImageBuildOperation
|
|
OperationStatusURL string
|
|
OperationSuccessURL string
|
|
OperationLogPath string
|
|
OperationKind string
|
|
}
|
|
|
|
type fsEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Kind string `json:"kind"`
|
|
}
|
|
|
|
type fsListingResponse struct {
|
|
Path string `json:"path"`
|
|
Parent string `json:"parent,omitempty"`
|
|
Kind string `json:"kind"`
|
|
Entries []fsEntry `json:"entries"`
|
|
Roots []pickerRoot `json:"roots"`
|
|
}
|
|
|
|
//go:embed templates/*.html assets/*
|
|
var embeddedAssets embed.FS
|
|
|
|
func NewHandler(backend Backend) http.Handler {
|
|
tmpl := template.Must(template.New("page").Funcs(template.FuncMap{
|
|
"shortID": shortID,
|
|
"formatBytes": formatBytes,
|
|
"formatBytesCompact": formatBytesCompact,
|
|
"formatPercent": formatPercent,
|
|
"percentOf": percentOf,
|
|
"relativeTime": relativeTime,
|
|
"formatBool": formatBool,
|
|
"stateClass": stateClass,
|
|
"findImage": findImage,
|
|
"endpointHref": endpointHref,
|
|
"sumInt64": sumInt64,
|
|
"eq": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
|
|
}).ParseFS(embeddedAssets, "templates/*.html"))
|
|
staticFS, err := fs.Sub(embeddedAssets, "assets")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
server := &Server{
|
|
backend: backend,
|
|
templates: tmpl,
|
|
pickerFS: staticFS,
|
|
}
|
|
mux := http.NewServeMux()
|
|
server.registerRoutes(mux)
|
|
return mux
|
|
}
|
|
|
|
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(s.pickerFS)))
|
|
mux.HandleFunc("GET /", s.wrap(s.handleDashboard))
|
|
mux.HandleFunc("GET /vms", s.wrap(s.handleVMList))
|
|
mux.HandleFunc("GET /vms/new", s.wrap(s.handleVMNew))
|
|
mux.HandleFunc("POST /vms", s.wrap(s.handleVMCreate))
|
|
mux.HandleFunc("GET /vms/{id}", s.wrap(s.handleVMShow))
|
|
mux.HandleFunc("GET /vms/{id}/logs", s.wrap(s.handleVMLogs))
|
|
mux.HandleFunc("POST /vms/{id}/start", s.wrap(s.handleVMStart))
|
|
mux.HandleFunc("POST /vms/{id}/stop", s.wrap(s.handleVMStop))
|
|
mux.HandleFunc("POST /vms/{id}/restart", s.wrap(s.handleVMRestart))
|
|
mux.HandleFunc("POST /vms/{id}/delete", s.wrap(s.handleVMDelete))
|
|
mux.HandleFunc("POST /vms/{id}/set", s.wrap(s.handleVMSet))
|
|
mux.HandleFunc("GET /images", s.wrap(s.handleImageList))
|
|
mux.HandleFunc("GET /images/build", s.wrap(s.handleImageBuildForm))
|
|
mux.HandleFunc("POST /images/build", s.wrap(s.handleImageBuild))
|
|
mux.HandleFunc("GET /images/register", s.wrap(s.handleImageRegisterForm))
|
|
mux.HandleFunc("POST /images/register", s.wrap(s.handleImageRegister))
|
|
mux.HandleFunc("GET /images/{id}", s.wrap(s.handleImageShow))
|
|
mux.HandleFunc("POST /images/{id}/promote", s.wrap(s.handleImagePromote))
|
|
mux.HandleFunc("POST /images/{id}/delete", s.wrap(s.handleImageDelete))
|
|
mux.HandleFunc("GET /operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationPage))
|
|
mux.HandleFunc("GET /operations/image-build/{id}", s.wrap(s.handleImageBuildOperationPage))
|
|
mux.HandleFunc("GET /api/operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationAPI))
|
|
mux.HandleFunc("GET /api/operations/image-build/{id}", s.wrap(s.handleImageBuildOperationAPI))
|
|
mux.HandleFunc("GET /api/fs", s.wrap(s.handleFSAPI))
|
|
}
|
|
|
|
func (s *Server) wrap(fn func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := fn(w, r); err != nil {
|
|
s.writeError(w, r, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
|
status := http.StatusInternalServerError
|
|
lower := strings.ToLower(err.Error())
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist), strings.Contains(lower, "not found"):
|
|
status = http.StatusNotFound
|
|
case strings.Contains(lower, "csrf"), strings.Contains(lower, "cross-origin"):
|
|
status = http.StatusForbidden
|
|
case strings.Contains(lower, "path must"), strings.Contains(lower, "not a directory"):
|
|
status = http.StatusBadRequest
|
|
}
|
|
if status == http.StatusInternalServerError {
|
|
http.Error(w, err.Error(), status)
|
|
return
|
|
}
|
|
if renderErr := s.renderPage(w, r, status, "Not Found", "error_content", func(data *pageData) error {
|
|
data.Section = "none"
|
|
data.ErrorMessage = err.Error()
|
|
return nil
|
|
}); renderErr != nil {
|
|
http.Error(w, err.Error(), status)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int, title, body string, fill func(*pageData) error) error {
|
|
summary, err := s.backend.DashboardSummary(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
flash := s.popFlash(w, r)
|
|
data := &pageData{
|
|
Title: title,
|
|
BodyTemplate: body,
|
|
Summary: summary,
|
|
Flash: flash,
|
|
CSRFToken: s.ensureCSRFToken(w, r),
|
|
PickerRoots: s.pickerRoots(),
|
|
MutationAllowed: summary.Sudo.Available,
|
|
}
|
|
if fill != nil {
|
|
if err := fill(data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var bodyHTML strings.Builder
|
|
if err := s.templates.ExecuteTemplate(&bodyHTML, body, data); err != nil {
|
|
return err
|
|
}
|
|
data.BodyHTML = template.HTML(bodyHTML.String())
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
return s.templates.ExecuteTemplate(w, "page", data)
|
|
}
|
|
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderPage(w, r, http.StatusOK, "Dashboard", "dashboard_content", func(data *pageData) error {
|
|
data.Section = "dashboard"
|
|
vms, err := s.backend.ListVMs(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
images, err := s.backend.ListImages(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data.VMs = vms
|
|
data.Images = images
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMList(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderPage(w, r, http.StatusOK, "VMs", "vm_list_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
vms, err := s.backend.ListVMs(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
images, err := s.backend.ListImages(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data.VMs = vms
|
|
data.Images = images
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMNew(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderVMNewPage(w, r, vmCreateForm{
|
|
VCPU: strconv.Itoa(model.DefaultVCPUCount),
|
|
Memory: strconv.Itoa(model.DefaultMemoryMiB),
|
|
SystemOverlaySize: model.FormatSizeBytes(model.DefaultSystemOverlaySize),
|
|
WorkDiskSize: model.FormatSizeBytes(model.DefaultWorkDiskSize),
|
|
}, "")
|
|
}
|
|
|
|
func (s *Server) renderVMNewPage(w http.ResponseWriter, r *http.Request, form vmCreateForm, formErr string) error {
|
|
return s.renderPage(w, r, http.StatusOK, "Create VM", "vm_new_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
images, err := s.backend.ListImages(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data.Images = images
|
|
data.VMCreateForm = form
|
|
data.ErrorMessage = formErr
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMCreate(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
form, params, err := s.parseVMCreateForm(r)
|
|
if err != nil {
|
|
return s.renderVMNewPage(w, r, form, err.Error())
|
|
}
|
|
if !allowed {
|
|
return s.renderVMNewPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds")
|
|
}
|
|
op, err := s.backend.BeginVMCreate(r.Context(), params)
|
|
if err != nil {
|
|
return s.renderVMNewPage(w, r, form, err.Error())
|
|
}
|
|
http.Redirect(w, r, "/operations/vm-create/"+url.PathEscape(op.ID), http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleVMShow(w http.ResponseWriter, r *http.Request) error {
|
|
_, vmStats, err := s.backend.GetVMStats(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
vm, err := s.backend.FindVM(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
image, _ := s.backend.FindImage(r.Context(), vm.ImageID)
|
|
return s.renderPage(w, r, http.StatusOK, vm.Name, "vm_show_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
data.VM = vm
|
|
data.VMImage = image
|
|
data.VMStats = vmStats
|
|
data.VMSetForm = vmSetForm{
|
|
VCPU: strconv.Itoa(vm.Spec.VCPUCount),
|
|
Memory: strconv.Itoa(vm.Spec.MemoryMiB),
|
|
WorkDiskSize: model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
|
|
NATEnabled: vm.Spec.NATEnabled,
|
|
}
|
|
if vm.State == model.VMStateRunning {
|
|
ports, err := s.backend.PortsVM(r.Context(), vm.ID)
|
|
if err != nil {
|
|
data.VMPortsError = err.Error()
|
|
} else {
|
|
data.VMPorts = ports
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMLogs(w http.ResponseWriter, r *http.Request) error {
|
|
vm, err := s.backend.FindVM(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logText, err := tailFile(vm.Runtime.LogPath, 200)
|
|
if err != nil {
|
|
logText = err.Error()
|
|
}
|
|
return s.renderPage(w, r, http.StatusOK, vm.Name+" Logs", "vm_logs_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
data.VM = vm
|
|
data.LogText = logText
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMStart(w http.ResponseWriter, r *http.Request) error {
|
|
return s.runVMAction(w, r, func(ctx context.Context, id string) error {
|
|
_, err := s.backend.StartVM(ctx, id)
|
|
return err
|
|
}, "VM started")
|
|
}
|
|
|
|
func (s *Server) handleVMStop(w http.ResponseWriter, r *http.Request) error {
|
|
return s.runVMAction(w, r, func(ctx context.Context, id string) error {
|
|
_, err := s.backend.StopVM(ctx, id)
|
|
return err
|
|
}, "VM stopped")
|
|
}
|
|
|
|
func (s *Server) handleVMRestart(w http.ResponseWriter, r *http.Request) error {
|
|
return s.runVMAction(w, r, func(ctx context.Context, id string) error {
|
|
_, err := s.backend.RestartVM(ctx, id)
|
|
return err
|
|
}, "VM restarted")
|
|
}
|
|
|
|
func (s *Server) handleVMDelete(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if _, err := s.backend.DeleteVM(r.Context(), r.PathValue("id")); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", "VM deleted")
|
|
http.Redirect(w, r, "/vms", http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleVMSet(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := "/vms/" + url.PathEscape(r.PathValue("id"))
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
vm, err := s.backend.FindVM(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
params, err := s.parseVMSetForm(r, vm)
|
|
if err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil {
|
|
s.setFlash(w, "info", "No VM settings changed")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if _, err := s.backend.SetVM(r.Context(), params); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", "VM settings updated")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) runVMAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) error, successMessage string) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := "/vms/" + url.PathEscape(r.PathValue("id"))
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if err := action(r.Context(), r.PathValue("id")); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", successMessage)
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleImageList(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderPage(w, r, http.StatusOK, "Images", "image_list_content", func(data *pageData) error {
|
|
data.Section = "images"
|
|
images, err := s.backend.ListImages(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data.Images = images
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImageBuildForm(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderImageBuildPage(w, r, imageBuildForm{}, "")
|
|
}
|
|
|
|
func (s *Server) renderImageBuildPage(w http.ResponseWriter, r *http.Request, form imageBuildForm, formErr string) error {
|
|
return s.renderPage(w, r, http.StatusOK, "Build Image", "image_build_content", func(data *pageData) error {
|
|
data.Section = "images"
|
|
data.ImageBuildForm = form
|
|
data.ErrorMessage = formErr
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImageBuild(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
form, params, err := s.parseImageBuildForm(r)
|
|
if err != nil {
|
|
return s.renderImageBuildPage(w, r, form, err.Error())
|
|
}
|
|
if !allowed {
|
|
return s.renderImageBuildPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds")
|
|
}
|
|
op, err := s.backend.BeginImageBuild(r.Context(), params)
|
|
if err != nil {
|
|
return s.renderImageBuildPage(w, r, form, err.Error())
|
|
}
|
|
http.Redirect(w, r, "/operations/image-build/"+url.PathEscape(op.ID), http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderImageRegisterPage(w, r, imageRegisterForm{}, "")
|
|
}
|
|
|
|
func (s *Server) renderImageRegisterPage(w http.ResponseWriter, r *http.Request, form imageRegisterForm, formErr string) error {
|
|
return s.renderPage(w, r, http.StatusOK, "Register Image", "image_register_content", func(data *pageData) error {
|
|
data.Section = "images"
|
|
data.ImageRegisterForm = form
|
|
data.ErrorMessage = formErr
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImageRegister(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
form, params, err := s.parseImageRegisterForm(r)
|
|
if err != nil {
|
|
return s.renderImageRegisterPage(w, r, form, err.Error())
|
|
}
|
|
if !allowed {
|
|
return s.renderImageRegisterPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds")
|
|
}
|
|
image, err := s.backend.RegisterImage(r.Context(), params)
|
|
if err != nil {
|
|
return s.renderImageRegisterPage(w, r, form, err.Error())
|
|
}
|
|
s.setFlash(w, "success", "Image registered")
|
|
http.Redirect(w, r, "/images/"+url.PathEscape(image.ID), http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleImageShow(w http.ResponseWriter, r *http.Request) error {
|
|
image, err := s.backend.FindImage(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
vms, err := s.backend.ListVMs(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userCount := 0
|
|
for _, vm := range vms {
|
|
if vm.ImageID == image.ID {
|
|
userCount++
|
|
}
|
|
}
|
|
return s.renderPage(w, r, http.StatusOK, image.Name, "image_show_content", func(data *pageData) error {
|
|
data.Section = "images"
|
|
data.Image = image
|
|
data.ImageUsers = userCount
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImagePromote(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := "/images/" + url.PathEscape(r.PathValue("id"))
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if _, err := s.backend.PromoteImage(r.Context(), r.PathValue("id")); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", "Image promoted")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := "/images/" + url.PathEscape(r.PathValue("id"))
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if _, err := s.backend.DeleteImage(r.Context(), r.PathValue("id")); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", "Image deleted")
|
|
http.Redirect(w, r, "/images", http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleVMCreateOperationPage(w http.ResponseWriter, r *http.Request) error {
|
|
op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.renderPage(w, r, http.StatusOK, "Creating VM", "operation_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
data.OperationKind = "vm"
|
|
data.VMCreateOperation = &op
|
|
data.OperationStatusURL = "/api/operations/vm-create/" + url.PathEscape(op.ID)
|
|
if op.VMID != "" {
|
|
data.OperationSuccessURL = "/vms/" + url.PathEscape(op.VMID)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImageBuildOperationPage(w http.ResponseWriter, r *http.Request) error {
|
|
op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.renderPage(w, r, http.StatusOK, "Building Image", "operation_content", func(data *pageData) error {
|
|
data.Section = "images"
|
|
data.OperationKind = "image"
|
|
data.ImageBuildOperation = &op
|
|
data.OperationStatusURL = "/api/operations/image-build/" + url.PathEscape(op.ID)
|
|
if op.ImageID != "" {
|
|
data.OperationSuccessURL = "/images/" + url.PathEscape(op.ImageID)
|
|
}
|
|
data.OperationLogPath = op.BuildLogPath
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Request) error {
|
|
op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, api.VMCreateStatusResult{Operation: op})
|
|
}
|
|
|
|
func (s *Server) handleImageBuildOperationAPI(w http.ResponseWriter, r *http.Request) error {
|
|
op, err := s.backend.ImageBuildStatus(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, api.ImageBuildStatusResult{Operation: op})
|
|
}
|
|
|
|
func (s *Server) handleFSAPI(w http.ResponseWriter, r *http.Request) error {
|
|
path := strings.TrimSpace(r.URL.Query().Get("path"))
|
|
if path == "" {
|
|
path = s.pickerRoots()[0].Path
|
|
}
|
|
path = filepath.Clean(path)
|
|
if !filepath.IsAbs(path) {
|
|
return fmt.Errorf("path must be absolute")
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("%s is not a directory", path)
|
|
}
|
|
kind := r.URL.Query().Get("kind")
|
|
if kind != "dir" {
|
|
kind = "file"
|
|
}
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := fsListingResponse{
|
|
Path: path,
|
|
Kind: kind,
|
|
Entries: make([]fsEntry, 0, len(entries)+1),
|
|
Roots: s.pickerRoots(),
|
|
}
|
|
parent := filepath.Dir(path)
|
|
if parent != path {
|
|
result.Parent = parent
|
|
result.Entries = append(result.Entries, fsEntry{Name: "..", Path: parent, Kind: "up"})
|
|
}
|
|
for _, entry := range entries {
|
|
entryKind := "file"
|
|
if entry.IsDir() {
|
|
entryKind = "dir"
|
|
}
|
|
result.Entries = append(result.Entries, fsEntry{
|
|
Name: entry.Name(),
|
|
Path: filepath.Join(path, entry.Name()),
|
|
Kind: entryKind,
|
|
})
|
|
}
|
|
sort.Slice(result.Entries, func(i, j int) bool {
|
|
left, right := result.Entries[i], result.Entries[j]
|
|
leftRank := kindRank(left.Kind)
|
|
rightRank := kindRank(right.Kind)
|
|
if leftRank != rightRank {
|
|
return leftRank < rightRank
|
|
}
|
|
return strings.ToLower(left.Name) < strings.ToLower(right.Name)
|
|
})
|
|
return writeJSON(w, result)
|
|
}
|
|
|
|
func kindRank(kind string) int {
|
|
switch kind {
|
|
case "up":
|
|
return 0
|
|
case "dir":
|
|
return 1
|
|
default:
|
|
return 2
|
|
}
|
|
}
|
|
|
|
func (s *Server) pickerRoots() []pickerRoot {
|
|
seen := map[string]struct{}{}
|
|
roots := []pickerRoot{{Label: "Filesystem", Path: "/"}}
|
|
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
|
|
roots = append(roots, pickerRoot{Label: "Home", Path: home})
|
|
}
|
|
layout := s.backend.Layout()
|
|
if layout.StateDir != "" {
|
|
roots = append(roots, pickerRoot{Label: "State", Path: layout.StateDir})
|
|
}
|
|
result := make([]pickerRoot, 0, len(roots))
|
|
for _, root := range roots {
|
|
root.Path = filepath.Clean(root.Path)
|
|
if _, ok := seen[root.Path]; ok {
|
|
continue
|
|
}
|
|
seen[root.Path] = struct{}{}
|
|
result = append(result, root)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Server) verifyPOST(w http.ResponseWriter, r *http.Request) error {
|
|
if r.Method != http.MethodPost {
|
|
return nil
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
return err
|
|
}
|
|
if err := verifySameOrigin(r); err != nil {
|
|
return err
|
|
}
|
|
tokenCookie, err := r.Cookie("banger_csrf")
|
|
if err != nil {
|
|
return errors.New("missing csrf cookie")
|
|
}
|
|
if tokenCookie.Value == "" || r.FormValue("csrf_token") != tokenCookie.Value {
|
|
return errors.New("csrf token mismatch")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func verifySameOrigin(r *http.Request) error {
|
|
for _, raw := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} {
|
|
if strings.TrimSpace(raw) == "" {
|
|
continue
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid origin: %w", err)
|
|
}
|
|
if parsed.Host != r.Host {
|
|
return errors.New("cross-origin POST rejected")
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) ensureCSRFToken(w http.ResponseWriter, r *http.Request) string {
|
|
if cookie, err := r.Cookie("banger_csrf"); err == nil && strings.TrimSpace(cookie.Value) != "" {
|
|
return cookie.Value
|
|
}
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
panic(err)
|
|
}
|
|
token := hex.EncodeToString(buf)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "banger_csrf",
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
return token
|
|
}
|
|
|
|
func (s *Server) setFlash(w http.ResponseWriter, kind, message string) {
|
|
payload := base64.RawURLEncoding.EncodeToString([]byte(kind + "\n" + message))
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "banger_flash",
|
|
Value: payload,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) *flashMessage {
|
|
cookie, err := r.Cookie("banger_flash")
|
|
if err != nil || cookie.Value == "" {
|
|
return nil
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "banger_flash",
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
data, err := base64.RawURLEncoding.DecodeString(cookie.Value)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
parts := strings.SplitN(string(data), "\n", 2)
|
|
if len(parts) != 2 {
|
|
return nil
|
|
}
|
|
return &flashMessage{Kind: parts[0], Message: parts[1]}
|
|
}
|
|
|
|
func (s *Server) requireMutationAllowed(ctx context.Context) (bool, error) {
|
|
summary, err := s.backend.DashboardSummary(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return summary.Sudo.Available, nil
|
|
}
|
|
|
|
func (s *Server) parseVMCreateForm(r *http.Request) (vmCreateForm, api.VMCreateParams, error) {
|
|
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
|
|
return vmCreateForm{}, api.VMCreateParams{}, err
|
|
}
|
|
form := vmCreateForm{
|
|
Name: strings.TrimSpace(r.FormValue("name")),
|
|
ImageName: strings.TrimSpace(r.FormValue("image_name")),
|
|
VCPU: strings.TrimSpace(r.FormValue("vcpu")),
|
|
Memory: strings.TrimSpace(r.FormValue("memory")),
|
|
SystemOverlaySize: strings.TrimSpace(r.FormValue("system_overlay_size")),
|
|
WorkDiskSize: strings.TrimSpace(r.FormValue("work_disk_size")),
|
|
NATEnabled: r.FormValue("nat_enabled") == "on",
|
|
NoStart: r.FormValue("no_start") == "on",
|
|
}
|
|
vcpu, err := strconv.Atoi(form.VCPU)
|
|
if err != nil {
|
|
return form, api.VMCreateParams{}, errors.New("vcpu must be an integer")
|
|
}
|
|
memory, err := strconv.Atoi(form.Memory)
|
|
if err != nil {
|
|
return form, api.VMCreateParams{}, errors.New("memory must be an integer")
|
|
}
|
|
params := api.VMCreateParams{
|
|
Name: form.Name,
|
|
ImageName: form.ImageName,
|
|
VCPUCount: &vcpu,
|
|
MemoryMiB: &memory,
|
|
SystemOverlaySize: form.SystemOverlaySize,
|
|
WorkDiskSize: form.WorkDiskSize,
|
|
NATEnabled: form.NATEnabled,
|
|
NoStart: form.NoStart,
|
|
}
|
|
return form, params, nil
|
|
}
|
|
|
|
func (s *Server) parseVMSetForm(r *http.Request, vm model.VMRecord) (api.VMSetParams, error) {
|
|
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
|
|
return api.VMSetParams{}, err
|
|
}
|
|
params := api.VMSetParams{IDOrName: vm.ID}
|
|
if raw := strings.TrimSpace(r.FormValue("vcpu")); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return api.VMSetParams{}, errors.New("vcpu must be an integer")
|
|
}
|
|
if value != vm.Spec.VCPUCount {
|
|
params.VCPUCount = &value
|
|
}
|
|
}
|
|
if raw := strings.TrimSpace(r.FormValue("memory")); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return api.VMSetParams{}, errors.New("memory must be an integer")
|
|
}
|
|
if value != vm.Spec.MemoryMiB {
|
|
params.MemoryMiB = &value
|
|
}
|
|
}
|
|
if raw := strings.TrimSpace(r.FormValue("work_disk_size")); raw != "" && raw != model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes) {
|
|
params.WorkDiskSize = raw
|
|
}
|
|
if raw := strings.TrimSpace(r.FormValue("nat_enabled")); raw != "" {
|
|
value := raw == "true"
|
|
if value != vm.Spec.NATEnabled {
|
|
params.NATEnabled = &value
|
|
}
|
|
}
|
|
return params, nil
|
|
}
|
|
|
|
func (s *Server) parseImageBuildForm(r *http.Request) (imageBuildForm, api.ImageBuildParams, error) {
|
|
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
|
|
return imageBuildForm{}, api.ImageBuildParams{}, err
|
|
}
|
|
form := imageBuildForm{
|
|
Name: strings.TrimSpace(r.FormValue("name")),
|
|
FromImage: strings.TrimSpace(r.FormValue("from_image")),
|
|
Size: strings.TrimSpace(r.FormValue("size")),
|
|
KernelPath: strings.TrimSpace(r.FormValue("kernel_path")),
|
|
InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")),
|
|
ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")),
|
|
Docker: r.FormValue("docker") == "on",
|
|
}
|
|
params := api.ImageBuildParams{
|
|
Name: form.Name,
|
|
FromImage: form.FromImage,
|
|
Size: form.Size,
|
|
KernelPath: form.KernelPath,
|
|
InitrdPath: form.InitrdPath,
|
|
ModulesDir: form.ModulesDir,
|
|
Docker: form.Docker,
|
|
}
|
|
return form, params, nil
|
|
}
|
|
|
|
func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api.ImageRegisterParams, error) {
|
|
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
|
|
return imageRegisterForm{}, api.ImageRegisterParams{}, err
|
|
}
|
|
form := imageRegisterForm{
|
|
Name: strings.TrimSpace(r.FormValue("name")),
|
|
RootfsPath: strings.TrimSpace(r.FormValue("rootfs_path")),
|
|
WorkSeedPath: strings.TrimSpace(r.FormValue("work_seed_path")),
|
|
KernelPath: strings.TrimSpace(r.FormValue("kernel_path")),
|
|
InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")),
|
|
ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")),
|
|
Docker: r.FormValue("docker") == "on",
|
|
}
|
|
params := api.ImageRegisterParams{
|
|
Name: form.Name,
|
|
RootfsPath: form.RootfsPath,
|
|
WorkSeedPath: form.WorkSeedPath,
|
|
KernelPath: form.KernelPath,
|
|
InitrdPath: form.InitrdPath,
|
|
ModulesDir: form.ModulesDir,
|
|
Docker: form.Docker,
|
|
}
|
|
return form, params, nil
|
|
}
|
|
|
|
type nilResponseWriter struct{}
|
|
|
|
func (nilResponseWriter) Header() http.Header { return http.Header{} }
|
|
func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil }
|
|
func (nilResponseWriter) WriteHeader(statusCode int) {}
|
|
|
|
func writeJSON(w http.ResponseWriter, value any) error {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
return json.NewEncoder(w).Encode(value)
|
|
}
|
|
|
|
func tailFile(path string, maxLines int) (string, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return "", errors.New("log path is unavailable")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
|
if maxLines > 0 && len(lines) > maxLines {
|
|
lines = lines[len(lines)-maxLines:]
|
|
}
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
func findImage(images []model.Image, id string) model.Image {
|
|
for _, image := range images {
|
|
if image.ID == id {
|
|
return image
|
|
}
|
|
}
|
|
return model.Image{}
|
|
}
|
|
|
|
func endpointHref(endpoint string) string {
|
|
endpoint = strings.TrimSpace(endpoint)
|
|
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
|
return endpoint
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func shortID(id string) string {
|
|
if len(id) <= 12 {
|
|
return id
|
|
}
|
|
return id[:12]
|
|
}
|
|
|
|
func sumInt64(values ...int64) int64 {
|
|
var total int64
|
|
for _, value := range values {
|
|
total += value
|
|
}
|
|
return total
|
|
}
|
|
|
|
func formatBytes(bytes int64) string {
|
|
const (
|
|
ki = 1024
|
|
mi = ki * 1024
|
|
gi = mi * 1024
|
|
ti = gi * 1024
|
|
)
|
|
switch {
|
|
case bytes >= ti:
|
|
return fmt.Sprintf("%.1f TiB", float64(bytes)/float64(ti))
|
|
case bytes >= gi:
|
|
return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(gi))
|
|
case bytes >= mi:
|
|
return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(mi))
|
|
case bytes >= ki:
|
|
return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(ki))
|
|
default:
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
}
|
|
|
|
func formatBytesCompact(bytes int64) string {
|
|
const (
|
|
ki = 1024
|
|
mi = ki * 1024
|
|
gi = mi * 1024
|
|
ti = gi * 1024
|
|
)
|
|
type unit struct {
|
|
size int64
|
|
suffix string
|
|
}
|
|
units := []unit{
|
|
{size: ti, suffix: "T"},
|
|
{size: gi, suffix: "G"},
|
|
{size: mi, suffix: "M"},
|
|
{size: ki, suffix: "K"},
|
|
}
|
|
abs := bytes
|
|
if abs < 0 {
|
|
abs = -abs
|
|
}
|
|
for _, candidate := range units {
|
|
if abs >= candidate.size {
|
|
value := float64(bytes) / float64(candidate.size)
|
|
if math.Abs(value-math.Round(value)) < 0.05 {
|
|
return fmt.Sprintf("%.0f%s", math.Round(value), candidate.suffix)
|
|
}
|
|
return fmt.Sprintf("%.1f%s", value, candidate.suffix)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%dB", bytes)
|
|
}
|
|
|
|
func percentOf(used, total any) int {
|
|
totalValue := numericValue(total)
|
|
if totalValue <= 0 {
|
|
return 0
|
|
}
|
|
usedValue := numericValue(used)
|
|
percent := int(math.Round((usedValue / totalValue) * 100))
|
|
switch {
|
|
case percent < 0:
|
|
return 0
|
|
case percent > 100:
|
|
return 100
|
|
default:
|
|
return percent
|
|
}
|
|
}
|
|
|
|
func numericValue(value any) float64 {
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return float64(typed)
|
|
case int8:
|
|
return float64(typed)
|
|
case int16:
|
|
return float64(typed)
|
|
case int32:
|
|
return float64(typed)
|
|
case int64:
|
|
return float64(typed)
|
|
case uint:
|
|
return float64(typed)
|
|
case uint8:
|
|
return float64(typed)
|
|
case uint16:
|
|
return float64(typed)
|
|
case uint32:
|
|
return float64(typed)
|
|
case uint64:
|
|
return float64(typed)
|
|
case float32:
|
|
return float64(typed)
|
|
case float64:
|
|
return typed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func formatPercent(value float64) string {
|
|
return fmt.Sprintf("%.1f%%", value)
|
|
}
|
|
|
|
func relativeTime(ts time.Time) string {
|
|
if ts.IsZero() {
|
|
return "-"
|
|
}
|
|
delta := time.Since(ts)
|
|
switch {
|
|
case delta < time.Minute:
|
|
return "just now"
|
|
case delta < time.Hour:
|
|
return fmt.Sprintf("%d minutes ago", int(delta.Minutes()))
|
|
case delta < 24*time.Hour:
|
|
return fmt.Sprintf("%d hours ago", int(delta.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%d days ago", int(delta.Hours()/24))
|
|
}
|
|
}
|
|
|
|
func formatBool(value bool) string {
|
|
if value {
|
|
return "yes"
|
|
}
|
|
return "no"
|
|
}
|
|
|
|
func stateClass(state model.VMState) string {
|
|
switch state {
|
|
case model.VMStateRunning:
|
|
return "running"
|
|
case model.VMStateStopped:
|
|
return "stopped"
|
|
case model.VMStateError:
|
|
return "error"
|
|
default:
|
|
return "created"
|
|
}
|
|
}
|