Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ui-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,13 @@ jobs:
name: grid-columns
path: grid-columns.png
if-no-files-found: ignore

# Named checks save what they saw (e.g. command-palette.png), so a
# reviewer can look at a UI change without running it.
- name: Upload smoke screenshots
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ui-smoke-screenshots
path: ui-smoke-screenshots/
if-no-files-found: ignore
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ corral-incus

# Coverage profiles (scripts/coverage-gate.sh, just cover)
cover.out

# UI smoke screenshots (scripts/ui-smoke.mjs)
ui-smoke-screenshots/
19 changes: 19 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ Access the Proxmox-style Web UI at `http://localhost:8006` or via `corral web`.

![Mobile Dashboard](screenshots/dashboard-mobile.png)

#### Web keyboard shortcuts

Press `Ctrl+K` (`⌘K` on macOS) to open the command palette. Type part of a
name to find a VM, a CT, a node or a pool. Type a verb and a name to find an
action, for example `stop web-prod`. Press `Enter` to run the first result.
The items that you used last show at the top of the list.

| Key | Action |
|---|---|
| `Ctrl+K` | Open the command palette |
| `?` | Show the list of shortcuts |
| `/` | Filter the tree by guest name |
| `c` | Open the console of the selected VM |
| `s` | Start or stop the selected VM |
| `g` then `d` | Go to the datacenter |

The one-key shortcuts do not operate when you type in a field or in a console.
A read-only user does not see the actions that change a guest.

#### Theme & Branding

In Corral's web UI, you can fully customize the accent colour, the header
Expand Down
70 changes: 68 additions & 2 deletions pkg/demo/demo.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ type demoVM struct {
Template bool
ExpiresAt string
Load float64 // baseline CPU load in millicores for the sparkline
pinnedNode string
}

func (v *demoVM) running() bool {
Expand Down Expand Up @@ -302,6 +303,36 @@ func (d *demoCluster) dispatch(stdin, name string, args []string) ([]byte, error
case args[0] == "label" && len(args) >= 3:
d.applyTagLabel(args)
return []byte{}, nil
case len(args) >= 3 && args[0] == "patch" && args[1] == "vm":
v := d.find(args[2], flagValue(args, "-n"))
if v != nil {
for i, a := range args {
if a == "-p" && i+1 < len(args) {
var p struct {
Spec struct {
Template struct {
Spec struct {
NodeSelector map[string]string `json:"nodeSelector"`
} `json:"spec"`
} `json:"template"`
} `json:"spec"`
}
if json.Unmarshal([]byte(args[i+1]), &p) == nil && p.Spec.Template.Spec.NodeSelector != nil {
if target := p.Spec.Template.Spec.NodeSelector["kubernetes.io/hostname"]; target != "" {
v.Node = target
v.pinnedNode = target
}
}
}
}
}
return []byte{}, nil
case len(args) >= 3 && args[0] == "get" && (args[1] == "vmi" || args[1] == "vmis") && !strings.HasPrefix(args[2], "-"):
v := d.find(args[2], flagValue(args, "-n"))
if v == nil {
return nil, fmt.Errorf("virtualmachineinstance %q not found", args[2])
}
return d.vmiJSON(v), nil
case args[0] == "get" && hasJSONOutput(args):
// A named get ("get datavolume foo -o json") must 404 like kubectl
// would — returning a list shape parses as a zero object and callers
Expand Down Expand Up @@ -352,7 +383,17 @@ func (d *demoCluster) virtctl(args []string) ([]byte, error) {
case "restart":
v.Status = "Running"
case "migrate":
v.Status = "Migrating"
if v.pinnedNode != "" {
v.Node = v.pinnedNode
v.pinnedNode = ""
} else if v.Node == "" || v.Node == "corral-1" {
v.Node = "corral-2"
} else if v.Node == "corral-2" {
v.Node = "corral-3"
} else {
v.Node = "corral-1"
}
v.Status = "Running"
}
return []byte{}, nil
}
Expand Down Expand Up @@ -533,6 +574,27 @@ func (d *demoCluster) vmItem(v *demoVM) map[string]any {
}
}

func (d *demoCluster) vmiJSON(v *demoVM) []byte {
b, _ := json.Marshal(map[string]any{
"metadata": map[string]any{"name": v.Name, "namespace": v.NS},
"status": map[string]any{
"nodeName": v.Node,
"phase": "Running",
"migrationState": map[string]any{
"completed": true,
"sourceNode": "corral-1",
"targetNode": v.Node,
},
"interfaces": []map[string]any{{"ipAddress": v.IP}},
"conditions": []map[string]any{
{"type": "LiveMigratable", "status": "True"},
{"type": "AgentConnected", "status": "True"},
},
},
})
return b
}

func (d *demoCluster) vmiListJSON() []byte {
items := []map[string]any{}
for _, v := range d.vms {
Expand Down Expand Up @@ -652,7 +714,11 @@ var demoStorageClassJSON = []byte(`{"items": [{

var demoNodesJSON = func() []byte {
node := func(name, role string) map[string]any {
labels := map[string]string{"kubernetes.io/hostname": name}
labels := map[string]string{
"kubernetes.io/hostname": name,
"kubevirt.io/schedulable": "true",
"cpu-vendor.node.kubevirt.io/intel": "true",
}
if role != "" {
labels["node-role.kubernetes.io/"+role] = "true"
}
Expand Down
48 changes: 48 additions & 0 deletions pkg/demo/demo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,51 @@ metadata:
t.Fatal("expected test-new-vm to be deleted")
}
}

func TestDemoCluster_MigrateAndPatch(t *testing.T) {
d := newDemoCluster()
v := d.find("web-prod", "corral-vms")
if v == nil {
t.Fatal("web-prod VM not found")
}
if v.Node != "corral-1" {
t.Fatalf("expected web-prod to be on corral-1 initially, got %s", v.Node)
}

// 1. Pinned migration: patch nodeSelector, then run virtctl migrate
patchJSON := `{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"corral-2"}}}}}`
if _, err := d.dispatch("", "kubectl", []string{"patch", "vm", "web-prod", "-n", "corral-vms", "--type", "merge", "-p", patchJSON}); err != nil {
t.Fatalf("patch failed: %v", err)
}
if v.Node != "corral-2" {
t.Fatalf("expected web-prod node to be updated to corral-2 by patch, got %s", v.Node)
}
if _, err := d.virtctl([]string{"migrate", "web-prod", "-n", "corral-vms"}); err != nil {
t.Fatalf("migrate failed: %v", err)
}
if v.Node != "corral-2" {
t.Fatalf("expected web-prod node to remain corral-2 after pinned migrate, got %s", v.Node)
}

// 2. Unpinned migration: virtctl migrate cycles to another node
if _, err := d.virtctl([]string{"migrate", "web-prod", "-n", "corral-vms"}); err != nil {
t.Fatalf("migrate failed: %v", err)
}
if v.Node != "corral-3" {
t.Fatalf("expected web-prod node to cycle to corral-3 after unpinned migrate, got %s", v.Node)
}

// 3. Test get vmi
out, err := d.dispatch("", "kubectl", []string{"get", "vmi", "web-prod", "-n", "corral-vms", "-o", "json"})
if err != nil {
t.Fatalf("get vmi failed: %v", err)
}
var vmi map[string]any
if err := json.Unmarshal(out, &vmi); err != nil {
t.Fatalf("failed to parse vmi json: %v", err)
}
status := vmi["status"].(map[string]any)
if status["nodeName"] != "corral-3" {
t.Errorf("expected vmi status.nodeName to be corral-3, got %v", status["nodeName"])
}
}
Loading
Loading