From 85c8c4e590625148faf75859072fe9fae3e1f302 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 15:44:48 +0200 Subject: [PATCH 01/21] f-32: disk: add boot multidisk handle Signed-off-by: GnomeZworc --- internal/api/agent/vms.go | 23 ++++++++++++-------- internal/dispatcher/agent/vm_commands.go | 27 +++++++++++++++--------- internal/vm/create.go | 2 +- internal/vm/data.go | 20 ++++++++++++++---- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/internal/api/agent/vms.go b/internal/api/agent/vms.go index 4dc6136..2ea5748 100644 --- a/internal/api/agent/vms.go +++ b/internal/api/agent/vms.go @@ -77,16 +77,21 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { return } + disks := make([]dispatcher.VMDisk, len(req.Storage)) + for i, s := range req.Storage { + disks[i] = dispatcher.VMDisk{Path: s.Path, Dev: s.Dev} + } + cmd := dispatcher.StartVMCommand{ - Name: req.Name, - Subnet: primary.Subnet, - IP: primary.IP, - VolumePath: req.Storage[0].Path, - Memory: req.Memory, - CPUs: req.CPUs, - UEFI: req.UEFI, - Password: req.Password, - SSHKey: req.SSHKey, + Name: req.Name, + Subnet: primary.Subnet, + IP: primary.IP, + Disks: disks, + Memory: req.Memory, + CPUs: req.CPUs, + UEFI: req.UEFI, + Password: req.Password, + SSHKey: req.SSHKey, } if err := s.dispatcher.Prepare(cmd); err != nil { diff --git a/internal/dispatcher/agent/vm_commands.go b/internal/dispatcher/agent/vm_commands.go index 1c4bbce..bdfeaec 100644 --- a/internal/dispatcher/agent/vm_commands.go +++ b/internal/dispatcher/agent/vm_commands.go @@ -13,16 +13,21 @@ import ( "github.com/dgraph-io/badger/v4" ) +type VMDisk struct { + Path string + Dev string +} + type StartVMCommand struct { - Name string - Subnet string - IP string - VolumePath string - Memory int - CPUs int - UEFI bool - Password string - SSHKey string + Name string + Subnet string + IP string + Disks []VMDisk + Memory int + CPUs int + UEFI bool + Password string + SSHKey string } func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { @@ -44,7 +49,9 @@ func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { kv.AddInDB(db, "vm/"+c.Name+"/subnet", c.Subnet) kv.AddInDB(db, "vm/"+c.Name+"/ip", c.IP) kv.AddInDB(db, "vm/"+c.Name+"/metadata_port", strconv.Itoa(port)) - kv.AddInDB(db, "vm/"+c.Name+"/volume_path", c.VolumePath) + for _, d := range c.Disks { + kv.AddInDB(db, "vm/"+c.Name+"/disk/"+d.Dev, d.Path) + } kv.AddInDB(db, "vm/"+c.Name+"/memory", strconv.Itoa(c.Memory)) kv.AddInDB(db, "vm/"+c.Name+"/cpus", strconv.Itoa(c.CPUs)) if c.UEFI { diff --git a/internal/vm/create.go b/internal/vm/create.go index 529c73c..267dab5 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -56,7 +56,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { Name: name, TapID: d.tapID, Mac: d.mac, - VolumePath: d.volumePath, + VolumePath: d.disks[0].path, Memory: d.memory, CPUs: d.cpus, SerialDir: cfg.QEMU.SerialDir, diff --git a/internal/vm/data.go b/internal/vm/data.go index 1e2644e..3435fb1 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -11,6 +11,11 @@ import ( "github.com/dgraph-io/badger/v4" ) +type diskEntry struct { + path string + dev string +} + type vmData struct { subnetName string vpcName string @@ -20,7 +25,7 @@ type vmData struct { ip string metadataPort string mac string - volumePath string + disks []diskEntry memory int cpus int uefi bool @@ -82,11 +87,18 @@ func loadVM(db *badger.DB, name string) (vmData, error) { } d.mac = mac - volumePath, err := kv.GetFromDB(db, "vm/"+name+"/volume_path") + diskEntries, err := kv.ListByPrefix(db, "vm/"+name+"/disk/") if err != nil { - return d, fmt.Errorf("get volume_path: %w", err) + return d, fmt.Errorf("list disks: %w", err) + } + if len(diskEntries) == 0 { + return d, fmt.Errorf("no disks found for vm %q", name) + } + diskPrefix := "vm/" + name + "/disk/" + for key, path := range diskEntries { + dev := strings.TrimPrefix(key, diskPrefix) + d.disks = append(d.disks, diskEntry{path: path, dev: dev}) } - d.volumePath = volumePath memoryStr, err := kv.GetFromDB(db, "vm/"+name+"/memory") if err != nil { From 89899005d9eb2b9f2e84fe68f5230990090ad956 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 15:56:45 +0200 Subject: [PATCH 02/21] f-32: disk: add disk in qemu start Signed-off-by: GnomeZworc --- internal/api/agent/vm.go | 7 +++-- internal/qemu/start_linux.go | 55 +++++++++++++++++++++++++++++------- internal/qemu/start_other.go | 23 ++++++++++----- internal/vm/create.go | 7 ++++- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/internal/api/agent/vm.go b/internal/api/agent/vm.go index f932766..d9f6bf5 100644 --- a/internal/api/agent/vm.go +++ b/internal/api/agent/vm.go @@ -87,8 +87,11 @@ func vmFromDB(name string, entries map[string]string) (VM, error) { vm.Interfaces = []VMInterface{{Subnet: subnet, IP: ip, Primary: true}} } - if path := entries[prefix+"volume_path"]; path != "" { - vm.Storage = []VMStorage{{Path: path}} + diskPrefix := prefix + "disk/" + for key, path := range entries { + if dev := strings.TrimPrefix(key, diskPrefix); dev != key { + vm.Storage = append(vm.Storage, VMStorage{Path: path, Dev: dev}) + } } return vm, nil diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go index 3f40e73..2043b64 100644 --- a/internal/qemu/start_linux.go +++ b/internal/qemu/start_linux.go @@ -7,20 +7,27 @@ import ( "os" "os/exec" "path/filepath" + "sort" + "strings" ) +type DiskConfig struct { + Path string + Dev string +} + type Config struct { - Name string - TapID int - Mac string - VolumePath string - Memory int - CPUs int + Name string + TapID int + Mac string + Disks []DiskConfig + Memory int + CPUs int UEFICodePath string UEFIVarsPath string - SerialDir string - MonitorDir string - QMPDir string + SerialDir string + MonitorDir string + QMPDir string } func Start(cfg Config) error { @@ -64,8 +71,36 @@ func Start(cfg Config) error { ) } + hasScsi := false + for _, d := range cfg.Disks { + if strings.HasPrefix(d.Dev, "sd") { + hasScsi = true + break + } + } + if hasScsi { + args = append(args, "-device", "virtio-scsi-pci,id=scsi0") + } + + sorted := make([]DiskConfig, len(cfg.Disks)) + copy(sorted, cfg.Disks) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Dev < sorted[j].Dev }) + + for _, d := range sorted { + if strings.HasPrefix(d.Dev, "sd") { + scsiID := int(d.Dev[2] - 'a') + args = append(args, + "-drive", fmt.Sprintf("file=%s,if=none,id=%s", d.Path, d.Dev), + "-device", fmt.Sprintf("scsi-hd,drive=%s,bus=scsi0.0,scsi-id=%d", d.Dev, scsiID), + ) + } else { + args = append(args, + "-drive", fmt.Sprintf("file=%s,if=virtio,id=%s", d.Path, d.Dev), + ) + } + } + args = append(args, - "-drive", fmt.Sprintf("file=%s,if=virtio", cfg.VolumePath), "-netdev", fmt.Sprintf("tap,id=net0,ifname=tap%d,script=no,downscript=no", cfg.TapID), "-device", fmt.Sprintf("virtio-net-pci,netdev=net0,mac=%s", cfg.Mac), "-daemonize", diff --git a/internal/qemu/start_other.go b/internal/qemu/start_other.go index c9c9192..f48d95a 100644 --- a/internal/qemu/start_other.go +++ b/internal/qemu/start_other.go @@ -4,14 +4,23 @@ package qemu import "errors" +type DiskConfig struct { + Path string + Dev string +} + type Config struct { - Name, Mac, VolumePath string - TapID, Memory, CPUs int - UEFICodePath string - UEFIVarsPath string - SerialDir string - MonitorDir string - QMPDir string + Name string + TapID int + Mac string + Disks []DiskConfig + Memory int + CPUs int + UEFICodePath string + UEFIVarsPath string + SerialDir string + MonitorDir string + QMPDir string } func Start(_ Config) error { diff --git a/internal/vm/create.go b/internal/vm/create.go index 267dab5..3e0d4a7 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -52,11 +52,16 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("start metadata: %w", err) } + qDisks := make([]qemu.DiskConfig, len(d.disks)) + for i, disk := range d.disks { + qDisks[i] = qemu.DiskConfig{Path: disk.path, Dev: disk.dev} + } + qcfg := qemu.Config{ Name: name, TapID: d.tapID, Mac: d.mac, - VolumePath: d.disks[0].path, + Disks: qDisks, Memory: d.memory, CPUs: d.cpus, SerialDir: cfg.QEMU.SerialDir, From a0637d827ad16cda9457e8085221af29e59d205d Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 16:13:11 +0200 Subject: [PATCH 03/21] f-32: syntax: fix duplicated code Signed-off-by: GnomeZworc --- internal/qemu/config.go | 20 ++++++++++++++++++++ internal/qemu/start_linux.go | 19 ------------------- internal/qemu/start_other.go | 23 +++-------------------- 3 files changed, 23 insertions(+), 39 deletions(-) create mode 100644 internal/qemu/config.go diff --git a/internal/qemu/config.go b/internal/qemu/config.go new file mode 100644 index 0000000..9b50add --- /dev/null +++ b/internal/qemu/config.go @@ -0,0 +1,20 @@ +package qemu + +type DiskConfig struct { + Path string + Dev string +} + +type Config struct { + Name string + TapID int + Mac string + Disks []DiskConfig + Memory int + CPUs int + UEFICodePath string + UEFIVarsPath string + SerialDir string + MonitorDir string + QMPDir string +} diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go index 2043b64..ae03254 100644 --- a/internal/qemu/start_linux.go +++ b/internal/qemu/start_linux.go @@ -11,25 +11,6 @@ import ( "strings" ) -type DiskConfig struct { - Path string - Dev string -} - -type Config struct { - Name string - TapID int - Mac string - Disks []DiskConfig - Memory int - CPUs int - UEFICodePath string - UEFIVarsPath string - SerialDir string - MonitorDir string - QMPDir string -} - func Start(cfg Config) error { memory := cfg.Memory if memory == 0 { diff --git a/internal/qemu/start_other.go b/internal/qemu/start_other.go index f48d95a..c28411c 100644 --- a/internal/qemu/start_other.go +++ b/internal/qemu/start_other.go @@ -2,26 +2,9 @@ package qemu -import "errors" - -type DiskConfig struct { - Path string - Dev string -} - -type Config struct { - Name string - TapID int - Mac string - Disks []DiskConfig - Memory int - CPUs int - UEFICodePath string - UEFIVarsPath string - SerialDir string - MonitorDir string - QMPDir string -} +import ( + "errors" +) func Start(_ Config) error { return errors.New("vm: not supported on this platform") From 9111045417e3a5bd1148d0fe2a0f1ef58d27df3c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 16:18:53 +0200 Subject: [PATCH 04/21] f-32: test: add test for vms Signed-off-by: GnomeZworc --- internal/api/agent/vm_test.go | 158 ++++++++++++++++++ internal/dispatcher/agent/vm_commands_test.go | 130 ++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 internal/api/agent/vm_test.go create mode 100644 internal/dispatcher/agent/vm_commands_test.go diff --git a/internal/api/agent/vm_test.go b/internal/api/agent/vm_test.go new file mode 100644 index 0000000..3af5968 --- /dev/null +++ b/internal/api/agent/vm_test.go @@ -0,0 +1,158 @@ +package agentapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- vmFromDB --- + +func TestVmFromDB_SingleDisk(t *testing.T) { + entries := map[string]string{ + "vm/vm-1/state": "started", + "vm/vm-1/subnet": "sn-1", + "vm/vm-1/ip": "10.0.0.5", + "vm/vm-1/metadata_port": "1234", + "vm/vm-1/memory": "512", + "vm/vm-1/cpus": "1", + "vm/vm-1/disk/sda": "/data/root.qcow2", + } + vm, err := vmFromDB("vm-1", entries) + if err != nil { + t.Fatalf("vmFromDB a échoué : %v", err) + } + if len(vm.Storage) != 1 { + t.Fatalf("attendu 1 disque, obtenu %d", len(vm.Storage)) + } + if vm.Storage[0].Dev != "sda" || vm.Storage[0].Path != "/data/root.qcow2" { + t.Errorf("disque inattendu : %+v", vm.Storage[0]) + } +} + +func TestVmFromDB_MultiDisk(t *testing.T) { + entries := map[string]string{ + "vm/vm-2/state": "started", + "vm/vm-2/subnet": "sn-1", + "vm/vm-2/ip": "10.0.0.6", + "vm/vm-2/metadata_port": "1235", + "vm/vm-2/memory": "1024", + "vm/vm-2/cpus": "2", + "vm/vm-2/disk/sda": "/data/root.qcow2", + "vm/vm-2/disk/sdb": "/data/data.qcow2", + } + vm, err := vmFromDB("vm-2", entries) + if err != nil { + t.Fatalf("vmFromDB a échoué : %v", err) + } + if len(vm.Storage) != 2 { + t.Fatalf("attendu 2 disques, obtenu %d", len(vm.Storage)) + } + sort.Slice(vm.Storage, func(i, j int) bool { return vm.Storage[i].Dev < vm.Storage[j].Dev }) + if vm.Storage[0].Dev != "sda" || vm.Storage[1].Dev != "sdb" { + t.Errorf("devs attendus [sda sdb], obtenus [%s %s]", vm.Storage[0].Dev, vm.Storage[1].Dev) + } +} + +func TestVmFromDB_SlotGap(t *testing.T) { + // sdb absent — sda et sdc seulement + entries := map[string]string{ + "vm/vm-3/state": "started", + "vm/vm-3/subnet": "sn-1", + "vm/vm-3/ip": "10.0.0.7", + "vm/vm-3/metadata_port": "1236", + "vm/vm-3/memory": "512", + "vm/vm-3/cpus": "1", + "vm/vm-3/disk/sda": "/data/root.qcow2", + "vm/vm-3/disk/sdc": "/data/extra.qcow2", + } + vm, err := vmFromDB("vm-3", entries) + if err != nil { + t.Fatalf("vmFromDB a échoué : %v", err) + } + if len(vm.Storage) != 2 { + t.Fatalf("attendu 2 disques, obtenu %d", len(vm.Storage)) + } + devs := map[string]bool{} + for _, s := range vm.Storage { + devs[s.Dev] = true + } + if !devs["sda"] || !devs["sdc"] { + t.Errorf("attendu sda et sdc, obtenus %v", devs) + } + if devs["sdb"] { + t.Error("sdb ne devrait pas apparaître") + } +} + +// --- POST /vms --- + +func TestStartVM_MultiDisk(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + body, _ := json.Marshal(VMCreateRequest{ + Name: "vm-10", + Interfaces: []VMInterface{ + {Subnet: "sn-1", IP: "10.0.0.10", Primary: true}, + }, + Storage: []VMStorage{ + {Path: "/data/root.qcow2", Dev: "sda"}, + {Path: "/data/data.qcow2", Dev: "sdb"}, + }, + Memory: 1024, + CPUs: 2, + }) + + w := httptest.NewRecorder() + s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String()) + } + + for _, dev := range []string{"sda", "sdb"} { + if _, err := kv.GetFromDB(db, "vm/vm-10/disk/"+dev); err != nil { + t.Errorf("disk/%s absent en DB après création", dev) + } + } + if _, err := kv.GetFromDB(db, "vm/vm-10/volume_path"); err == nil { + t.Error("volume_path ne devrait plus exister en DB") + } +} + +func TestStartVM_StorageReturnedInResponse(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + body, _ := json.Marshal(VMCreateRequest{ + Name: "vm-11", + Interfaces: []VMInterface{ + {Subnet: "sn-1", IP: "10.0.0.11", Primary: true}, + }, + Storage: []VMStorage{ + {Path: "/data/root.qcow2", Dev: "sda"}, + }, + }) + + w := httptest.NewRecorder() + s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d", w.Code) + } + + var vm VM + json.NewDecoder(w.Body).Decode(&vm) + if len(vm.Storage) != 1 { + t.Fatalf("attendu 1 disque dans la réponse, obtenu %d", len(vm.Storage)) + } + if vm.Storage[0].Dev != "sda" || vm.Storage[0].Path != "/data/root.qcow2" { + t.Errorf("disque inattendu dans la réponse : %+v", vm.Storage[0]) + } +} diff --git a/internal/dispatcher/agent/vm_commands_test.go b/internal/dispatcher/agent/vm_commands_test.go new file mode 100644 index 0000000..ba400b9 --- /dev/null +++ b/internal/dispatcher/agent/vm_commands_test.go @@ -0,0 +1,130 @@ +package dispatcher + +import ( + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- StartVMCommand.Prepare : écriture des disques en DB --- + +func TestStartVMCommand_Prepare_SingleDisk(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + cmd := StartVMCommand{ + Name: "vm-1", + Subnet: "sn-1", + IP: "10.0.0.5", + Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "sda"}}, + } + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + + path, err := kv.GetFromDB(db, "vm/vm-1/disk/sda") + if err != nil { + t.Fatalf("clé disk/sda absente en DB : %v", err) + } + if path != "/data/root.qcow2" { + t.Errorf("path attendu /data/root.qcow2, obtenu %q", path) + } +} + +func TestStartVMCommand_Prepare_MultiDisk(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + cmd := StartVMCommand{ + Name: "vm-2", + Subnet: "sn-1", + IP: "10.0.0.6", + Disks: []VMDisk{ + {Path: "/data/root.qcow2", Dev: "sda"}, + {Path: "/data/data.qcow2", Dev: "sdb"}, + }, + } + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + + for dev, want := range map[string]string{ + "sda": "/data/root.qcow2", + "sdb": "/data/data.qcow2", + } { + got, err := kv.GetFromDB(db, "vm/vm-2/disk/"+dev) + if err != nil { + t.Fatalf("clé disk/%s absente en DB : %v", dev, err) + } + if got != want { + t.Errorf("disk/%s : attendu %q, obtenu %q", dev, want, got) + } + } +} + +func TestStartVMCommand_Prepare_SlotGap(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + // sdb absent au boot — slot réservé pour hotplug + cmd := StartVMCommand{ + Name: "vm-3", + Subnet: "sn-1", + IP: "10.0.0.7", + Disks: []VMDisk{ + {Path: "/data/root.qcow2", Dev: "sda"}, + {Path: "/data/extra.qcow2", Dev: "sdc"}, + }, + } + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + + if _, err := kv.GetFromDB(db, "vm/vm-3/disk/sda"); err != nil { + t.Fatalf("disk/sda absent : %v", err) + } + if _, err := kv.GetFromDB(db, "vm/vm-3/disk/sdc"); err != nil { + t.Fatalf("disk/sdc absent : %v", err) + } + if _, err := kv.GetFromDB(db, "vm/vm-3/disk/sdb"); err == nil { + t.Error("disk/sdb ne devrait pas exister en DB") + } +} + +func TestStartVMCommand_Prepare_NoVolumePath(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + cmd := StartVMCommand{ + Name: "vm-4", + Subnet: "sn-1", + IP: "10.0.0.8", + Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "sda"}}, + } + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + + if _, err := kv.GetFromDB(db, "vm/vm-4/volume_path"); err == nil { + t.Error("volume_path ne devrait plus être écrit en DB") + } +} + +func TestStartVMCommand_Prepare_Duplicate(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vm/vm-exist/state", "started") + + cmd := StartVMCommand{ + Name: "vm-exist", + Subnet: "sn-1", + IP: "10.0.0.9", + Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "sda"}}, + } + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer si la VM existe déjà") + } +} From c3f26836cd60ab94bac2293ce767a8f4041cba41 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 16:47:49 +0200 Subject: [PATCH 05/21] f-32: fix: update boot order Signed-off-by: GnomeZworc --- internal/qemu/start_linux.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go index ae03254..a7869a2 100644 --- a/internal/qemu/start_linux.go +++ b/internal/qemu/start_linux.go @@ -65,18 +65,29 @@ func Start(cfg Config) error { sorted := make([]DiskConfig, len(cfg.Disks)) copy(sorted, cfg.Disks) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Dev < sorted[j].Dev }) + // vd* avant sd* : les disques virtio-blk bootent en premier. + // À lettre égale de type, ordre alphabétique. + sort.Slice(sorted, func(i, j int) bool { + iVirtio := strings.HasPrefix(sorted[i].Dev, "vd") + jVirtio := strings.HasPrefix(sorted[j].Dev, "vd") + if iVirtio != jVirtio { + return iVirtio + } + return sorted[i].Dev < sorted[j].Dev + }) - for _, d := range sorted { + for idx, d := range sorted { + bootindex := idx + 1 if strings.HasPrefix(d.Dev, "sd") { scsiID := int(d.Dev[2] - 'a') args = append(args, "-drive", fmt.Sprintf("file=%s,if=none,id=%s", d.Path, d.Dev), - "-device", fmt.Sprintf("scsi-hd,drive=%s,bus=scsi0.0,scsi-id=%d", d.Dev, scsiID), + "-device", fmt.Sprintf("scsi-hd,drive=%s,bus=scsi0.0,scsi-id=%d,bootindex=%d", d.Dev, scsiID, bootindex), ) } else { args = append(args, - "-drive", fmt.Sprintf("file=%s,if=virtio,id=%s", d.Path, d.Dev), + "-drive", fmt.Sprintf("file=%s,if=none,id=%s", d.Path, d.Dev), + "-device", fmt.Sprintf("virtio-blk-pci,drive=%s,bootindex=%d", d.Dev, bootindex), ) } } From 4ab880a32d8d4871a275af6fcc19bf58abfb2cfa Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 19:02:31 +0200 Subject: [PATCH 06/21] f-32: vm: fix incomplete stop vm Signed-off-by: GnomeZworc --- internal/vm/delete.go | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/internal/vm/delete.go b/internal/vm/delete.go index 7e47f3f..3d808e6 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -33,25 +33,18 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock") - if _, err := qmp.Send(socketPath, []string{`{"execute":"system_powerdown"}`}); err != nil { - return fmt.Errorf("qmp system_powerdown: %w", err) - } - - // attendre l'arrêt effectif de la VM ; forcer via quit après timeout - timeout := time.After(time.Duration(cfg.Dispatcher.TimeoutSeconds) * time.Second) - poll := time.Duration(cfg.Dispatcher.PollSeconds) * time.Second - stopped := false - for !stopped { - select { - case <-timeout: - qmp.Send(socketPath, []string{`{"execute":"quit"}`}) - stopped = true - case <-time.After(poll): - if _, err := qmp.Send(socketPath, nil); err != nil { - stopped = true - } + if _, err := os.Stat(socketPath); err == nil { + // socket présent : tenter l'arrêt gracieux + if _, err := qmp.Send(socketPath, []string{`{"execute":"system_powerdown"}`}); err == nil { + waitQMPDead(socketPath, + time.Duration(cfg.Dispatcher.TimeoutSeconds)*time.Second, + time.Duration(cfg.Dispatcher.PollSeconds)*time.Second, + ) } + // connexion QMP échouée : QEMU déjà mort } + // socket absent ou QEMU déjà arrêté : cleanup direct + if err := netns.Call(d.vpcName, func() error { return iptables.DeleteMetadataRedirect(d.ip, d.interfaceIP, d.metadataPort) @@ -74,3 +67,18 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return kv.AddInDB(db, "vm/"+name+"/state", "stopped") } + +func waitQMPDead(socketPath string, timeout, poll time.Duration) { + timer := time.After(timeout) + for { + select { + case <-timer: + qmp.Send(socketPath, []string{`{"execute":"quit"}`}) + return + case <-time.After(poll): + if _, err := qmp.Send(socketPath, nil); err != nil { + return + } + } + } +} From 14ef0ecc8375525b87f74290e480eb258e70b497 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Fri, 29 May 2026 23:36:59 +0200 Subject: [PATCH 07/21] web: start: premier j'ai d'un dashboard modulaire Signed-off-by: GnomeZworc --- web/components.json | 5 + web/components/api-client/api-client.js | 123 ++++++++++++++++++++ web/components/api-client/manifest.json | 5 + web/components/date-display/date-display.js | 69 +++++++++++ web/components/date-display/manifest.json | 5 + web/components/side-nav/manifest.json | 5 + web/components/side-nav/side-nav.js | 100 ++++++++++++++++ web/config.json | 5 + web/core/config.js | 6 + web/core/registry.js | 10 ++ web/favicon.svg | 24 ++++ web/index.html | 100 ++++++++++++++++ web/navigation.json | 22 ++++ 13 files changed, 479 insertions(+) create mode 100644 web/components.json create mode 100644 web/components/api-client/api-client.js create mode 100644 web/components/api-client/manifest.json create mode 100644 web/components/date-display/date-display.js create mode 100644 web/components/date-display/manifest.json create mode 100644 web/components/side-nav/manifest.json create mode 100644 web/components/side-nav/side-nav.js create mode 100644 web/config.json create mode 100644 web/core/config.js create mode 100644 web/core/registry.js create mode 100644 web/favicon.svg create mode 100644 web/index.html create mode 100644 web/navigation.json diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..6dae454 --- /dev/null +++ b/web/components.json @@ -0,0 +1,5 @@ +[ + "components/api-client/api-client.js", + "components/side-nav/side-nav.js", + "components/date-display/date-display.js" +] diff --git a/web/components/api-client/api-client.js b/web/components/api-client/api-client.js new file mode 100644 index 0000000..d9eab75 --- /dev/null +++ b/web/components/api-client/api-client.js @@ -0,0 +1,123 @@ +// API service component. +// +// Declare once in the shell: +// +// +// Use from any other component: +// const api = document.querySelector('api-client') +// const vrfs = await api.netbox.list('/ipam/vrfs/') +// const vpc = await api.agent.get('/vpcs/vp-admin') +// await api.agent.post('/vpcs', { name: 'vp-admin', cidr: '10.0.0.0/8' }) +// await api.agent.delete('/vpcs/vp-admin') +// await api.agent.waitFor('/vms/i-test1', 'started') +// +// Phase 2: swap this component for one that points to the orchestrator. +// No other component changes. + +import { config } from '../../core/config.js' + +export class ApiError extends Error { + constructor(origin, status, message) { + super(`[${origin}] HTTP ${status}: ${message}`) + this.origin = origin // 'netbox' | 'agent' + this.status = status + } +} + +class ApiClient extends HTMLElement { + connectedCallback() { + this.style.display = 'none' + this.netbox = this.#buildNetbox() + this.agent = this.#buildAgent() + } + + // ── Internal ────────────────────────────────────────────────────────────── + + async #request(origin, url, options = {}) { + let response + try { + response = await fetch(url, options) + } catch (e) { + throw new ApiError(origin, 0, `Network error: ${e.message}`) + } + + if (response.status === 204) return null + + const text = await response.text() + let json + try { json = JSON.parse(text) } catch { json = null } + + if (!response.ok) { + const detail = json?.detail ?? json?.message ?? text + throw new ApiError(origin, response.status, detail) + } + + return json + } + + // ── Netbox ──────────────────────────────────────────────────────────────── + + #buildNetbox() { + const headers = () => ({ + 'Authorization': `Token ${config.netbox_token}`, + 'Accept': 'application/json', + }) + + const url = (path, params = {}) => { + const u = new URL(path, config.netbox_url + '/api/') + Object.entries(params).forEach(([k, v]) => { + if (v != null) u.searchParams.set(k, v) + }) + return u.toString() + } + + return { + // Returns results array. Netbox paginates; limit=1000 covers most cases. + list: (path, params = {}) => + this.#request('netbox', url(path, { limit: 1000, ...params }), { headers: headers() }) + .then(d => d?.results ?? []), + + get: (path, params = {}) => + this.#request('netbox', url(path, params), { headers: headers() }), + } + } + + // ── Agent ───────────────────────────────────────────────────────────────── + + #buildAgent() { + const headers = (body = false) => ({ + 'Accept': 'application/json', + ...(body ? { 'Content-Type': 'application/json' } : {}), + }) + + const url = path => new URL(path, config.agent_url + '/').toString() + + const req = (method, path, body) => + this.#request('agent', url(path), { + method, + headers: headers(body != null), + ...(body != null ? { body: JSON.stringify(body) } : {}), + }) + + return { + get: path => req('GET', path), + list: path => req('GET', path), + post: (path, b) => req('POST', path, b), + delete: path => req('DELETE', path), + + // Poll path until resource.state === desired or timeout. + waitFor: async (path, desired, { timeout = 120_000, interval = 2_000 } = {}) => { + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + const r = await req('GET', path) + if (r?.state === desired) return r + if (r?.state === 'error') throw new ApiError('agent', 'error', `${path} reached error state`) + await new Promise(ok => setTimeout(ok, interval)) + } + throw new ApiError('agent', 'timeout', `${path} did not reach '${desired}' in ${timeout}ms`) + }, + } + } +} + +customElements.define('api-client', ApiClient) diff --git a/web/components/api-client/manifest.json b/web/components/api-client/manifest.json new file mode 100644 index 0000000..49c3c0c --- /dev/null +++ b/web/components/api-client/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "api-client", + "version": "0.1.0", + "description": "Service component. Provides Netbox and agent API access to all other components via document.querySelector('api-client')." +} diff --git a/web/components/date-display/date-display.js b/web/components/date-display/date-display.js new file mode 100644 index 0000000..dab208e --- /dev/null +++ b/web/components/date-display/date-display.js @@ -0,0 +1,69 @@ +import { config } from '../../core/config.js' + +class DateDisplay extends HTMLElement { + #interval = null + + connectedCallback() { + this.attachShadow({ mode: 'open' }) + this.shadowRoot.innerHTML = ` + +
Current time
+
+
+
agent →
+ ` + + this.shadowRoot.getElementById('agent-url').textContent = config.agent_url + this.#tick() + this.#interval = setInterval(() => this.#tick(), 1000) + } + + disconnectedCallback() { + clearInterval(this.#interval) + } + + #tick() { + const now = new Date() + this.shadowRoot.getElementById('time').textContent = now.toLocaleTimeString() + this.shadowRoot.getElementById('date').textContent = now.toLocaleDateString(undefined, { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' + }) + } +} + +customElements.define('date-display', DateDisplay) diff --git a/web/components/date-display/manifest.json b/web/components/date-display/manifest.json new file mode 100644 index 0000000..6320a47 --- /dev/null +++ b/web/components/date-display/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "date-display", + "version": "0.1.0", + "description": "Displays current date and time, refreshed every second." +} diff --git a/web/components/side-nav/manifest.json b/web/components/side-nav/manifest.json new file mode 100644 index 0000000..67cdfbd --- /dev/null +++ b/web/components/side-nav/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "side-nav", + "version": "0.1.0", + "description": "Left sidebar navigation, driven by navigation.json." +} diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js new file mode 100644 index 0000000..f8d7435 --- /dev/null +++ b/web/components/side-nav/side-nav.js @@ -0,0 +1,100 @@ +// Side navigation component. +// Reads items from navigation.json (path relative to web root via data-base attribute, +// defaults to ./navigation.json). +// Highlights the active entry by matching href against window.location.pathname. + +const ICONS = { + dashboard: ``, + vpc: ``, + subnet: ``, + vm: ``, +} + +function resolveIcon(name) { + return ICONS[name] ?? `` +} + +class SideNav extends HTMLElement { + async connectedCallback() { + this.attachShadow({ mode: 'open' }) + + const navPath = this.dataset.src ?? './navigation.json' + const response = await fetch(navPath) + if (!response.ok) throw new Error(`side-nav: failed to load ${navPath}`) + const items = await response.json() + + this.#render(items) + } + + #render(items) { + const currentPage = window.location.pathname.split('/').pop() || 'index.html' + + const links = items.map(item => { + const isActive = item.href === currentPage + return ` + + ${resolveIcon(item.icon)} + ${item.label} + + ` + }).join('') + + this.shadowRoot.innerHTML = ` + + + ${links} + ` + } +} + +customElements.define('side-nav', SideNav) diff --git a/web/config.json b/web/config.json new file mode 100644 index 0000000..bf6e218 --- /dev/null +++ b/web/config.json @@ -0,0 +1,5 @@ +{ + "netbox_url": "http://netbox.local", + "netbox_token": "your-token-here", + "agent_url": "http://127.0.0.1:8080" +} diff --git a/web/core/config.js b/web/core/config.js new file mode 100644 index 0000000..21618e4 --- /dev/null +++ b/web/core/config.js @@ -0,0 +1,6 @@ +// Single source of truth for configuration. +// Phase 2: replace the fetch with a call to the orchestrator. +const response = await fetch('./config.json') +if (!response.ok) throw new Error('Failed to load config.json') + +export const config = await response.json() diff --git a/web/core/registry.js b/web/core/registry.js new file mode 100644 index 0000000..56671cc --- /dev/null +++ b/web/core/registry.js @@ -0,0 +1,10 @@ +// Loads and registers all components listed in components.json. +// To add a component: git clone into components/, add the path here. +const response = await fetch('./components.json') +if (!response.ok) throw new Error('Failed to load components.json') + +const components = await response.json() + +for (const path of components) { + await import(`../${path}`) +} diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..3b8d831 --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a9301aa --- /dev/null +++ b/web/index.html @@ -0,0 +1,100 @@ + + + + + + two — dashboard + + + + + +
+

two

+ network orchestrator +
+ + + +
+ + +
+
+ +
+
+ + + + + diff --git a/web/navigation.json b/web/navigation.json new file mode 100644 index 0000000..96a2b94 --- /dev/null +++ b/web/navigation.json @@ -0,0 +1,22 @@ +[ + { + "label": "Dashboard", + "href": "index.html", + "icon": "dashboard" + }, + { + "label": "VPCs", + "href": "vpc.html", + "icon": "vpc" + }, + { + "label": "Subnets", + "href": "subnet.html", + "icon": "subnet" + }, + { + "label": "VMs", + "href": "vm.html", + "icon": "vm" + } +] From da740aa322c98d5a6a672a9f2c2ff45cdebe37b0 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 30 May 2026 00:00:33 +0200 Subject: [PATCH 08/21] web: start: login et logout Signed-off-by: GnomeZworc --- web/components.json | 2 + web/components/api-client/api-client.js | 38 ++-- web/components/login-gate/login-gate.js | 211 ++++++++++++++++++ web/components/login-gate/manifest.json | 5 + web/components/logout-button/logout-button.js | 75 +++++++ web/components/logout-button/manifest.json | 5 + web/index.html | 11 +- 7 files changed, 330 insertions(+), 17 deletions(-) create mode 100644 web/components/login-gate/login-gate.js create mode 100644 web/components/login-gate/manifest.json create mode 100644 web/components/logout-button/logout-button.js create mode 100644 web/components/logout-button/manifest.json diff --git a/web/components.json b/web/components.json index 6dae454..4d6c2ee 100644 --- a/web/components.json +++ b/web/components.json @@ -1,5 +1,7 @@ [ + "components/login-gate/login-gate.js", "components/api-client/api-client.js", "components/side-nav/side-nav.js", + "components/logout-button/logout-button.js", "components/date-display/date-display.js" ] diff --git a/web/components/api-client/api-client.js b/web/components/api-client/api-client.js index d9eab75..15dfc3e 100644 --- a/web/components/api-client/api-client.js +++ b/web/components/api-client/api-client.js @@ -1,6 +1,7 @@ // API service component. // -// Declare once in the shell: +// Declare after login-gate in the shell: +// // // // Use from any other component: @@ -11,10 +12,8 @@ // await api.agent.delete('/vpcs/vp-admin') // await api.agent.waitFor('/vms/i-test1', 'started') // -// Phase 2: swap this component for one that points to the orchestrator. -// No other component changes. - -import { config } from '../../core/config.js' +// Credentials come from .ready — no direct config.json dependency. +// Phase 2: swap login-gate for oidc-gate → this component unchanged. export class ApiError extends Error { constructor(origin, status, message) { @@ -25,12 +24,23 @@ export class ApiError extends Error { } class ApiClient extends HTMLElement { - connectedCallback() { + // Resolves once login-gate is ready (or immediately if no gate in DOM). + #credentials = null + + async connectedCallback() { this.style.display = 'none' + + const gate = document.querySelector('login-gate') + this.#credentials = gate ? await gate.ready : null + this.netbox = this.#buildNetbox() this.agent = this.#buildAgent() } + get creds() { + return this.#credentials ?? {} + } + // ── Internal ────────────────────────────────────────────────────────────── async #request(origin, url, options = {}) { @@ -59,12 +69,12 @@ class ApiClient extends HTMLElement { #buildNetbox() { const headers = () => ({ - 'Authorization': `Token ${config.netbox_token}`, + 'Authorization': `Token ${this.creds.netbox_token}`, 'Accept': 'application/json', }) const url = (path, params = {}) => { - const u = new URL(path, config.netbox_url + '/api/') + const u = new URL(path, this.creds.netbox_url + '/api/') Object.entries(params).forEach(([k, v]) => { if (v != null) u.searchParams.set(k, v) }) @@ -72,7 +82,6 @@ class ApiClient extends HTMLElement { } return { - // Returns results array. Netbox paginates; limit=1000 covers most cases. list: (path, params = {}) => this.#request('netbox', url(path, { limit: 1000, ...params }), { headers: headers() }) .then(d => d?.results ?? []), @@ -90,7 +99,7 @@ class ApiClient extends HTMLElement { ...(body ? { 'Content-Type': 'application/json' } : {}), }) - const url = path => new URL(path, config.agent_url + '/').toString() + const url = path => new URL(path, this.creds.agent_url + '/').toString() const req = (method, path, body) => this.#request('agent', url(path), { @@ -100,12 +109,11 @@ class ApiClient extends HTMLElement { }) return { - get: path => req('GET', path), - list: path => req('GET', path), - post: (path, b) => req('POST', path, b), - delete: path => req('DELETE', path), + get: path => req('GET', path), + list: path => req('GET', path), + post: (path, b) => req('POST', path, b), + delete: path => req('DELETE', path), - // Poll path until resource.state === desired or timeout. waitFor: async (path, desired, { timeout = 120_000, interval = 2_000 } = {}) => { const deadline = Date.now() + timeout while (Date.now() < deadline) { diff --git a/web/components/login-gate/login-gate.js b/web/components/login-gate/login-gate.js new file mode 100644 index 0000000..247fa06 --- /dev/null +++ b/web/components/login-gate/login-gate.js @@ -0,0 +1,211 @@ +// Auth service component. +// +// Declare before api-client in the shell: +// +// +// Exposes: +// gate.ready → Promise — awaited by api-client before any call +// gate.credentials → current credentials or null +// gate.logout() → clears session and reloads +// +// Credentials shape (extensible for Phase 2): +// { +// netbox_url: string, +// netbox_token: string, +// agent_url: string, +// } +// +// Phase 2: swap for that resolves ready with a JWT — api-client unchanged. + +const SESSION_KEY = 'two:credentials' + +class LoginGate extends HTMLElement { + #resolve = null + + get credentials() { + const raw = sessionStorage.getItem(SESSION_KEY) + return raw ? JSON.parse(raw) : null + } + + logout() { + sessionStorage.removeItem(SESSION_KEY) + window.location.reload() + } + + async connectedCallback() { + this.style.display = 'none' + + this.ready = new Promise(resolve => { this.#resolve = resolve }) + + const stored = this.credentials + if (stored) { + this.#resolve(stored) + return + } + + // Load config.json defaults to pre-fill the form + let defaults = {} + try { + const r = await fetch('./config.json') + if (r.ok) defaults = await r.json() + } catch { /* no defaults */ } + + this.#renderOverlay(defaults) + } + + #renderOverlay(defaults) { + const overlay = document.createElement('div') + overlay.attachShadow({ mode: 'open' }) + overlay.shadowRoot.innerHTML = ` + + +
+
+
+

two — connect

+

Enter your Netbox and agent details to continue.

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ ` + + document.body.appendChild(overlay) + + const form = overlay.shadowRoot.getElementById('form') + + form.addEventListener('submit', e => { + e.preventDefault() + + const credentials = { + netbox_url: overlay.shadowRoot.getElementById('netbox_url').value.replace(/\/$/, ''), + netbox_token: overlay.shadowRoot.getElementById('netbox_token').value.trim(), + agent_url: overlay.shadowRoot.getElementById('agent_url').value.replace(/\/$/, ''), + } + + sessionStorage.setItem(SESSION_KEY, JSON.stringify(credentials)) + overlay.remove() + this.#resolve(credentials) + }) + } +} + +customElements.define('login-gate', LoginGate) diff --git a/web/components/login-gate/manifest.json b/web/components/login-gate/manifest.json new file mode 100644 index 0000000..0dfe4cc --- /dev/null +++ b/web/components/login-gate/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "login-gate", + "version": "0.1.0", + "description": "Auth service component. Exposes a 'ready' Promise that resolves with credentials. Shows a login overlay if no session exists." +} diff --git a/web/components/logout-button/logout-button.js b/web/components/logout-button/logout-button.js new file mode 100644 index 0000000..45aa80f --- /dev/null +++ b/web/components/logout-button/logout-button.js @@ -0,0 +1,75 @@ +// Logout button — drop anywhere in the DOM. +// +// full button with label +// icon only (for tight spaces) +// +// Delegates to .logout(). Works regardless of where it sits +// (header, members panel, dropdown…) since it resolves the gate from the document. +// +// Phase 2: if login-gate is swapped for oidc-gate, this still works as long as +// the auth component exposes a logout() method (see #gate()). + +const ICON = `` + +class LogoutButton extends HTMLElement { + connectedCallback() { + this.attachShadow({ mode: 'open' }) + + const compact = this.hasAttribute('compact') + + this.shadowRoot.innerHTML = ` + + + + ` + + this.shadowRoot.querySelector('button') + .addEventListener('click', () => this.#logout()) + } + + // Resolves the auth component. Today: login-gate. Tomorrow: any element + // exposing logout() (oidc-gate, oauth-gate…). + #gate() { + return document.querySelector('login-gate, [data-auth-gate]') + } + + #logout() { + const gate = this.#gate() + if (gate?.logout) { + gate.logout() + } else { + console.warn('logout-button: no auth gate with logout() found in document') + } + } +} + +customElements.define('logout-button', LogoutButton) diff --git a/web/components/logout-button/manifest.json b/web/components/logout-button/manifest.json new file mode 100644 index 0000000..0abc50b --- /dev/null +++ b/web/components/logout-button/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "logout-button", + "version": "0.1.0", + "description": "Drop-anywhere button that clears the session via login-gate.logout(). Reusable in header, members panel, etc." +} diff --git a/web/index.html b/web/index.html index a9301aa..5d82bdc 100644 --- a/web/index.html +++ b/web/index.html @@ -39,6 +39,10 @@ color: #6c7086; } + header .spacer { + flex: 1; + } + .layout { display: flex; flex: 1; @@ -70,13 +74,16 @@ + + +

two

network orchestrator +
+
- -
From 1d79f06f605cd5f62d696913065ad402c3f30d33 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 30 May 2026 10:05:40 +0200 Subject: [PATCH 09/21] web: start: build Signed-off-by: GnomeZworc --- web/build.sh | 177 ++++++++++++++++++++++++ web/components.lock.json | 1 + web/components.yml | 25 ++++ web/components/api-client/api-client.js | 2 + web/index.html | 1 + 5 files changed, 206 insertions(+) create mode 100755 web/build.sh create mode 100644 web/components.lock.json create mode 100644 web/components.yml diff --git a/web/build.sh b/web/build.sh new file mode 100755 index 0000000..6a33d4f --- /dev/null +++ b/web/build.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Build the web UI component set from components.yml. +# +# ./build.sh build (download remote components + generate components.json) +# ./build.sh --check validate manifest and entry files without downloading +# +# Reads components.yml, downloads each remote component into components// +# at the requested ref, then generates components.json (runtime load list) and +# components.lock.json (resolved commit pins). +# +# Dependencies: yq (mikefarah/yq v4), jq, curl +# Optional env: +# GIT_TOKEN forge token for private repos (sent as "Authorization: token …") + +set -euo pipefail + +WEB_DIR="$(cd "$(dirname "$0")" && pwd)" +MANIFEST="${WEB_DIR}/components.yml" +COMPONENTS_DIR="${WEB_DIR}/components" +OUTPUT="${WEB_DIR}/components.json" +LOCKFILE="${WEB_DIR}/components.lock.json" +GIT_TOKEN="${GIT_TOKEN:-}" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +log() { echo -e "${GREEN}[+]${NC} $*"; } +warn() { echo -e "${YELLOW}[!]${NC} $*"; } +info() { echo -e "${BLUE}[i]${NC} $*"; } +die() { echo -e "${RED}[-]${NC} $*" >&2; exit 1; } + +CHECK_ONLY=false +[[ "${1:-}" == "--check" ]] && CHECK_ONLY=true + +# ── Dependency + manifest guards ──────────────────────────────────────────────── + +command -v yq &>/dev/null || die "yq is required (mikefarah/yq v4)" +command -v jq &>/dev/null || die "jq is required" +command -v curl &>/dev/null || die "curl is required" +[[ -f "$MANIFEST" ]] || die "manifest not found: $MANIFEST" + +# ── Forge API helpers ─────────────────────────────────────────────────────────── + +api_get() { + local url="$1" + local args=(-sf -H "Accept: application/json") + [[ -n "$GIT_TOKEN" ]] && args+=(-H "Authorization: token ${GIT_TOKEN}") + curl "${args[@]}" "$url" || die "API request failed: $url" +} + +raw_dl() { + local url="$1" out="$2" + local args=(-sfL) + [[ -n "$GIT_TOKEN" ]] && args+=(-H "Authorization: token ${GIT_TOKEN}") + curl "${args[@]}" "$url" -o "$out" || die "download failed: $url" +} + +# Split a repo URL into "server owner repo". +# https://git.g3e.fr/team-reseau/vpc-panel → https://git.g3e.fr team-reseau vpc-panel +parse_repo_url() { + local url="$1" + url="${url%.git}"; url="${url%/}" + local proto="${url%%://*}" + local rest="${url#*://}" + local host="${rest%%/*}" + local path="${rest#*/}" + [[ "$path" == "$rest" || -z "$path" ]] && die "invalid repo URL (need owner/repo): $1" + local owner="${path%/*}" repo="${path##*/}" + [[ -z "$owner" || -z "$repo" ]] && die "invalid repo URL (need owner/repo): $1" + echo "${proto}://${host}" "$owner" "$repo" +} + +resolve_commit() { + local server="$1" owner="$2" repo="$3" ref="$4" + api_get "${server}/api/v1/repos/${owner}/${repo}/commits?sha=${ref}&limit=1" \ + | jq -r '.[0].sha // empty' +} + +# Recursively download a repo path (relative to repo root) into dest dir. +download_path() { + local server="$1" owner="$2" repo="$3" ref="$4" rpath="$5" dest="$6" + + local api + if [[ -z "$rpath" ]]; then + api="${server}/api/v1/repos/${owner}/${repo}/contents?ref=${ref}" + else + api="${server}/api/v1/repos/${owner}/${repo}/contents/${rpath}?ref=${ref}" + fi + + local listing + listing="$(api_get "$api")" + + mkdir -p "$dest" + while IFS= read -r entry; do + local type name dl path + type="$(jq -r '.type' <<<"$entry")" + name="$(jq -r '.name' <<<"$entry")" + dl="$( jq -r '.download_url // ""' <<<"$entry")" + path="$(jq -r '.path' <<<"$entry")" + + if [[ "$type" == "dir" ]]; then + download_path "$server" "$owner" "$repo" "$ref" "$path" "${dest}/${name}" + else + [[ -z "$dl" ]] && die "no download_url for ${path} in ${owner}/${repo}@${ref}" + raw_dl "$dl" "${dest}/${name}" + fi + done < <(echo "$listing" | jq -c 'if type=="array" then .[] else . end') +} + +# ── Download WASM libs ─────────────────────────────────────────────────────────── + +# ── Read manifest ──────────────────────────────────────────────────────────────── + +mapfile -t LOCALS < <(yq '(.local // [])[]' "$MANIFEST") +mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") + +# Ordered list of load paths for components.json +declare -a LOAD_PATHS=() + +# ── Validate locals ────────────────────────────────────────────────────────────── + +for name in "${LOCALS[@]}"; do + [[ -z "$name" ]] && continue + entry="${COMPONENTS_DIR}/${name}/${name}.js" + if [[ ! -f "$entry" ]]; then + die "local component '${name}' missing entry file: components/${name}/${name}.js" + fi + LOAD_PATHS+=("components/${name}/${name}.js") +done + +# ── Download remotes ───────────────────────────────────────────────────────────── + +LOCK_ENTRIES="[]" + +for spec in "${REMOTES[@]}"; do + [[ -z "$spec" ]] && continue + IFS='|' read -r name repo ref <<<"$spec" + [[ -z "$name" || -z "$repo" ]] && die "manifest entry missing name or repo: '$spec'" + + read -r server owner gitrepo <<<"$(parse_repo_url "$repo")" + dest="${COMPONENTS_DIR}/${name}" + + if [[ "$CHECK_ONLY" == true ]]; then + info "would fetch ${name} from ${repo}@${ref}" + commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref" || true)" + [[ -z "$commit" ]] && warn " could not resolve ref '${ref}' in ${owner}/${gitrepo}" + LOAD_PATHS+=("components/${name}/${name}.js") + continue + fi + + log "fetching ${name} ← ${owner}/${gitrepo}@${ref}" + commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref")" + [[ -z "$commit" ]] && die "cannot resolve ref '${ref}' in ${owner}/${gitrepo}" + + rm -rf "$dest" + download_path "$server" "$owner" "$gitrepo" "$ref" "" "$dest" + + entry="${dest}/${name}.js" + [[ -f "$entry" ]] || die "component '${name}' has no ${name}.js at repo root" + + LOAD_PATHS+=("components/${name}/${name}.js") + LOCK_ENTRIES="$(jq \ + --arg name "$name" --arg repo "$repo" --arg ref "$ref" --arg commit "$commit" \ + '. + [{name:$name, repo:$repo, ref:$ref, commit:$commit}]' <<<"$LOCK_ENTRIES")" + info " pinned ${commit:0:12}" +done + +# ── Generate components.json ───────────────────────────────────────────────────── + +if [[ "$CHECK_ONLY" == true ]]; then + log "check passed: ${#LOAD_PATHS[@]} components, manifest valid" + exit 0 +fi + +printf '%s\n' "${LOAD_PATHS[@]}" | jq -R . | jq -s . > "$OUTPUT" +echo "$LOCK_ENTRIES" | jq '.' > "$LOCKFILE" + +log "wrote ${OUTPUT} (${#LOAD_PATHS[@]} components)" +log "wrote ${LOCKFILE} ($(jq 'length' <<<"$LOCK_ENTRIES") remote pins)" diff --git a/web/components.lock.json b/web/components.lock.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/web/components.lock.json @@ -0,0 +1 @@ +[] diff --git a/web/components.yml b/web/components.yml new file mode 100644 index 0000000..646d294 --- /dev/null +++ b/web/components.yml @@ -0,0 +1,25 @@ +# Component manifest — source of truth for the web UI. +# +# Edit this file, then run ./build.sh to: +# - download every remote component into components// +# - generate components.json (the runtime load list) in declared order +# +# Load order matters: service components (login-gate, api-client) must come +# before the components that consume them. + +# Local components shipped inside this repo. Not downloaded — listed here only +# so build.sh can place them in the generated load order. +local: + - login-gate + - api-client + - side-nav + - logout-button + - date-display + +# Remote components fetched from git at build time. +# Each is its own repo; its root must contain .js and manifest.json. +# +# - name: vpc-panel # → components/vpc-panel/ +# repo: https://git.g3e.fr/team-reseau/vpc-panel +# ref: v1.2.0 # tag, branch or commit (default: main) +components: [] diff --git a/web/components/api-client/api-client.js b/web/components/api-client/api-client.js index 15dfc3e..1d4c104 100644 --- a/web/components/api-client/api-client.js +++ b/web/components/api-client/api-client.js @@ -30,6 +30,7 @@ class ApiClient extends HTMLElement { async connectedCallback() { this.style.display = 'none' + // Support login-gate (static token) and biscuit-gate (attenuated token). const gate = document.querySelector('login-gate') this.#credentials = gate ? await gate.ready : null @@ -37,6 +38,7 @@ class ApiClient extends HTMLElement { this.agent = this.#buildAgent() } + get creds() { return this.#credentials ?? {} } diff --git a/web/index.html b/web/index.html index 5d82bdc..4c75ef4 100644 --- a/web/index.html +++ b/web/index.html @@ -90,6 +90,7 @@
+
From aa171000dde7405147d41fb6cb1ad29c611a5e77 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 1 Jun 2026 19:14:40 +0200 Subject: [PATCH 10/21] web: start: add side panel Signed-off-by: GnomeZworc --- web/components.json | 1 + web/components.yml | 1 + web/components/side-panel/manifest.json | 5 + web/components/side-panel/side-panel.js | 236 ++++++++++++++++++++++++ web/index.html | 29 ++- 5 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 web/components/side-panel/manifest.json create mode 100644 web/components/side-panel/side-panel.js diff --git a/web/components.json b/web/components.json index 4d6c2ee..36d2ab3 100644 --- a/web/components.json +++ b/web/components.json @@ -2,6 +2,7 @@ "components/login-gate/login-gate.js", "components/api-client/api-client.js", "components/side-nav/side-nav.js", + "components/side-panel/side-panel.js", "components/logout-button/logout-button.js", "components/date-display/date-display.js" ] diff --git a/web/components.yml b/web/components.yml index 646d294..f2878e3 100644 --- a/web/components.yml +++ b/web/components.yml @@ -15,6 +15,7 @@ local: - side-nav - logout-button - date-display + - side-panel # Remote components fetched from git at build time. # Each is its own repo; its root must contain .js and manifest.json. diff --git a/web/components/side-panel/manifest.json b/web/components/side-panel/manifest.json new file mode 100644 index 0000000..460f54b --- /dev/null +++ b/web/components/side-panel/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "side-panel", + "version": "0.1.0", + "description": "Configurable right-side drawer. Multiple instances stack independently. Opens declaratively or via SidePanel.open()." +} diff --git a/web/components/side-panel/side-panel.js b/web/components/side-panel/side-panel.js new file mode 100644 index 0000000..9feaa4d --- /dev/null +++ b/web/components/side-panel/side-panel.js @@ -0,0 +1,236 @@ +// Configurable right-side drawer panel. +// +// ── Declarative ────────────────────────────────────────────────────────────── +// +// +// +// +// panel.open() +// panel.close() +// +// ── Programmatic (from any component) ──────────────────────────────────────── +// +// import { SidePanel } from '../side-panel/side-panel.js' +// +// const panel = SidePanel.open({ +// title: 'VPC — vp-admin', +// width: '520px', +// content: '', +// }) +// panel.close() +// +// ── Multiple panels ─────────────────────────────────────────────────────────── +// +// Each open panel stacks with a slight depth offset. +// Clicking the backdrop closes only the topmost panel. +// ESC closes the topmost panel. + +const CLOSE_ICON = ` + +` + +const STACK_OFFSET = 12 // px shift per stacked panel +const ANIM_DURATION = 220 // ms + +function openCount() { + return document.querySelectorAll('side-panel[data-open]').length +} + +function topPanel() { + const panels = [...document.querySelectorAll('side-panel[data-open]')] + return panels[panels.length - 1] ?? null +} + +// Global ESC handler — closes topmost panel only. +document.addEventListener('keydown', e => { + if (e.key === 'Escape') topPanel()?.close() +}) + +export class SidePanel extends HTMLElement { + // ── Static factory ────────────────────────────────────────────────────────── + + static open({ title = '', content = '', width = '480px' } = {}) { + const panel = document.createElement('side-panel') + if (title) panel.setAttribute('title', title) + if (width) panel.setAttribute('width', width) + if (content) panel.innerHTML = content + document.body.appendChild(panel) + panel.open() + return panel + } + + // ── Lifecycle ─────────────────────────────────────────────────────────────── + + connectedCallback() { + this.attachShadow({ mode: 'open' }) + this.#render() + } + + // ── Public API ────────────────────────────────────────────────────────────── + + open() { + const depth = openCount() + const offset = depth * STACK_OFFSET + + // Shift panel left based on stack depth + this.style.setProperty('--offset', `${offset}px`) + this.setAttribute('data-open', '') + + // Backdrop: only show/darken the shared one + this.#ensureBackdrop() + + this.dispatchEvent(new CustomEvent('panel-open', { bubbles: true })) + } + + close() { + if (!this.hasAttribute('data-open')) return + + this.removeAttribute('data-open') + this.#updateBackdrop() + + this.addEventListener('transitionend', () => { + // If created programmatically (appended to body by factory), remove from DOM + if (this.dataset.programmatic) this.remove() + }, { once: true }) + + this.dispatchEvent(new CustomEvent('panel-close', { bubbles: true })) + } + + // ── Rendering ─────────────────────────────────────────────────────────────── + + #render() { + const title = this.getAttribute('title') ?? '' + const width = this.getAttribute('width') ?? '480px' + + this.shadowRoot.innerHTML = ` + + +
+ ${title} + +
+
+ +
+ ` + + this.shadowRoot.querySelector('.close').addEventListener('click', () => this.close()) + + // Mark programmatic panels so they self-remove on close + if (!this.parentElement || this.parentElement === document.body) { + this.dataset.programmatic = 'true' + } + } + + // ── Backdrop ───────────────────────────────────────────────────────────────── + + #ensureBackdrop() { + let bd = document.getElementById('__side-panel-backdrop__') + if (!bd) { + bd = document.createElement('div') + bd.id = '__side-panel-backdrop__' + Object.assign(bd.style, { + position: 'fixed', inset: '0', + background: 'rgba(0,0,0,0)', + transition: `background ${ANIM_DURATION}ms`, + zIndex: '999', + }) + bd.addEventListener('click', () => topPanel()?.close()) + document.body.appendChild(bd) + } + // Opacity scales with stack depth (max 0.5) + const opacity = Math.min(0.5, openCount() * 0.15) + requestAnimationFrame(() => { bd.style.background = `rgba(0,0,0,${opacity})` }) + } + + #updateBackdrop() { + const bd = document.getElementById('__side-panel-backdrop__') + if (!bd) return + const remaining = openCount() + if (remaining === 0) { + bd.style.background = 'rgba(0,0,0,0)' + bd.addEventListener('transitionend', () => bd.remove(), { once: true }) + } else { + const opacity = Math.min(0.5, remaining * 0.15) + bd.style.background = `rgba(0,0,0,${opacity})` + } + } +} + +customElements.define('side-panel', SidePanel) diff --git a/web/index.html b/web/index.html index 4c75ef4..19127ed 100644 --- a/web/index.html +++ b/web/index.html @@ -90,12 +90,39 @@
- + + +
+ + +
+ + + From 6e976ac6e3f6d895147f961600130a16ab88a5d8 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 1 Jun 2026 19:44:44 +0200 Subject: [PATCH 12/21] web: start: move css to dedicated file Signed-off-by: GnomeZworc --- web/components/date-display/date-display.css | 38 +++++++ web/components/date-display/date-display.js | 40 +------ web/components/login-gate/login-gate.css | 81 ++++++++++++++ web/components/login-gate/login-gate.js | 103 +----------------- .../logout-button/logout-button.css | 30 +++++ web/components/logout-button/logout-button.js | 46 +------- web/components/side-nav/side-nav.css | 40 +++++++ web/components/side-nav/side-nav.js | 42 +------ web/components/side-panel/side-panel.css | 83 ++++++++++++++ web/components/side-panel/side-panel.js | 94 +--------------- 10 files changed, 288 insertions(+), 309 deletions(-) create mode 100644 web/components/date-display/date-display.css create mode 100644 web/components/login-gate/login-gate.css create mode 100644 web/components/logout-button/logout-button.css create mode 100644 web/components/side-nav/side-nav.css create mode 100644 web/components/side-panel/side-panel.css diff --git a/web/components/date-display/date-display.css b/web/components/date-display/date-display.css new file mode 100644 index 0000000..06c3390 --- /dev/null +++ b/web/components/date-display/date-display.css @@ -0,0 +1,38 @@ +:host { + display: block; + background: #1e1e2e; + border: 1px solid #313244; + border-radius: 8px; + padding: 16px 20px; + font-family: monospace; + color: #cdd6f4; + min-width: 260px; +} + +.label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6c7086; + margin-bottom: 6px; +} + +.time { + font-size: 28px; + font-weight: 600; + color: #89b4fa; +} + +.date { + font-size: 13px; + color: #a6adc8; + margin-top: 4px; +} + +.agent { + margin-top: 12px; + font-size: 11px; + color: #585b70; + border-top: 1px solid #313244; + padding-top: 10px; +} diff --git a/web/components/date-display/date-display.js b/web/components/date-display/date-display.js index dab208e..c008c93 100644 --- a/web/components/date-display/date-display.js +++ b/web/components/date-display/date-display.js @@ -1,53 +1,19 @@ import { config } from '../../core/config.js' +const CSS = new URL('./date-display.css', import.meta.url).href + class DateDisplay extends HTMLElement { #interval = null connectedCallback() { this.attachShadow({ mode: 'open' }) this.shadowRoot.innerHTML = ` - +
Current time
agent →
` - this.shadowRoot.getElementById('agent-url').textContent = config.agent_url this.#tick() this.#interval = setInterval(() => this.#tick(), 1000) diff --git a/web/components/login-gate/login-gate.css b/web/components/login-gate/login-gate.css new file mode 100644 index 0000000..f843d38 --- /dev/null +++ b/web/components/login-gate/login-gate.css @@ -0,0 +1,81 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +.overlay { + position: fixed; + inset: 0; + background: #181825; + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.card { + background: #1e1e2e; + border: 1px solid #313244; + border-radius: 12px; + padding: 36px 40px; + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + gap: 24px; +} + +@media (max-width: 480px) { + .card { padding: 24px 20px; margin: 0 12px; } +} + +.header { display: flex; flex-direction: column; gap: 6px; } + +.header h1 { + font-size: 20px; + font-weight: 600; + color: #89b4fa; + letter-spacing: 0.02em; +} + +.header p { font-size: 13px; color: #6c7086; } + +.fields { display: flex; flex-direction: column; gap: 14px; } +.field { display: flex; flex-direction: column; gap: 6px; } + +label { + font-size: 12px; + font-weight: 500; + color: #a6adc8; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +input { + background: #181825; + border: 1px solid #313244; + border-radius: 6px; + padding: 9px 12px; + color: #cdd6f4; + font-size: 14px; + font-family: monospace; + outline: none; + transition: border-color 0.15s; + width: 100%; +} + +input:focus { border-color: #89b4fa; } +input::placeholder { color: #45475a; } + +button { + background: #89b4fa; + color: #1e1e2e; + border: none; + border-radius: 6px; + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + width: 100%; +} + +button:hover { background: #b4d0fa; } diff --git a/web/components/login-gate/login-gate.js b/web/components/login-gate/login-gate.js index 247fa06..1de65de 100644 --- a/web/components/login-gate/login-gate.js +++ b/web/components/login-gate/login-gate.js @@ -18,6 +18,7 @@ // Phase 2: swap for that resolves ready with a JWT — api-client unchanged. const SESSION_KEY = 'two:credentials' +const CSS = new URL('./login-gate.css', import.meta.url).href class LoginGate extends HTMLElement { #resolve = null @@ -57,107 +58,7 @@ class LoginGate extends HTMLElement { const overlay = document.createElement('div') overlay.attachShadow({ mode: 'open' }) overlay.shadowRoot.innerHTML = ` - - +
diff --git a/web/components/logout-button/logout-button.css b/web/components/logout-button/logout-button.css new file mode 100644 index 0000000..2a6aa10 --- /dev/null +++ b/web/components/logout-button/logout-button.css @@ -0,0 +1,30 @@ +:host { + display: inline-flex; +} + +button { + display: inline-flex; + align-items: center; + gap: 7px; + background: transparent; + border: 1px solid #313244; + border-radius: 6px; + padding: 7px 12px; + color: #a6adc8; + font-size: 13px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} + +button:hover { + background: #313244; + color: #f38ba8; + border-color: #f38ba8; +} + +.icon { display: flex; align-items: center; } + +/* compact attribute — icon only */ +:host([compact]) button { padding: 7px; } +:host([compact]) .label { display: none; } diff --git a/web/components/logout-button/logout-button.js b/web/components/logout-button/logout-button.js index 45aa80f..69b651f 100644 --- a/web/components/logout-button/logout-button.js +++ b/web/components/logout-button/logout-button.js @@ -1,63 +1,21 @@ -// Logout button — drop anywhere in the DOM. -// -// full button with label -// icon only (for tight spaces) -// -// Delegates to .logout(). Works regardless of where it sits -// (header, members panel, dropdown…) since it resolves the gate from the document. -// -// Phase 2: if login-gate is swapped for oidc-gate, this still works as long as -// the auth component exposes a logout() method (see #gate()). +const CSS = new URL('./logout-button.css', import.meta.url).href const ICON = `` class LogoutButton extends HTMLElement { connectedCallback() { this.attachShadow({ mode: 'open' }) - - const compact = this.hasAttribute('compact') - this.shadowRoot.innerHTML = ` - - + ` - this.shadowRoot.querySelector('button') .addEventListener('click', () => this.#logout()) } - // Resolves the auth component. Today: login-gate. Tomorrow: any element - // exposing logout() (oidc-gate, oauth-gate…). #gate() { return document.querySelector('login-gate, [data-auth-gate]') } diff --git a/web/components/side-nav/side-nav.css b/web/components/side-nav/side-nav.css new file mode 100644 index 0000000..6e371c1 --- /dev/null +++ b/web/components/side-nav/side-nav.css @@ -0,0 +1,40 @@ +:host { + display: flex; + flex-direction: column; + width: 220px; + min-width: 220px; + background: #1e1e2e; + border-right: 1px solid #313244; + padding: 16px 12px; + gap: 4px; + height: 100%; + overflow: hidden; + box-sizing: border-box; +} + +.item { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 12px; + border-radius: 6px; + text-decoration: none; + color: #a6adc8; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + min-height: 44px; + transition: background 0.1s, color 0.1s; +} + +.item:hover { background: #313244; color: #cdd6f4; } +.item.active { background: #313244; color: #89b4fa; } +.item.active .icon { color: #89b4fa; } + +.icon { + display: flex; + align-items: center; + color: #6c7086; + flex-shrink: 0; +} + +.item:hover .icon { color: #cdd6f4; } diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index b96f236..8eb9e61 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -18,7 +18,8 @@ const ICONS = { vm: ``, } -const MQ = window.matchMedia('(max-width: 768px)') +const CSS = new URL('./side-nav.css', import.meta.url).href +const MQ = window.matchMedia('(max-width: 768px)') const NAV_W = 220 // px function resolveIcon(name) { @@ -67,44 +68,7 @@ class SideNav extends HTMLElement { }).join('') this.shadowRoot.innerHTML = ` - + ${links} ` diff --git a/web/components/side-panel/side-panel.css b/web/components/side-panel/side-panel.css new file mode 100644 index 0000000..017585c --- /dev/null +++ b/web/components/side-panel/side-panel.css @@ -0,0 +1,83 @@ +:host { + --width: 480px; /* overridden via this.style.setProperty in JS */ + --offset: 0px; + --dur: 220ms; + --actual-width: min(var(--width), 100vw); + + position: fixed; + top: 0; + right: calc(-1 * var(--actual-width)); + width: var(--actual-width); + height: 100vh; + background: #1e1e2e; + border-left: 1px solid #313244; + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + z-index: calc(1000 + var(--stack, 0)); + transition: right var(--dur) cubic-bezier(0.4, 0, 0.2, 1); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #cdd6f4; +} + +:host([data-open]) { + right: var(--offset); +} + +@media (max-width: 600px) { + :host { + --actual-width: 100vw; + } + :host([data-open]) { + right: 0; + } +} + +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid #313244; + flex-shrink: 0; +} + +.title { + flex: 1; + font-size: 15px; + font-weight: 600; + color: #cdd6f4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.close { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + padding: 5px; + color: #6c7086; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; + flex-shrink: 0; +} + +.close:hover { + background: #313244; + border-color: #45475a; + color: #f38ba8; +} + +.body { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.body::-webkit-scrollbar { width: 6px; } +.body::-webkit-scrollbar-track { background: transparent; } +.body::-webkit-scrollbar-thumb { background: #45475a; border-radius: 3px; } diff --git a/web/components/side-panel/side-panel.js b/web/components/side-panel/side-panel.js index 254160f..63e68c9 100644 --- a/web/components/side-panel/side-panel.js +++ b/web/components/side-panel/side-panel.js @@ -25,6 +25,8 @@ // Clicking the backdrop closes only the topmost panel. // ESC closes the topmost panel. +const CSS = new URL('./side-panel.css', import.meta.url).href + const CLOSE_ICON = ` @@ -103,95 +105,11 @@ export class SidePanel extends HTMLElement { const title = this.getAttribute('title') ?? '' const width = this.getAttribute('width') ?? '480px' + // Passe la largeur comme custom property — référencée dans le CSS via var(--width) + this.style.setProperty('--width', width) + this.shadowRoot.innerHTML = ` - - +
${title} From 2e06cd85bc892c6e0d08ce1de009933a0473c496 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 1 Jun 2026 21:40:26 +0200 Subject: [PATCH 13/21] web: start: multiple pages Signed-off-by: GnomeZworc --- web/build.sh | 97 ++++++++++++++++++++++++++++- web/components.json | 2 +- web/components.yml | 35 ++++++++++- web/components/side-nav/side-nav.js | 30 ++++++--- web/core/router.js | 54 ++++++++++++++++ web/index.html | 46 +++----------- web/navigation.json | 8 +-- web/pages.json | 34 ++++++++++ web/pages/dashboard/dashboard.css | 2 + web/pages/dashboard/dashboard.js | 8 +++ web/pages/dashboard/index.html | 33 ++++++++++ web/pages/dashboard/manifest.json | 6 ++ web/pages/subnets/index.html | 1 + web/pages/subnets/manifest.json | 1 + web/pages/vms/index.html | 1 + web/pages/vms/manifest.json | 1 + web/pages/vpcs/index.html | 1 + web/pages/vpcs/manifest.json | 1 + 18 files changed, 309 insertions(+), 52 deletions(-) create mode 100644 web/core/router.js create mode 100644 web/pages.json create mode 100644 web/pages/dashboard/dashboard.css create mode 100644 web/pages/dashboard/dashboard.js create mode 100644 web/pages/dashboard/index.html create mode 100644 web/pages/dashboard/manifest.json create mode 100644 web/pages/subnets/index.html create mode 100644 web/pages/subnets/manifest.json create mode 100644 web/pages/vms/index.html create mode 100644 web/pages/vms/manifest.json create mode 100644 web/pages/vpcs/index.html create mode 100644 web/pages/vpcs/manifest.json diff --git a/web/build.sh b/web/build.sh index 6a33d4f..e882219 100755 --- a/web/build.sh +++ b/web/build.sh @@ -109,8 +109,12 @@ download_path() { # ── Read manifest ──────────────────────────────────────────────────────────────── -mapfile -t LOCALS < <(yq '(.local // [])[]' "$MANIFEST") -mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") +PAGES_DIR="${WEB_DIR}/pages" + +mapfile -t LOCALS < <(yq '(.local_components // [])[]' "$MANIFEST") +mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") +mapfile -t LOCAL_PAGES < <(yq '(.local_pages // [])[]' "$MANIFEST") +mapfile -t REMOTE_PAGES < <(yq '(.remote_pages // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") # Ordered list of load paths for components.json declare -a LOAD_PATHS=() @@ -175,3 +179,92 @@ echo "$LOCK_ENTRIES" | jq '.' > "$LOCKFILE" log "wrote ${OUTPUT} (${#LOAD_PATHS[@]} components)" log "wrote ${LOCKFILE} ($(jq 'length' <<<"$LOCK_ENTRIES") remote pins)" + +# ── Process pages ──────────────────────────────────────────────────────────────── + +PAGE_ENTRIES="[]" + +# Collect page metadata from a directory into PAGE_ENTRIES. +# Detects css/js presence automatically — both are optional. +add_page_entry() { + local name="$1" dir="$2" + local manifest="${dir}/manifest.json" + local html="${dir}/index.html" + + [[ -f "$manifest" ]] || die "page '${name}' missing manifest.json in ${dir}" + [[ -f "$html" ]] || die "page '${name}' missing index.html in ${dir}" + + local route label icon css_arg js_arg + route=$(jq -r '.route' "$manifest") + label=$(jq -r '.label' "$manifest") + icon=$(jq -r '.icon' "$manifest") + css_arg="null"; js_arg="null" + [[ -f "${dir}/${name}.css" ]] && css_arg="\"pages/${name}/${name}.css\"" + [[ -f "${dir}/${name}.js" ]] && js_arg="\"pages/${name}/${name}.js\"" + + PAGE_ENTRIES="$(jq \ + --arg route "$route" --arg label "$label" --arg icon "$icon" \ + --arg html "pages/${name}/index.html" \ + --argjson css "$css_arg" --argjson js "$js_arg" \ + '. + [{route:$route, label:$label, icon:$icon, html:$html, css:$css, js:$js}]' \ + <<<"$PAGE_ENTRIES")" + info " page '${name}' → /#/${route}$([ "$css_arg" != "null" ] && echo " +css")$([ "$js_arg" != "null" ] && echo " +js")" +} + +# Local pages +for name in "${LOCAL_PAGES[@]}"; do + [[ -z "$name" ]] && continue + add_page_entry "$name" "${PAGES_DIR}/${name}" +done + +# Remote pages — download then register +for spec in "${REMOTE_PAGES[@]}"; do + [[ -z "$spec" ]] && continue + IFS='|' read -r name repo ref <<<"$spec" + [[ -z "$name" || -z "$repo" ]] && die "remote_pages entry missing name or repo" + + read -r server owner gitrepo <<<"$(parse_repo_url "$repo")" + dest="${PAGES_DIR}/${name}" + + if [[ "$CHECK_ONLY" == true ]]; then + info "would fetch page ${name} from ${repo}@${ref}" + continue + fi + + log "fetching page ${name} ← ${owner}/${gitrepo}@${ref}" + commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref")" + [[ -z "$commit" ]] && die "cannot resolve ref '${ref}' in ${owner}/${gitrepo}" + + rm -rf "$dest" + download_path "$server" "$owner" "$gitrepo" "$ref" "" "$dest" + add_page_entry "$name" "$dest" + info " pinned ${commit:0:12}" +done + +if [[ "$CHECK_ONLY" == false ]]; then + # Apply menu order if defined — reorder PAGE_ENTRIES and filter nav visibility + mapfile -t MENU_ORDER < <(yq '(.menu // [])[]' "$MANIFEST") + + if [[ ${#MENU_ORDER[@]} -gt 0 ]]; then + ORDERED="[]" + for route in "${MENU_ORDER[@]}"; do + [[ -z "$route" ]] && continue + entry="$(echo "$PAGE_ENTRIES" | jq --arg r "$route" '.[] | select(.route == $r)')" + [[ -z "$entry" ]] && warn "menu: route '${route}' not found in pages, skipping" + [[ -n "$entry" ]] && ORDERED="$(echo "$ORDERED" | jq --argjson e "$entry" '. + [$e]')" + done + NAV_ENTRIES="$ORDERED" + else + NAV_ENTRIES="$PAGE_ENTRIES" + fi + + # pages.json — full list (all pages, original discovery order) + echo "$PAGE_ENTRIES" | jq '.' > "${WEB_DIR}/pages.json" + + # navigation.json — ordered + filtered by menu: + echo "$NAV_ENTRIES" | \ + jq '[.[] | {label: .label, href: ("#/" + .route), icon: .icon}]' \ + > "${WEB_DIR}/navigation.json" + + log "wrote pages.json ($(echo "$PAGE_ENTRIES" | jq 'length') pages) + navigation.json ($(echo "$NAV_ENTRIES" | jq 'length') in menu)" +fi diff --git a/web/components.json b/web/components.json index 36d2ab3..c4e8217 100644 --- a/web/components.json +++ b/web/components.json @@ -2,7 +2,7 @@ "components/login-gate/login-gate.js", "components/api-client/api-client.js", "components/side-nav/side-nav.js", - "components/side-panel/side-panel.js", "components/logout-button/logout-button.js", + "components/side-panel/side-panel.js", "components/date-display/date-display.js" ] diff --git a/web/components.yml b/web/components.yml index f2878e3..dcdb15a 100644 --- a/web/components.yml +++ b/web/components.yml @@ -9,13 +9,13 @@ # Local components shipped inside this repo. Not downloaded — listed here only # so build.sh can place them in the generated load order. -local: +local_components: - login-gate - api-client - side-nav - logout-button - - date-display - side-panel + - date-display # Remote components fetched from git at build time. # Each is its own repo; its root must contain .js and manifest.json. @@ -24,3 +24,34 @@ local: # repo: https://git.g3e.fr/team-reseau/vpc-panel # ref: v1.2.0 # tag, branch or commit (default: main) components: [] + +# Pages — each page is a self-contained directory (JS + CSS + manifest.json). +# Same model as components: local pages ship in this repo, remote pages are +# downloaded from git at build time. build.sh reads each manifest.json to +# generate navigation.json and pages.json. +# +# Page manifest.json must contain: tag, route, label, icon. +# Page JS must: customElements.define('', ...) and use display:contents. + +local_pages: + - dashboard + - vpcs + - subnets + - vms + +# Remote pages fetched from git — same mechanism as components. +# +# - name: vpcs +# repo: https://git.g3e.fr/team-reseau/page-vpcs +# ref: v1.0.0 +remote_pages: [] + +# Menu — order and visibility in the side-nav. +# List routes in desired display order. +# A page not listed here is accessible via /#/route but hidden from the nav. +menu: + - dashboard + - vpcs + - subnets + - vms + diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index 8eb9e61..3c7f7db 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -27,11 +27,12 @@ function resolveIcon(name) { } class SideNav extends HTMLElement { - #items = [] - #hamburger = null - #backdrop = null - #mqHandler = null - #isMobile = false + #items = [] + #hamburger = null + #backdrop = null + #mqHandler = null + #hashHandler = null + #isMobile = false async connectedCallback() { this.attachShadow({ mode: 'open' }) @@ -43,21 +44,36 @@ class SideNav extends HTMLElement { this.#render() - this.#mqHandler = () => this.#applyMode() + this.#mqHandler = () => this.#applyMode() + this.#hashHandler = () => this.#updateActive() MQ.addEventListener('change', this.#mqHandler) + window.addEventListener('hashchange', this.#hashHandler) this.#applyMode() } disconnectedCallback() { MQ.removeEventListener('change', this.#mqHandler) + window.removeEventListener('hashchange', this.#hashHandler) this.#removeHamburger() this.#removeBackdrop() } // ── Rendu des items ───────────────────────────────────────────────────────── + #currentHref() { + return window.location.hash || `#/${this.#items[0]?.href?.replace(/^#\//, '') ?? 'dashboard'}` + } + + // Update active class without re-rendering the whole nav. + #updateActive() { + const current = this.#currentHref() + this.shadowRoot?.querySelectorAll('.item').forEach(a => + a.classList.toggle('active', a.getAttribute('href') === current) + ) + } + #render() { - const current = window.location.pathname.split('/').pop() || 'index.html' + const current = this.#currentHref() const links = this.#items.map(item => { const active = item.href === current diff --git a/web/core/router.js b/web/core/router.js new file mode 100644 index 0000000..06dbe9c --- /dev/null +++ b/web/core/router.js @@ -0,0 +1,54 @@ +// Hash-based SPA router. +// Reads pages.json (generated by build.sh from components.yml). +// Each page is an HTML fragment (index.html) + optional CSS + optional JS module. +// +// Load order enforced by index.html: +// await import('./core/registry.js') ← defines all components +// await import('./core/router.js') ← this file + +const response = await fetch('./pages.json') +if (!response.ok) throw new Error('router: failed to load pages.json — run build.sh') +const PAGES = await response.json() + +// Cache of imported JS modules — modules are only fetched once per session. +const MODULE_CACHE = new Map() + +// Currently active page CSS — removed on navigation. +let activeCSS = null + +function currentRoute() { + return window.location.hash.replace(/^#\//, '') || PAGES[0]?.route || '' +} + +async function render(routeName) { + const page = PAGES.find(p => p.route === routeName) ?? PAGES[0] + const main = document.querySelector('main') + if (!main || !page) return + + // ── HTML ──────────────────────────────────────────────────────────────────── + const htmlRes = await fetch(page.html) + if (!htmlRes.ok) throw new Error(`router: failed to load ${page.html}`) + main.innerHTML = await htmlRes.text() + + // ── CSS ───────────────────────────────────────────────────────────────────── + activeCSS?.remove() + activeCSS = null + if (page.css) { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = page.css + document.head.appendChild(link) + activeCSS = link + } + + // ── JS ────────────────────────────────────────────────────────────────────── + if (page.js) { + if (!MODULE_CACHE.has(page.js)) { + MODULE_CACHE.set(page.js, await import(`../${page.js}`)) + } + MODULE_CACHE.get(page.js)?.init?.(main) + } +} + +await render(currentRoute()) +window.addEventListener('hashchange', () => render(currentRoute())) diff --git a/web/index.html b/web/index.html index 0961b64..021fb07 100644 --- a/web/index.html +++ b/web/index.html @@ -92,48 +92,22 @@
-
-
- - - -
- - -
-
+
diff --git a/web/navigation.json b/web/navigation.json index 96a2b94..2845612 100644 --- a/web/navigation.json +++ b/web/navigation.json @@ -1,22 +1,22 @@ [ { "label": "Dashboard", - "href": "index.html", + "href": "#/dashboard", "icon": "dashboard" }, { "label": "VPCs", - "href": "vpc.html", + "href": "#/vpcs", "icon": "vpc" }, { "label": "Subnets", - "href": "subnet.html", + "href": "#/subnets", "icon": "subnet" }, { "label": "VMs", - "href": "vm.html", + "href": "#/vms", "icon": "vm" } ] diff --git a/web/pages.json b/web/pages.json new file mode 100644 index 0000000..e018e51 --- /dev/null +++ b/web/pages.json @@ -0,0 +1,34 @@ +[ + { + "route": "dashboard", + "label": "Dashboard", + "icon": "dashboard", + "html": "pages/dashboard/index.html", + "css": "pages/dashboard/dashboard.css", + "js": "pages/dashboard/dashboard.js" + }, + { + "route": "vpcs", + "label": "VPCs", + "icon": "vpc", + "html": "pages/vpcs/index.html", + "css": null, + "js": null + }, + { + "route": "subnets", + "label": "Subnets", + "icon": "subnet", + "html": "pages/subnets/index.html", + "css": null, + "js": null + }, + { + "route": "vms", + "label": "VMs", + "icon": "vm", + "html": "pages/vms/index.html", + "css": null, + "js": null + } +] diff --git a/web/pages/dashboard/dashboard.css b/web/pages/dashboard/dashboard.css new file mode 100644 index 0000000..204e2c0 --- /dev/null +++ b/web/pages/dashboard/dashboard.css @@ -0,0 +1,2 @@ +/* Styles scoped to the dashboard page. + Applied when the route is active, removed on navigation. */ diff --git a/web/pages/dashboard/dashboard.js b/web/pages/dashboard/dashboard.js new file mode 100644 index 0000000..43d7fea --- /dev/null +++ b/web/pages/dashboard/dashboard.js @@ -0,0 +1,8 @@ +// Dashboard page — called by the router each time this route is activated. +// Export init(main) to run logic after the HTML fragment is injected. +// +// `main` is the
DOM element containing the injected HTML. + +export function init(main) { + // Page is ready — add event listeners, fetch data, etc. +} diff --git a/web/pages/dashboard/index.html b/web/pages/dashboard/index.html new file mode 100644 index 0000000..15da108 --- /dev/null +++ b/web/pages/dashboard/index.html @@ -0,0 +1,33 @@ + + + +
+ + +
+ + + diff --git a/web/pages/dashboard/manifest.json b/web/pages/dashboard/manifest.json new file mode 100644 index 0000000..1a0f7ba --- /dev/null +++ b/web/pages/dashboard/manifest.json @@ -0,0 +1,6 @@ +{ + "route": "dashboard", + "label": "Dashboard", + "icon": "dashboard", + "version": "0.1.0" +} diff --git a/web/pages/subnets/index.html b/web/pages/subnets/index.html new file mode 100644 index 0000000..3ff00dd --- /dev/null +++ b/web/pages/subnets/index.html @@ -0,0 +1 @@ +

Subnets — à venir

diff --git a/web/pages/subnets/manifest.json b/web/pages/subnets/manifest.json new file mode 100644 index 0000000..189d0c2 --- /dev/null +++ b/web/pages/subnets/manifest.json @@ -0,0 +1 @@ +{ "route": "subnets", "label": "Subnets", "icon": "subnet", "version": "0.1.0" } diff --git a/web/pages/vms/index.html b/web/pages/vms/index.html new file mode 100644 index 0000000..ffe3861 --- /dev/null +++ b/web/pages/vms/index.html @@ -0,0 +1 @@ +

VMs — à venir

diff --git a/web/pages/vms/manifest.json b/web/pages/vms/manifest.json new file mode 100644 index 0000000..597d281 --- /dev/null +++ b/web/pages/vms/manifest.json @@ -0,0 +1 @@ +{ "route": "vms", "label": "VMs", "icon": "vm", "version": "0.1.0" } diff --git a/web/pages/vpcs/index.html b/web/pages/vpcs/index.html new file mode 100644 index 0000000..6de5126 --- /dev/null +++ b/web/pages/vpcs/index.html @@ -0,0 +1 @@ +

VPCs — à venir

diff --git a/web/pages/vpcs/manifest.json b/web/pages/vpcs/manifest.json new file mode 100644 index 0000000..7dde73b --- /dev/null +++ b/web/pages/vpcs/manifest.json @@ -0,0 +1 @@ +{ "route": "vpcs", "label": "VPCs", "icon": "vpc", "version": "0.1.0" } From bec5e65728b8dca9635c24bf4a079ab449f90946 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 1 Jun 2026 23:04:49 +0200 Subject: [PATCH 14/21] web: start: simple form component Signed-off-by: GnomeZworc --- web/components.json | 3 +- web/components.yml | 1 + web/components/form-vm/form-vm.css | 138 ++++++++++++ web/components/form-vm/form-vm.js | 311 +++++++++++++++++++++++++++ web/components/form-vm/manifest.json | 5 + web/pages/dashboard/dashboard.js | 48 ++++- web/pages/dashboard/index.html | 35 ++- 7 files changed, 513 insertions(+), 28 deletions(-) create mode 100644 web/components/form-vm/form-vm.css create mode 100644 web/components/form-vm/form-vm.js create mode 100644 web/components/form-vm/manifest.json diff --git a/web/components.json b/web/components.json index c4e8217..8377a10 100644 --- a/web/components.json +++ b/web/components.json @@ -4,5 +4,6 @@ "components/side-nav/side-nav.js", "components/logout-button/logout-button.js", "components/side-panel/side-panel.js", - "components/date-display/date-display.js" + "components/date-display/date-display.js", + "components/form-vm/form-vm.js" ] diff --git a/web/components.yml b/web/components.yml index dcdb15a..7b80ec3 100644 --- a/web/components.yml +++ b/web/components.yml @@ -16,6 +16,7 @@ local_components: - logout-button - side-panel - date-display + - form-vm # Remote components fetched from git at build time. # Each is its own repo; its root must contain .js and manifest.json. diff --git a/web/components/form-vm/form-vm.css b/web/components/form-vm/form-vm.css new file mode 100644 index 0000000..9fdd662 --- /dev/null +++ b/web/components/form-vm/form-vm.css @@ -0,0 +1,138 @@ +:host { + display: block; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #cdd6f4; +} + +form { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* ── Sections ─────────────────────────────────────────────────────────────── */ + +.section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6c7086; + padding-bottom: 8px; + border-bottom: 1px solid #313244; +} + +/* ── Fields ───────────────────────────────────────────────────────────────── */ + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +label { + font-size: 12px; + font-weight: 500; + color: #a6adc8; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +input, select { + background: #181825; + border: 1px solid #313244; + border-radius: 6px; + padding: 8px 12px; + color: #cdd6f4; + font-size: 13px; + font-family: monospace; + outline: none; + width: 100%; + box-sizing: border-box; + transition: border-color 0.15s; +} + +input:focus, select:focus { border-color: #89b4fa; } +input::placeholder { color: #45475a; } +input[readonly] { color: #6c7086; cursor: not-allowed; } + +/* ── Disk list ────────────────────────────────────────────────────────────── */ + +.disk-row { + display: grid; + grid-template-columns: 80px 1fr auto; + gap: 8px; + align-items: center; +} + +.disk-row input { min-width: 0; } + +/* ── Buttons ──────────────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + border-radius: 6px; + padding: 8px 14px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, opacity 0.12s; +} + +.btn-primary { background: #89b4fa; color: #1e1e2e; } +.btn-primary:hover { background: #b4d0fa; } +.btn-danger { background: transparent; border: 1px solid #f38ba8; color: #f38ba8; } +.btn-danger:hover { background: rgba(243,139,168,0.1); } +.btn-ghost { background: #313244; color: #a6adc8; padding: 6px 10px; font-size: 12px; } +.btn-ghost:hover { background: #45475a; } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.actions { + display: flex; + gap: 8px; + padding-top: 4px; +} + +/* ── Status ───────────────────────────────────────────────────────────────── */ + +.status { + font-size: 13px; + padding: 10px 14px; + border-radius: 6px; + display: none; +} +.status.error { background: #1e1e2e; border: 1px solid #f38ba8; color: #f38ba8; display: block; } +.status.success { background: #1e1e2e; border: 1px solid #a6e3a1; color: #a6e3a1; display: block; } +.status.loading { color: #6c7086; display: block; } + +/* ── Toggle ───────────────────────────────────────────────────────────────── */ + +.toggle-row { + display: flex; + align-items: center; + gap: 10px; +} + +input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #89b4fa; + cursor: pointer; +} + +.toggle-label { font-size: 13px; color: #a6adc8; text-transform: none; letter-spacing: 0; } diff --git a/web/components/form-vm/form-vm.js b/web/components/form-vm/form-vm.js new file mode 100644 index 0000000..8cb6b3c --- /dev/null +++ b/web/components/form-vm/form-vm.js @@ -0,0 +1,311 @@ +// VM create / edit form. +// +// Usage: +// → create mode +// → edit mode (loads VM from agent) +// +// Events dispatched on the element: +// vm-saved → { detail: { name, mode: 'create'|'edit' } } +// vm-error → { detail: { message } } +// +// Edit mode stops the VM then recreates it with the new parameters. +// The name field is read-only in edit mode. + +const CSS = new URL('./form-vm.css', import.meta.url).href + +const PLUS_ICON = `` +const TRASH_ICON = `` + +class FormVm extends HTMLElement { + #api = null + #mode = 'create' + #vmData = null // loaded in edit mode + + async connectedCallback() { + this.attachShadow({ mode: 'open' }) + this.shadowRoot.innerHTML = `
` + + this.#api = document.querySelector('api-client') + this.#mode = this.hasAttribute('vm-name') ? 'edit' : 'create' + + if (this.#mode === 'edit') { + this.#setStatus('loading', 'Chargement…') + try { + this.#vmData = await this.#api.agent.get(`/vms/${this.getAttribute('vm-name')}`) + } catch (e) { + this.#setStatus('error', `Impossible de charger la VM : ${e.message}`) + return + } + } + + this.#render() + } + + // ── Render ────────────────────────────────────────────────────────────────── + + #render() { + const d = this.#vmData + const edit = this.#mode === 'edit' + + this.#root().innerHTML = ` +
+ +
+
Général
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+
Authentification
+
+ + +
+
+ + +
+
+ +
+
Interface réseau
+
+
+ + +
+
+ + +
+
+
+ +
+
Stockage
+
+ ${this.#renderDisks(d?.storage ?? [{ dev: 'vda', path: '' }])} +
+ +
+ +
+ +
+ + ${edit ? `` : ''} +
+ +
+ ` + + this.#bindEvents() + } + + #renderDisks(disks) { + return disks.map((disk, i) => ` +
+ + + +
+ `).join('') + } + + // ── Events ────────────────────────────────────────────────────────────────── + + #bindEvents() { + const form = this.#root().querySelector('#vm-form') + + form.addEventListener('submit', e => { e.preventDefault(); this.#submit() }) + + this.#root().querySelector('#add-disk')?.addEventListener('click', () => { + this.#addDisk() + }) + + this.#root().querySelector('#delete-btn')?.addEventListener('click', () => { + this.#delete() + }) + + this.#root().addEventListener('click', e => { + const btn = e.target.closest('[data-remove-disk]') + if (btn) this.#removeDisk(Number(btn.dataset.removeDisk)) + }) + } + + // ── Disk list helpers ──────────────────────────────────────────────────────── + + #currentDisks() { + return [...this.#root().querySelectorAll('[data-disk]')].map(row => { + const i = row.dataset.disk + return { + dev: row.querySelector(`[name="dev_${i}"]`).value.trim(), + path: row.querySelector(`[name="path_${i}"]`).value.trim(), + } + }) + } + + #addDisk() { + const container = this.#root().querySelector('#disks') + const idx = container.querySelectorAll('[data-disk]').length + const row = document.createElement('div') + row.dataset.disk = idx + row.className = 'disk-row' + row.innerHTML = ` + + + + ` + container.appendChild(row) + } + + #removeDisk(idx) { + this.#root().querySelector(`[data-disk="${idx}"]`)?.remove() + this.#reindexDisks() + } + + #reindexDisks() { + this.#root().querySelectorAll('[data-disk]').forEach((row, i) => { + row.dataset.disk = i + row.querySelector('[name^="dev_"]').name = `dev_${i}` + row.querySelector('[name^="path_"]').name = `path_${i}` + const trash = row.querySelector('[data-remove-disk]') + if (trash) trash.dataset.removeDisk = i + }) + } + + // ── Build payload ──────────────────────────────────────────────────────────── + + #buildPayload() { + const f = this.#root().querySelector('#vm-form') + const data = new FormData(f) + const val = k => data.get(k)?.trim() ?? '' + + return { + name: val('name'), + memory: Number(val('memory')), + cpus: Number(val('cpus')), + uefi: f.querySelector('[name="uefi"]').checked, + sshkey: val('sshkey'), + password: val('password'), + interfaces: [{ subnet: val('subnet'), ip: val('ip'), primary: true }], + storage: this.#currentDisks(), + } + } + + // ── Submit ─────────────────────────────────────────────────────────────────── + + async #submit() { + const btn = this.#root().querySelector('#submit-btn') + btn.disabled = true + this.#clearStatus() + + try { + const payload = this.#buildPayload() + + if (this.#mode === 'edit') { + // Stop existing VM, then recreate with new params + this.#setStatus('loading', 'Arrêt de la VM…') + await this.#api.agent.delete(`/vms/${payload.name}`) + await this.#api.agent.waitFor(`/vms/${payload.name}`, 'stopped') + this.#setStatus('loading', 'Recréation…') + } else { + this.#setStatus('loading', 'Création…') + } + + await this.#api.agent.post('/vms', payload) + await this.#api.agent.waitFor(`/vms/${payload.name}`, 'started') + + this.#setStatus('success', this.#mode === 'edit' ? 'VM mise à jour.' : 'VM créée.') + this.dispatchEvent(new CustomEvent('vm-saved', { + bubbles: true, + detail: { name: payload.name, mode: this.#mode }, + })) + + // Auto-close parent side-panel after 1s + setTimeout(() => this.closest('side-panel')?.close(), 1000) + + } catch (e) { + this.#setStatus('error', e.message) + this.dispatchEvent(new CustomEvent('vm-error', { + bubbles: true, + detail: { message: e.message }, + })) + } finally { + btn.disabled = false + } + } + + // ── Delete ─────────────────────────────────────────────────────────────────── + + async #delete() { + if (!confirm(`Supprimer la VM ${this.getAttribute('vm-name')} ?`)) return + const btn = this.#root().querySelector('#delete-btn') + btn.disabled = true + this.#setStatus('loading', 'Suppression…') + + try { + const name = this.getAttribute('vm-name') + await this.#api.agent.delete(`/vms/${name}`) + await this.#api.agent.waitFor(`/vms/${name}`, 'stopped') + this.#setStatus('success', 'VM supprimée.') + this.dispatchEvent(new CustomEvent('vm-saved', { + bubbles: true, + detail: { name, mode: 'delete' }, + })) + setTimeout(() => this.closest('side-panel')?.close(), 1000) + } catch (e) { + this.#setStatus('error', e.message) + btn.disabled = false + } + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + #root() { return this.shadowRoot.getElementById('root') } + + #setStatus(type, msg) { + const el = this.#root().querySelector('#status') + if (!el) return + el.className = `status ${type}` + el.textContent = msg + } + + #clearStatus() { + const el = this.#root().querySelector('#status') + if (el) { el.className = 'status'; el.textContent = '' } + } +} + +customElements.define('form-vm', FormVm) diff --git a/web/components/form-vm/manifest.json b/web/components/form-vm/manifest.json new file mode 100644 index 0000000..06cedd0 --- /dev/null +++ b/web/components/form-vm/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "form-vm", + "version": "0.1.0", + "description": "Create or edit a VM. Auto-detects mode via vm-name attribute." +} diff --git a/web/pages/dashboard/dashboard.js b/web/pages/dashboard/dashboard.js index 43d7fea..773ec8f 100644 --- a/web/pages/dashboard/dashboard.js +++ b/web/pages/dashboard/dashboard.js @@ -1,8 +1,46 @@ -// Dashboard page — called by the router each time this route is activated. -// Export init(main) to run logic after the HTML fragment is injected. -// -// `main` is the
DOM element containing the injected HTML. +import { SidePanel } from '../../components/side-panel/side-panel.js' export function init(main) { - // Page is ready — add event listeners, fetch data, etc. + main.querySelectorAll('[data-panel-title]').forEach(btn => { + btn.addEventListener('click', () => { + SidePanel.open({ + title: btn.dataset.panelTitle, + width: btn.dataset.panelWidth ?? '480px', + content: `

+ Contenu du panneau ${btn.dataset.panelTitle}.
+ Largeur : ${btn.dataset.panelWidth}.

+ Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche. + Ferme avec ✕, Échap, ou en cliquant le fond. +

`, + }) + }) + }) + + main.querySelectorAll('[data-action="create-vm"]').forEach(btn => { + btn.addEventListener('click', () => { + SidePanel.open({ + title: 'Nouvelle VM', + width: '520px', + content: ``, + }) + // form-vm gère son propre submit et dispatche vm-saved — pas besoin de querySelector + }) + }) + + main.querySelectorAll('[data-action="edit-vm-i-test1"]').forEach(btn => { + btn.addEventListener('click', () => { + SidePanel.open({ + title: 'Modifier i-test1', + width: '520px', + content: `` + }) + // form-vm gère son propre submit et dispatche vm-saved — pas besoin de querySelector + }) + }) + + // vm-saved est attaché sur main (pas document) → nettoyé automatiquement + // quand le router remplace le contenu de
+ main.addEventListener('vm-saved', e => { + console.log('vm-saved', e.detail) + }) } diff --git a/web/pages/dashboard/index.html b/web/pages/dashboard/index.html index 15da108..ba114f1 100644 --- a/web/pages/dashboard/index.html +++ b/web/pages/dashboard/index.html @@ -1,33 +1,24 @@ - -
- - + +
- - - From 19c656e69f6af9de8b7274d56add28e402648994 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Tue, 2 Jun 2026 15:03:39 +0200 Subject: [PATCH 15/21] web: start: serparator for menu Signed-off-by: GnomeZworc --- web/build.sh | 38 ++++++++++++++++------------ web/components.yml | 3 +++ web/components/side-nav/side-nav.css | 15 +++++++++++ web/components/side-nav/side-nav.js | 8 ++++++ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/web/build.sh b/web/build.sh index e882219..0806f63 100755 --- a/web/build.sh +++ b/web/build.sh @@ -242,29 +242,35 @@ for spec in "${REMOTE_PAGES[@]}"; do done if [[ "$CHECK_ONLY" == false ]]; then - # Apply menu order if defined — reorder PAGE_ENTRIES and filter nav visibility - mapfile -t MENU_ORDER < <(yq '(.menu // [])[]' "$MANIFEST") + # Build navigation.json from menu: section. + # Convert full manifest to JSON once (single yq call) then jq handles all logic. + # Menu items: string → page lookup, object → pass-through (separator/section). + manifest_json=$(yq -o json '.' "$MANIFEST") + menu_json=$(echo "$manifest_json" | jq -c '.menu // []') + menu_len=$(echo "$menu_json" | jq 'length') - if [[ ${#MENU_ORDER[@]} -gt 0 ]]; then - ORDERED="[]" - for route in "${MENU_ORDER[@]}"; do - [[ -z "$route" ]] && continue - entry="$(echo "$PAGE_ENTRIES" | jq --arg r "$route" '.[] | select(.route == $r)')" - [[ -z "$entry" ]] && warn "menu: route '${route}' not found in pages, skipping" - [[ -n "$entry" ]] && ORDERED="$(echo "$ORDERED" | jq --argjson e "$entry" '. + [$e]')" - done - NAV_ENTRIES="$ORDERED" + if [[ "$menu_len" -gt 0 ]]; then + NAV_ENTRIES=$(jq -n \ + --argjson menu "$menu_json" \ + --argjson pages "$PAGE_ENTRIES" \ + '[ $menu[] | + if type == "string" + then (. as $r | $pages[] | select(.route == $r) | + { label, href: ("#/" + .route), icon }) + else . + end + ]') else - NAV_ENTRIES="$PAGE_ENTRIES" + # No menu defined — use all pages in discovery order + NAV_ENTRIES=$(echo "$PAGE_ENTRIES" | \ + jq '[.[] | { label, href: ("#/" + .route), icon }]') fi # pages.json — full list (all pages, original discovery order) echo "$PAGE_ENTRIES" | jq '.' > "${WEB_DIR}/pages.json" - # navigation.json — ordered + filtered by menu: - echo "$NAV_ENTRIES" | \ - jq '[.[] | {label: .label, href: ("#/" + .route), icon: .icon}]' \ - > "${WEB_DIR}/navigation.json" + # navigation.json — write NAV_ENTRIES as-is (already correctly shaped) + echo "$NAV_ENTRIES" | jq '.' > "${WEB_DIR}/navigation.json" log "wrote pages.json ($(echo "$PAGE_ENTRIES" | jq 'length') pages) + navigation.json ($(echo "$NAV_ENTRIES" | jq 'length') in menu)" fi diff --git a/web/components.yml b/web/components.yml index 7b80ec3..b39eb90 100644 --- a/web/components.yml +++ b/web/components.yml @@ -52,7 +52,10 @@ remote_pages: [] # A page not listed here is accessible via /#/route but hidden from the nav. menu: - dashboard + - type: section + label: Réseau - vpcs - subnets + - type: separator - vms diff --git a/web/components/side-nav/side-nav.css b/web/components/side-nav/side-nav.css index 6e371c1..cd1ae00 100644 --- a/web/components/side-nav/side-nav.css +++ b/web/components/side-nav/side-nav.css @@ -38,3 +38,18 @@ } .item:hover .icon { color: #cdd6f4; } + +.section-header { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #45475a; + padding: 14px 12px 4px; +} + +.separator { + height: 1px; + background: #313244; + margin: 6px 4px; +} diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index 3c7f7db..9e85870 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -76,6 +76,14 @@ class SideNav extends HTMLElement { const current = this.#currentHref() const links = this.#items.map(item => { + if (item.type === 'separator') { + return `
` + } + if (item.type === 'section') { + return `
${item.label ?? ''}
` + } + // Skip malformed entries (missing href or label) + if (!item.href || !item.label) return '' const active = item.href === current return ` ${resolveIcon(item.icon)} From afa189d438385e3ca0db72fe24f4eca45298b1f8 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Tue, 2 Jun 2026 15:04:48 +0200 Subject: [PATCH 16/21] web: start: menu css Signed-off-by: GnomeZworc --- web/components/side-nav/side-nav.css | 5 +++-- web/navigation.json | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/web/components/side-nav/side-nav.css b/web/components/side-nav/side-nav.css index cd1ae00..2cae64a 100644 --- a/web/components/side-nav/side-nav.css +++ b/web/components/side-nav/side-nav.css @@ -16,13 +16,14 @@ display: flex; align-items: center; gap: 10px; - padding: 11px 12px; + padding: 5px 12px; + margin: 2px; border-radius: 6px; text-decoration: none; color: #a6adc8; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - min-height: 44px; + min-height: 25px; transition: background 0.1s, color 0.1s; } diff --git a/web/navigation.json b/web/navigation.json index 2845612..773cf36 100644 --- a/web/navigation.json +++ b/web/navigation.json @@ -4,6 +4,10 @@ "href": "#/dashboard", "icon": "dashboard" }, + { + "type": "section", + "label": "Réseau" + }, { "label": "VPCs", "href": "#/vpcs", @@ -14,6 +18,9 @@ "href": "#/subnets", "icon": "subnet" }, + { + "type": "separator" + }, { "label": "VMs", "href": "#/vms", From 38939a0579ecab91a4cf8500e1168eb890c59004 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Tue, 2 Jun 2026 23:24:50 +0200 Subject: [PATCH 17/21] web: start: clean some code Signed-off-by: GnomeZworc --- .gitignore | 5 + web/components.json | 9 -- web/components/api-client/api-client.js | 1 - web/components/date-display/date-display.js | 3 - web/components/login-gate/login-gate.js | 1 - web/core/config.js | 2 - web/navigation.json | 29 ---- web/pages.json | 34 ----- web/vpc.html | 140 -------------------- 9 files changed, 5 insertions(+), 219 deletions(-) delete mode 100644 web/components.json delete mode 100644 web/navigation.json delete mode 100644 web/pages.json delete mode 100644 web/vpc.html diff --git a/.gitignore b/.gitignore index 68ae417..594473d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ go.work.sum # ignore local info data/ + + +web/components.json +web/navigation.json +web/pages.json diff --git a/web/components.json b/web/components.json deleted file mode 100644 index 8377a10..0000000 --- a/web/components.json +++ /dev/null @@ -1,9 +0,0 @@ -[ - "components/login-gate/login-gate.js", - "components/api-client/api-client.js", - "components/side-nav/side-nav.js", - "components/logout-button/logout-button.js", - "components/side-panel/side-panel.js", - "components/date-display/date-display.js", - "components/form-vm/form-vm.js" -] diff --git a/web/components/api-client/api-client.js b/web/components/api-client/api-client.js index 1d4c104..4973e1e 100644 --- a/web/components/api-client/api-client.js +++ b/web/components/api-client/api-client.js @@ -12,7 +12,6 @@ // await api.agent.delete('/vpcs/vp-admin') // await api.agent.waitFor('/vms/i-test1', 'started') // -// Credentials come from .ready — no direct config.json dependency. // Phase 2: swap login-gate for oidc-gate → this component unchanged. export class ApiError extends Error { diff --git a/web/components/date-display/date-display.js b/web/components/date-display/date-display.js index c008c93..38f5598 100644 --- a/web/components/date-display/date-display.js +++ b/web/components/date-display/date-display.js @@ -1,5 +1,3 @@ -import { config } from '../../core/config.js' - const CSS = new URL('./date-display.css', import.meta.url).href class DateDisplay extends HTMLElement { @@ -14,7 +12,6 @@ class DateDisplay extends HTMLElement {
agent →
` - this.shadowRoot.getElementById('agent-url').textContent = config.agent_url this.#tick() this.#interval = setInterval(() => this.#tick(), 1000) } diff --git a/web/components/login-gate/login-gate.js b/web/components/login-gate/login-gate.js index 1de65de..929539b 100644 --- a/web/components/login-gate/login-gate.js +++ b/web/components/login-gate/login-gate.js @@ -44,7 +44,6 @@ class LoginGate extends HTMLElement { return } - // Load config.json defaults to pre-fill the form let defaults = {} try { const r = await fetch('./config.json') diff --git a/web/core/config.js b/web/core/config.js index 21618e4..51f9b56 100644 --- a/web/core/config.js +++ b/web/core/config.js @@ -1,5 +1,3 @@ -// Single source of truth for configuration. -// Phase 2: replace the fetch with a call to the orchestrator. const response = await fetch('./config.json') if (!response.ok) throw new Error('Failed to load config.json') diff --git a/web/navigation.json b/web/navigation.json deleted file mode 100644 index 773cf36..0000000 --- a/web/navigation.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "label": "Dashboard", - "href": "#/dashboard", - "icon": "dashboard" - }, - { - "type": "section", - "label": "Réseau" - }, - { - "label": "VPCs", - "href": "#/vpcs", - "icon": "vpc" - }, - { - "label": "Subnets", - "href": "#/subnets", - "icon": "subnet" - }, - { - "type": "separator" - }, - { - "label": "VMs", - "href": "#/vms", - "icon": "vm" - } -] diff --git a/web/pages.json b/web/pages.json deleted file mode 100644 index e018e51..0000000 --- a/web/pages.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { - "route": "dashboard", - "label": "Dashboard", - "icon": "dashboard", - "html": "pages/dashboard/index.html", - "css": "pages/dashboard/dashboard.css", - "js": "pages/dashboard/dashboard.js" - }, - { - "route": "vpcs", - "label": "VPCs", - "icon": "vpc", - "html": "pages/vpcs/index.html", - "css": null, - "js": null - }, - { - "route": "subnets", - "label": "Subnets", - "icon": "subnet", - "html": "pages/subnets/index.html", - "css": null, - "js": null - }, - { - "route": "vms", - "label": "VMs", - "icon": "vm", - "html": "pages/vms/index.html", - "css": null, - "js": null - } -] diff --git a/web/vpc.html b/web/vpc.html deleted file mode 100644 index 0961b64..0000000 --- a/web/vpc.html +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - two — dashboard - - - - - - - - -
-

two

- network orchestrator -
- -
- -
- - -
-
- - - -
- - -
-
-
- - - - - From d4282353e72418cd70bc5a5476b5483d32fe8c12 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 3 Jun 2026 23:54:41 +0200 Subject: [PATCH 18/21] web: start: add sliding menu Signed-off-by: GnomeZworc --- web/build.sh | 11 +++- web/components.yml | 20 +++++- web/components/side-nav/side-nav.css | 51 ++++++++++++++ web/components/side-nav/side-nav.js | 99 +++++++++++++++++++++++----- 4 files changed, 159 insertions(+), 22 deletions(-) diff --git a/web/build.sh b/web/build.sh index 0806f63..78bc79f 100755 --- a/web/build.sh +++ b/web/build.sh @@ -253,10 +253,15 @@ if [[ "$CHECK_ONLY" == false ]]; then NAV_ENTRIES=$(jq -n \ --argjson menu "$menu_json" \ --argjson pages "$PAGE_ENTRIES" \ - '[ $menu[] | + 'def resolve($pages): + . as $r | $pages[] | select(.route == $r) | { label, href: ("#/" + .route), icon }; + [ $menu[] | if type == "string" - then (. as $r | $pages[] | select(.route == $r) | - { label, href: ("#/" + .route), icon }) + then resolve($pages) + elif .type == "group" + then { type: "group", label: .label, icon: (.icon // ""), + children: [ .children[]? | + if type == "string" then resolve($pages) else . end ] } else . end ]') diff --git a/web/components.yml b/web/components.yml index b39eb90..caa8912 100644 --- a/web/components.yml +++ b/web/components.yml @@ -51,11 +51,27 @@ remote_pages: [] # List routes in desired display order. # A page not listed here is accessible via /#/route but hidden from the nav. menu: + - dashboard + - type: separator + - type: group + label: Réseau + icon: vpc + children: + - vpcs + - subnets + - type: group + label: Empty + icon: vpc + children: [] + - type: group + label: Compute + icon: vm + children: + - vms + - type: separator - dashboard - type: section label: Réseau - vpcs - subnets - - type: separator - vms - diff --git a/web/components/side-nav/side-nav.css b/web/components/side-nav/side-nav.css index 2cae64a..5dd311f 100644 --- a/web/components/side-nav/side-nav.css +++ b/web/components/side-nav/side-nav.css @@ -54,3 +54,54 @@ background: #313244; margin: 6px 4px; } + +/* ── Accordion group ────────────────────────────────────────────────────── */ + +.group { display: flex; flex-direction: column; } + +.group-header { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 12px; + margin: 2px; + border-radius: 6px; + background: transparent; + border: none; + cursor: pointer; + color: #a6adc8; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + min-height: 25px; + transition: background 0.1s, color 0.1s; + width: 100%; + text-align: left; +} + +.group-header:hover { background: #313244; color: #cdd6f4; } +.group-header:hover .icon { color: #cdd6f4; } +.group-header.has-active { color: #89b4fa; } +.group-header.has-active .icon { color: #89b4fa; } + +.chevron { + margin-left: auto; + display: flex; + align-items: center; + color: #6c7086; + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.chevron.open { transform: rotate(90deg); } + +.group-children { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.2s ease; +} + +.group-children.open { grid-template-rows: 1fr; } + +.group-children > div { overflow: hidden; } + +.item.child { padding-left: 36px; } diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index 9e85870..dc105e7 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -11,6 +11,11 @@ const HAMBURGER_ICON = ` ` +const CHEVRON_ICON = ` + +` + const ICONS = { dashboard: ``, vpc: ``, @@ -28,6 +33,7 @@ function resolveIcon(name) { class SideNav extends HTMLElement { #items = [] + #expanded = new Set() // labels of open groups #hamburger = null #backdrop = null #mqHandler = null @@ -42,6 +48,14 @@ class SideNav extends HTMLElement { if (!response.ok) throw new Error(`side-nav: failed to load ${navPath}`) this.#items = await response.json() + // Auto-expand groups that contain the active route + const current = this.#currentHref() + for (const item of this.#items) { + if (item.type === 'group' && item.children?.some(c => c.href === current)) { + this.#expanded.add(item.label) + } + } + this.#render() this.#mqHandler = () => this.#applyMode() @@ -64,38 +78,89 @@ class SideNav extends HTMLElement { return window.location.hash || `#/${this.#items[0]?.href?.replace(/^#\//, '') ?? 'dashboard'}` } - // Update active class without re-rendering the whole nav. + // Update active state without re-rendering the whole nav. #updateActive() { const current = this.#currentHref() + this.shadowRoot?.querySelectorAll('.item').forEach(a => a.classList.toggle('active', a.getAttribute('href') === current) ) + + this.shadowRoot?.querySelectorAll('.group-header').forEach(btn => { + const item = this.#items.find(i => i.label === btn.dataset.group) + const hasActive = item?.children?.some(c => c.href === current) ?? false + btn.classList.toggle('has-active', hasActive) + // Auto-expand if a child becomes active + if (hasActive && !this.#expanded.has(item.label)) { + this.#expanded.add(item.label) + btn.querySelector('.chevron')?.classList.add('open') + btn.nextElementSibling?.classList.add('open') + } + }) + } + + #toggleGroup(label) { + const isOpen = this.#expanded.has(label) + isOpen ? this.#expanded.delete(label) : this.#expanded.add(label) + + const btn = this.shadowRoot.querySelector(`[data-group="${label}"]`) + const children = btn?.nextElementSibling + btn?.querySelector('.chevron')?.classList.toggle('open', !isOpen) + children?.classList.toggle('open', !isOpen) + } + + #renderItem(item, current) { + if (item.type === 'separator') { + return `
` + } + if (item.type === 'section') { + return `
${item.label ?? ''}
` + } + if (item.type === 'group') { + const isOpen = this.#expanded.has(item.label) + const hasActive = item.children?.some(c => c.href === current) ?? false + const children = (item.children ?? []).map(child => { + const active = child.href === current + return `
+ ${resolveIcon(child.icon)} + ${child.label} + ` + }).join('') + return ` +
+ +
+
${children}
+
+
` + } + // Default: leaf link + if (!item.href || !item.label) return '' + const active = item.href === current + return ` + ${resolveIcon(item.icon)} + ${item.label} + ` } #render() { const current = this.#currentHref() - - const links = this.#items.map(item => { - if (item.type === 'separator') { - return `
` - } - if (item.type === 'section') { - return `
${item.label ?? ''}
` - } - // Skip malformed entries (missing href or label) - if (!item.href || !item.label) return '' - const active = item.href === current - return ` - ${resolveIcon(item.icon)} - ${item.label} - ` - }).join('') + const links = this.#items.map(item => this.#renderItem(item, current)).join('') this.shadowRoot.innerHTML = ` ${links} ` + // Group toggle + this.shadowRoot.querySelectorAll('.group-header').forEach(btn => + btn.addEventListener('click', () => this.#toggleGroup(btn.dataset.group)) + ) + // Fermer l'overlay sur tap d'un lien (mobile) this.shadowRoot.querySelectorAll('.item').forEach(a => a.addEventListener('click', () => { if (this.#isMobile) this.#close() }) From c805169747eeec68cee9ba493fa86063d9b6b827 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Thu, 4 Jun 2026 00:06:51 +0200 Subject: [PATCH 19/21] web: start: add dynamique menu Signed-off-by: GnomeZworc --- web/components.yml | 5 ++ web/components/side-nav/side-nav.js | 31 +++++-- web/core/menu.js | 37 +++++++++ web/core/router.js | 3 +- web/index.html | 3 + web/pages/account/account.css | 121 ++++++++++++++++++++++++++++ web/pages/account/account.js | 117 +++++++++++++++++++++++++++ web/pages/account/index.html | 29 +++++++ web/pages/account/manifest.json | 5 ++ 9 files changed, 343 insertions(+), 8 deletions(-) create mode 100644 web/core/menu.js create mode 100644 web/pages/account/account.css create mode 100644 web/pages/account/account.js create mode 100644 web/pages/account/index.html create mode 100644 web/pages/account/manifest.json diff --git a/web/components.yml b/web/components.yml index caa8912..e387836 100644 --- a/web/components.yml +++ b/web/components.yml @@ -39,6 +39,7 @@ local_pages: - vpcs - subnets - vms + - account # Remote pages fetched from git — same mechanism as components. # @@ -68,6 +69,10 @@ menu: icon: vm children: - vms + - type: group + label: Comptes + icon: account + children: [] - type: separator - dashboard - type: section diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index dc105e7..ac49d79 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -32,13 +32,18 @@ function resolveIcon(name) { } class SideNav extends HTMLElement { - #items = [] - #expanded = new Set() // labels of open groups - #hamburger = null - #backdrop = null - #mqHandler = null - #hashHandler = null - #isMobile = false + #items = [] + #expanded = new Set() + #hamburger = null + #backdrop = null + #mqHandler = null + #hashHandler = null + #isMobile = false + #readyResolve = null + + // Resolves once navigation.json is loaded and the nav is rendered. + // Await this before calling setGroupChildren(). + ready = new Promise(r => { this.#readyResolve = r }) async connectedCallback() { this.attachShadow({ mode: 'open' }) @@ -57,6 +62,7 @@ class SideNav extends HTMLElement { } this.#render() + this.#readyResolve() this.#mqHandler = () => this.#applyMode() this.#hashHandler = () => this.#updateActive() @@ -72,6 +78,17 @@ class SideNav extends HTMLElement { this.#removeBackdrop() } + // ── Public API ────────────────────────────────────────────────────────────── + + // Inject dynamic children into an existing group (identified by label). + // Call after awaiting nav.ready. + setGroupChildren(label, children) { + const group = this.#items.find(i => i.type === 'group' && i.label === label) + if (!group) return + group.children = children + this.#render() + } + // ── Rendu des items ───────────────────────────────────────────────────────── #currentHref() { diff --git a/web/core/menu.js b/web/core/menu.js new file mode 100644 index 0000000..0a2adad --- /dev/null +++ b/web/core/menu.js @@ -0,0 +1,37 @@ +// Dynamic menu loader. +// Fetches data for groups declared with empty children in navigation.json +// and injects them into side-nav after it is ready. +// +// Imported non-blocking from index.html — does not delay routing. +// Replace MOCK_* with real api-client calls when the backend is ready. + +// ── Mock data ──────────────────────────────────────────────────────────────── + +const MOCK_ACCOUNTS = [ + { id: 'tresorerie', name: 'Trésorerie' }, + { id: 'charges', name: 'Charges' }, + { id: 'produits', name: 'Produits' }, + { id: 'fournisseurs', name: 'Fournisseurs' }, +] + +async function fetchAccounts() { + // TODO: replace with real call + // const api = document.querySelector('api-client') + // return await api.finance.list('/accounts') + await new Promise(r => setTimeout(r, 500)) // simulate network latency + return MOCK_ACCOUNTS +} + +// ── Injection ──────────────────────────────────────────────────────────────── + +const nav = document.querySelector('side-nav') +if (!nav) throw new Error('menu.js: side-nav not found in DOM') + +await nav.ready + +const accounts = await fetchAccounts() +nav.setGroupChildren('Comptes', accounts.map(a => ({ + label: a.name, + href: `#/account?id=${a.id}`, + icon: 'account', +}))) diff --git a/web/core/router.js b/web/core/router.js index 06dbe9c..d23777c 100644 --- a/web/core/router.js +++ b/web/core/router.js @@ -17,7 +17,8 @@ const MODULE_CACHE = new Map() let activeCSS = null function currentRoute() { - return window.location.hash.replace(/^#\//, '') || PAGES[0]?.route || '' + const hash = window.location.hash.replace(/^#\//, '') || PAGES[0]?.route || '' + return hash.split('?')[0] } async function render(routeName) { diff --git a/web/index.html b/web/index.html index 021fb07..1af8dea 100644 --- a/web/index.html +++ b/web/index.html @@ -108,6 +108,9 @@ // then router renders the current route. await import('./core/registry.js') await import('./core/router.js') + + // Non-blocking: populates dynamic menu groups after routing. + import('./core/menu.js') diff --git a/web/pages/account/account.css b/web/pages/account/account.css new file mode 100644 index 0000000..b8eb4e4 --- /dev/null +++ b/web/pages/account/account.css @@ -0,0 +1,121 @@ +#account-page { + width: 100%; + display: flex; + flex-direction: column; + gap: 24px; +} + +/* ── Header ────────────────────────────────────────────────────────────── */ + +#account-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + background: #1e1e2e; + border: 1px solid #313244; + border-radius: 10px; + padding: 20px 24px; +} + +#account-name { + font-size: 20px; + font-weight: 600; + color: #cdd6f4; + margin: 0 0 4px; +} + +#account-id { + font-size: 12px; + color: #6c7086; + font-family: monospace; +} + +#account-balance-block { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.balance-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6c7086; +} + +#account-balance { + font-size: 24px; + font-weight: 700; + color: #a6e3a1; + font-variant-numeric: tabular-nums; +} + +#account-balance.negative { color: #f38ba8; } + +/* ── Transactions ───────────────────────────────────────────────────────── */ + +#account-transactions { + background: #1e1e2e; + border: 1px solid #313244; + border-radius: 10px; + padding: 20px 24px; +} + +#account-transactions h3 { + font-size: 13px; + font-weight: 600; + color: #a6adc8; + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 16px; +} + +#tx-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +#tx-table th { + text-align: left; + color: #6c7086; + font-weight: 500; + font-size: 12px; + padding: 0 12px 10px; + border-bottom: 1px solid #313244; +} + +#tx-table td { + padding: 10px 12px; + color: #a6adc8; + border-bottom: 1px solid #1e1e2e; +} + +#tx-table tbody tr:last-child td { border-bottom: none; } +#tx-table tbody tr:hover td { background: #313244; } + +.amount-col { text-align: right; } + +td.amount { text-align: right; font-variant-numeric: tabular-nums; font-weight: 500; } +td.amount.positive { color: #a6e3a1; } +td.amount.negative { color: #f38ba8; } + +td.date { color: #6c7086; font-size: 13px; white-space: nowrap; } + +/* ── States ─────────────────────────────────────────────────────────────── */ + +#account-loading { + color: #6c7086; + font-size: 14px; +} + +#account-error { + background: #313244; + border: 1px solid #f38ba8; + border-radius: 8px; + padding: 12px 16px; + color: #f38ba8; + font-size: 13px; +} diff --git a/web/pages/account/account.js b/web/pages/account/account.js new file mode 100644 index 0000000..bed9af0 --- /dev/null +++ b/web/pages/account/account.js @@ -0,0 +1,117 @@ +// Account page. +// Reads ?id= from the URL hash and renders account details. +// Replace fetchAccount() with a real api-client call when backend is ready. + +// ── Mock data ──────────────────────────────────────────────────────────────── + +const MOCK_DB = { + tresorerie: { + name: 'Trésorerie', + balance: 48_230.50, + currency: 'EUR', + transactions: [ + { date: '2026-06-03', label: 'Virement client Martin SA', amount: +12_000.00 }, + { date: '2026-06-01', label: 'Virement client Dupont SARL', amount: +5_000.00 }, + { date: '2026-05-30', label: 'Loyer bureau juin', amount: -2_500.00 }, + { date: '2026-05-28', label: 'Facture EDF', amount: -340.20 }, + { date: '2026-05-25', label: 'Abonnement SaaS infra', amount: -189.00 }, + ], + }, + charges: { + name: 'Charges', + balance: 12_890.00, + currency: 'EUR', + transactions: [ + { date: '2026-06-01', label: 'Salaires mai', amount: -8_500.00 }, + { date: '2026-05-28', label: 'Assurance professionnelle', amount: -420.00 }, + { date: '2026-05-20', label: 'Formation équipe', amount: -1_200.00 }, + ], + }, + produits: { + name: 'Produits', + balance: 67_450.00, + currency: 'EUR', + transactions: [ + { date: '2026-06-02', label: 'Facture client #2024-089', amount: +18_000.00 }, + { date: '2026-05-29', label: 'Facture client #2024-088', amount: +9_500.00 }, + { date: '2026-05-22', label: 'Avoir client Dupont', amount: -500.00 }, + ], + }, + fournisseurs: { + name: 'Fournisseurs', + balance: -8_340.00, + currency: 'EUR', + transactions: [ + { date: '2026-06-01', label: 'Facture Tech Components SAS', amount: -3_200.00 }, + { date: '2026-05-27', label: 'Règlement fournisseur Leroy', amount: +2_000.00 }, + { date: '2026-05-20', label: 'Facture Bureau & Co', amount: -640.00 }, + ], + }, +} + +async function fetchAccount(id) { + // TODO: replace with real call + // const api = document.querySelector('api-client') + // return await api.finance.get(`/accounts/${id}`) + await new Promise(r => setTimeout(r, 300)) + const data = MOCK_DB[id] + if (!data) throw new Error(`Compte introuvable : ${id}`) + return data +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function formatAmount(amount, currency) { + return new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(amount) +} + +function formatDate(iso) { + return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }).format(new Date(iso)) +} + +// ── Init ───────────────────────────────────────────────────────────────────── + +export function init(main) { + const params = new URLSearchParams(window.location.hash.split('?')[1]) + const id = params.get('id') + + const loading = main.querySelector('#account-loading') + const error = main.querySelector('#account-error') + + if (!id) { + loading.style.display = 'none' + error.style.display = 'block' + error.textContent = 'Aucun compte sélectionné.' + return + } + + fetchAccount(id) + .then(data => render(main, id, data)) + .catch(err => { + loading.style.display = 'none' + error.style.display = 'block' + error.textContent = err.message + }) +} + +function render(main, id, data) { + main.querySelector('#account-loading').style.display = 'none' + + main.querySelector('#account-name').textContent = data.name + main.querySelector('#account-id').textContent = id + + const balanceEl = main.querySelector('#account-balance') + balanceEl.textContent = formatAmount(data.balance, data.currency) + balanceEl.classList.toggle('negative', data.balance < 0) + + const tbody = main.querySelector('#tx-body') + tbody.innerHTML = data.transactions.map(tx => ` + + ${formatDate(tx.date)} + ${tx.label} + + ${formatAmount(tx.amount, data.currency)} + + + `).join('') +} diff --git a/web/pages/account/index.html b/web/pages/account/index.html new file mode 100644 index 0000000..9d40142 --- /dev/null +++ b/web/pages/account/index.html @@ -0,0 +1,29 @@ +
+
+
+

+ +
+
+ Solde + +
+
+ +
+

Transactions récentes

+ + + + + + + + + +
DateLibelléMontant
+
+ + +
Chargement…
+
diff --git a/web/pages/account/manifest.json b/web/pages/account/manifest.json new file mode 100644 index 0000000..9226d9d --- /dev/null +++ b/web/pages/account/manifest.json @@ -0,0 +1,5 @@ +{ + "route": "account", + "label": "Compte", + "icon": "account" +} From 1d0e046ff48110bf39a17b77a3464eae8ff27328 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Fri, 5 Jun 2026 16:35:50 +0200 Subject: [PATCH 20/21] web: start: add vnc-viewer feature Signed-off-by: GnomeZworc --- .gitignore | 1 + web/build.sh | 67 ++++++++-- web/components.yml | 17 +++ web/components/vnc-viewer/manifest.json | 4 + web/components/vnc-viewer/vnc-viewer.js | 164 ++++++++++++++++++++++++ web/pages/dashboard/dashboard.js | 11 ++ web/pages/dashboard/index.html | 5 + 7 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 web/components/vnc-viewer/manifest.json create mode 100644 web/components/vnc-viewer/vnc-viewer.js diff --git a/.gitignore b/.gitignore index 594473d..aaee194 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ data/ web/components.json web/navigation.json web/pages.json +web/vendor/ diff --git a/web/build.sh b/web/build.sh index 78bc79f..b75143c 100755 --- a/web/build.sh +++ b/web/build.sh @@ -68,9 +68,37 @@ parse_repo_url() { echo "${proto}://${host}" "$owner" "$repo" } +# Build the commits API URL — handles GitHub (api.github.com) vs Gitea (/api/v1). +forge_commits_url() { + local server="$1" owner="$2" repo="$3" ref="$4" + if [[ "$server" == "https://github.com" ]]; then + echo "https://api.github.com/repos/${owner}/${repo}/commits?sha=${ref}&per_page=1" + else + echo "${server}/api/v1/repos/${owner}/${repo}/commits?sha=${ref}&limit=1" + fi +} + +# Build the contents API URL for a given path (empty = repo root). +forge_contents_url() { + local server="$1" owner="$2" repo="$3" ref="$4" path="$5" + if [[ "$server" == "https://github.com" ]]; then + if [[ -z "$path" ]]; then + echo "https://api.github.com/repos/${owner}/${repo}/contents?ref=${ref}" + else + echo "https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}" + fi + else + if [[ -z "$path" ]]; then + echo "${server}/api/v1/repos/${owner}/${repo}/contents?ref=${ref}" + else + echo "${server}/api/v1/repos/${owner}/${repo}/contents/${path}?ref=${ref}" + fi + fi +} + resolve_commit() { local server="$1" owner="$2" repo="$3" ref="$4" - api_get "${server}/api/v1/repos/${owner}/${repo}/commits?sha=${ref}&limit=1" \ + api_get "$(forge_commits_url "$server" "$owner" "$repo" "$ref")" \ | jq -r '.[0].sha // empty' } @@ -79,11 +107,7 @@ download_path() { local server="$1" owner="$2" repo="$3" ref="$4" rpath="$5" dest="$6" local api - if [[ -z "$rpath" ]]; then - api="${server}/api/v1/repos/${owner}/${repo}/contents?ref=${ref}" - else - api="${server}/api/v1/repos/${owner}/${repo}/contents/${rpath}?ref=${ref}" - fi + api="$(forge_contents_url "$server" "$owner" "$repo" "$ref" "$rpath")" local listing listing="$(api_get "$api")" @@ -105,8 +129,6 @@ download_path() { done < <(echo "$listing" | jq -c 'if type=="array" then .[] else . end') } -# ── Download WASM libs ─────────────────────────────────────────────────────────── - # ── Read manifest ──────────────────────────────────────────────────────────────── PAGES_DIR="${WEB_DIR}/pages" @@ -115,6 +137,35 @@ mapfile -t LOCALS < <(yq '(.local_components // [])[]' "$MANIFEST") mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") mapfile -t LOCAL_PAGES < <(yq '(.local_pages // [])[]' "$MANIFEST") mapfile -t REMOTE_PAGES < <(yq '(.remote_pages // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST") +mapfile -t VENDORS < <(yq '(.vendors // [])[] | [.name, .repo, (.ref // "main"), (.path // ""), (.dest // "")] | join("|")' "$MANIFEST") + +# ── Download vendors ──────────────────────────────────────────────────────────── +# Vendors are third-party libraries downloaded into web/vendor/ at build time. +# They are gitignored and never loaded by components.json — components import +# them directly via relative paths (e.g. ../../vendor/novnc/core/rfb.js). + +for spec in "${VENDORS[@]}"; do + [[ -z "$spec" ]] && continue + IFS='|' read -r name repo ref vpath dest <<<"$spec" + [[ -z "$name" || -z "$repo" ]] && die "vendor entry missing name or repo: '$spec'" + + read -r server owner gitrepo <<<"$(parse_repo_url "$repo")" + [[ -z "$dest" ]] && dest="vendor/${name}" + local_dest="${WEB_DIR}/${dest}" + + if [[ "$CHECK_ONLY" == true ]]; then + info "would fetch vendor ${name} from ${repo}@${ref} path=${vpath:-/}" + continue + fi + + log "fetching vendor ${name} ← ${owner}/${gitrepo}@${ref}${vpath:+ path=}${vpath}" + commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref")" + [[ -z "$commit" ]] && die "cannot resolve ref '${ref}' in ${owner}/${gitrepo}" + + rm -rf "$local_dest" + download_path "$server" "$owner" "$gitrepo" "$ref" "$vpath" "$local_dest" + info " vendor '${name}' → ${dest} (${commit:0:12})" +done # Ordered list of load paths for components.json declare -a LOAD_PATHS=() diff --git a/web/components.yml b/web/components.yml index e387836..8a885fb 100644 --- a/web/components.yml +++ b/web/components.yml @@ -17,6 +17,7 @@ local_components: - side-panel - date-display - form-vm + - vnc-viewer # Remote components fetched from git at build time. # Each is its own repo; its root must contain .js and manifest.json. @@ -26,6 +27,22 @@ local_components: # ref: v1.2.0 # tag, branch or commit (default: main) components: [] +# Vendor libraries — downloaded into web/vendor/ at build time (gitignored). +# Components import them via relative paths, never via components.json. +# path: restrict download to a subdirectory of the repo (optional). +# dest: destination under web/ (default: vendor/). +vendors: + - name: novnc-core + repo: https://github.com/novnc/noVNC + ref: v1.5.0 + path: core + dest: vendor/novnc/core + - name: novnc-vendor + repo: https://github.com/novnc/noVNC + ref: v1.5.0 + path: vendor + dest: vendor/novnc/vendor + # Pages — each page is a self-contained directory (JS + CSS + manifest.json). # Same model as components: local pages ship in this repo, remote pages are # downloaded from git at build time. build.sh reads each manifest.json to diff --git a/web/components/vnc-viewer/manifest.json b/web/components/vnc-viewer/manifest.json new file mode 100644 index 0000000..7218099 --- /dev/null +++ b/web/components/vnc-viewer/manifest.json @@ -0,0 +1,4 @@ +{ + "tag": "vnc-viewer", + "version": "0.1.0" +} diff --git a/web/components/vnc-viewer/vnc-viewer.js b/web/components/vnc-viewer/vnc-viewer.js new file mode 100644 index 0000000..36c8644 --- /dev/null +++ b/web/components/vnc-viewer/vnc-viewer.js @@ -0,0 +1,164 @@ +// VNC viewer component — wraps noVNC RFB. +// +// Usage (in a side-panel or page): +// +// +// Connects to ws://:9000/vnc/ via the VNC gateway (cmd/vncd). +// Agent host is read from login-gate credentials; falls back to location.hostname. +// +// Attributes: +// vm — VM name (required) +// port — gateway port (default: 9000) + +import RFB from '../../vendor/novnc/core/rfb.js' + +const RECONNECT_DELAY = 3000 // ms between auto-reconnect attempts + +class VncViewer extends HTMLElement { + #rfb = null + #retryTimer = null + + connectedCallback() { + this.attachShadow({ mode: 'open' }) + this.#render() + this.#connect() + } + + disconnectedCallback() { + clearTimeout(this.#retryTimer) + this.#rfb?.disconnect() + this.#rfb = null + } + + // ── Render ────────────────────────────────────────────────────────────────── + + #render() { + this.shadowRoot.innerHTML = ` + + +
+
+ + Connexion… + + + +
+ ` + + this.shadowRoot.getElementById('fullscreen-btn') + .addEventListener('click', () => this.#toggleFullscreen()) + + this.shadowRoot.getElementById('reconnect-btn') + .addEventListener('click', () => { this.#rfb?.disconnect(); this.#connect() }) + } + + // ── Connection ─────────────────────────────────────────────────────────────── + + #wsUrl() { + const vm = this.getAttribute('vm') + const port = this.getAttribute('port') ?? '9000' + const creds = document.querySelector('login-gate')?.credentials + const host = creds?.agent_url + ? new URL(creds.agent_url).hostname + : location.hostname + return `ws://${host}:${port}/vnc/${vm}` + } + + #connect() { + const vm = this.getAttribute('vm') + if (!vm) { this.#setStatus('disconnected', 'Attribut vm manquant'); return } + + const url = this.#wsUrl() + this.#setStatus('connecting', `Connexion à ${url}…`) + + this.#rfb = new RFB(this.shadowRoot.getElementById('screen'), url) + this.#rfb.scaleViewport = true + this.#rfb.resizeSession = false + + this.#rfb.addEventListener('connect', () => { + this.#setStatus('connected', 'Connecté') + }) + + this.#rfb.addEventListener('disconnect', e => { + const clean = e.detail?.clean ?? false + if (clean) { + this.#setStatus('disconnected', 'Déconnecté') + } else { + this.#setStatus('disconnected', 'Déconnecté — reconnexion dans 3s…') + this.#retryTimer = setTimeout(() => this.#connect(), RECONNECT_DELAY) + } + }) + + this.#rfb.addEventListener('credentialsrequired', () => { + this.#rfb.sendCredentials({ password: '' }) + }) + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + #setStatus(state, msg) { + const dot = this.shadowRoot.getElementById('dot') + const text = this.shadowRoot.getElementById('status-text') + if (!dot || !text) return + dot.className = `dot ${state}` + text.textContent = msg + } + + #toggleFullscreen() { + const screen = this.shadowRoot.getElementById('screen') + if (!document.fullscreenElement) { + screen.requestFullscreen?.() + } else { + document.exitFullscreen?.() + } + } +} + +customElements.define('vnc-viewer', VncViewer) diff --git a/web/pages/dashboard/dashboard.js b/web/pages/dashboard/dashboard.js index 773ec8f..9df2f71 100644 --- a/web/pages/dashboard/dashboard.js +++ b/web/pages/dashboard/dashboard.js @@ -38,6 +38,17 @@ export function init(main) { }) }) + main.querySelectorAll('[data-action="console"]').forEach(btn => { + btn.addEventListener('click', () => { + SidePanel.open({ + title: 'afficher une consol', + width: '700px', + content: `` + }) + // form-vm gère son propre submit et dispatche vm-saved — pas besoin de querySelector + }) + }) + // vm-saved est attaché sur main (pas document) → nettoyé automatiquement // quand le router remplace le contenu de
main.addEventListener('vm-saved', e => { diff --git a/web/pages/dashboard/index.html b/web/pages/dashboard/index.html index ba114f1..9e8195c 100644 --- a/web/pages/dashboard/index.html +++ b/web/pages/dashboard/index.html @@ -21,4 +21,9 @@ padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;"> Edition +
From 6abf93005b7197fb085582ac991228b70abfdd33 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 13 Jun 2026 11:03:50 +0200 Subject: [PATCH 21/21] web: start: add icons gestion Signed-off-by: GnomeZworc --- web/components.lock.json | 3 ++- web/components.yml | 6 ++++-- web/components/side-nav/side-nav.js | 18 ++++++++---------- web/icons.json | 7 +++++++ 4 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 web/icons.json diff --git a/web/components.lock.json b/web/components.lock.json index fe51488..0d4f101 100644 --- a/web/components.lock.json +++ b/web/components.lock.json @@ -1 +1,2 @@ -[] +[ +] diff --git a/web/components.yml b/web/components.yml index 8a885fb..0b2e70b 100644 --- a/web/components.yml +++ b/web/components.yml @@ -12,7 +12,6 @@ local_components: - login-gate - api-client - - side-nav - logout-button - side-panel - date-display @@ -25,7 +24,10 @@ local_components: # - name: vpc-panel # → components/vpc-panel/ # repo: https://git.g3e.fr/team-reseau/vpc-panel # ref: v1.2.0 # tag, branch or commit (default: main) -components: [] +components: + - name: side-nav + repo: https://git.g3e.fr/syonad/syn-side-nav + ref: main # Vendor libraries — downloaded into web/vendor/ at build time (gitignored). # Components import them via relative paths, never via components.json. diff --git a/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index ac49d79..7ccfadf 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -16,19 +16,17 @@ const CHEVRON_ICON = ` ` -const ICONS = { - dashboard: ``, - vpc: ``, - subnet: ``, - vm: ``, -} +const CSS = new URL('./side-nav.css', import.meta.url).href +const MQ = window.matchMedia('(max-width: 768px)') +const NAV_W = 220 // px -const CSS = new URL('./side-nav.css', import.meta.url).href -const MQ = window.matchMedia('(max-width: 768px)') -const NAV_W = 220 // px +const ICON_FALLBACK = `` + +const iconsRes = await fetch('./icons.json') +const ICONS = iconsRes.ok ? await iconsRes.json() : {} function resolveIcon(name) { - return ICONS[name] ?? `` + return ICONS[name] ?? ICON_FALLBACK } class SideNav extends HTMLElement { diff --git a/web/icons.json b/web/icons.json new file mode 100644 index 0000000..a9777cc --- /dev/null +++ b/web/icons.json @@ -0,0 +1,7 @@ +{ + "dashboard": "", + "vpc": "", + "subnet": "", + "vm": "", + "account": "" +}