Compare commits
22 commits
14c5f41e2b
...
6abf93005b
| Author | SHA1 | Date | |
|---|---|---|---|
|
6abf93005b |
|||
|
1d0e046ff4 |
|||
|
c805169747 |
|||
|
d4282353e7 |
|||
|
38939a0579 |
|||
|
afa189d438 |
|||
|
19c656e69f |
|||
|
bec5e65728 |
|||
|
2e06cd85bc |
|||
|
6e976ac6e3 |
|||
|
158dd0451a |
|||
|
aa171000dd |
|||
|
1d79f06f60 |
|||
|
da740aa322 |
|||
|
14ef0ecc83 |
|||
|
a3b85f5926 |
|||
|
4ab880a32d |
|||
|
c3f26836cd |
|||
|
9111045417 |
|||
|
a0637d827a |
|||
|
89899005d9 |
|||
|
85c8c4e590 |
59 changed files with 3267 additions and 69 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -30,3 +30,9 @@ go.work.sum
|
||||||
|
|
||||||
# ignore local info
|
# ignore local info
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
|
||||||
|
web/components.json
|
||||||
|
web/navigation.json
|
||||||
|
web/pages.json
|
||||||
|
web/vendor/
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,11 @@ func vmFromDB(name string, entries map[string]string) (VM, error) {
|
||||||
vm.Interfaces = []VMInterface{{Subnet: subnet, IP: ip, Primary: true}}
|
vm.Interfaces = []VMInterface{{Subnet: subnet, IP: ip, Primary: true}}
|
||||||
}
|
}
|
||||||
|
|
||||||
if path := entries[prefix+"volume_path"]; path != "" {
|
diskPrefix := prefix + "disk/"
|
||||||
vm.Storage = []VMStorage{{Path: path}}
|
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
|
return vm, nil
|
||||||
|
|
|
||||||
158
internal/api/agent/vm_test.go
Normal file
158
internal/api/agent/vm_test.go
Normal file
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,11 +77,16 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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{
|
cmd := dispatcher.StartVMCommand{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Subnet: primary.Subnet,
|
Subnet: primary.Subnet,
|
||||||
IP: primary.IP,
|
IP: primary.IP,
|
||||||
VolumePath: req.Storage[0].Path,
|
Disks: disks,
|
||||||
Memory: req.Memory,
|
Memory: req.Memory,
|
||||||
CPUs: req.CPUs,
|
CPUs: req.CPUs,
|
||||||
UEFI: req.UEFI,
|
UEFI: req.UEFI,
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,16 @@ import (
|
||||||
"github.com/dgraph-io/badger/v4"
|
"github.com/dgraph-io/badger/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type VMDisk struct {
|
||||||
|
Path string
|
||||||
|
Dev string
|
||||||
|
}
|
||||||
|
|
||||||
type StartVMCommand struct {
|
type StartVMCommand struct {
|
||||||
Name string
|
Name string
|
||||||
Subnet string
|
Subnet string
|
||||||
IP string
|
IP string
|
||||||
VolumePath string
|
Disks []VMDisk
|
||||||
Memory int
|
Memory int
|
||||||
CPUs int
|
CPUs int
|
||||||
UEFI bool
|
UEFI bool
|
||||||
|
|
@ -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+"/subnet", c.Subnet)
|
||||||
kv.AddInDB(db, "vm/"+c.Name+"/ip", c.IP)
|
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+"/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+"/memory", strconv.Itoa(c.Memory))
|
||||||
kv.AddInDB(db, "vm/"+c.Name+"/cpus", strconv.Itoa(c.CPUs))
|
kv.AddInDB(db, "vm/"+c.Name+"/cpus", strconv.Itoa(c.CPUs))
|
||||||
if c.UEFI {
|
if c.UEFI {
|
||||||
|
|
|
||||||
130
internal/dispatcher/agent/vm_commands_test.go
Normal file
130
internal/dispatcher/agent/vm_commands_test.go
Normal file
|
|
@ -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à")
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/qemu/config.go
Normal file
20
internal/qemu/config.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -7,22 +7,10 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Name string
|
|
||||||
TapID int
|
|
||||||
Mac string
|
|
||||||
VolumePath string
|
|
||||||
Memory int
|
|
||||||
CPUs int
|
|
||||||
UEFICodePath string
|
|
||||||
UEFIVarsPath string
|
|
||||||
SerialDir string
|
|
||||||
MonitorDir string
|
|
||||||
QMPDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Start(cfg Config) error {
|
func Start(cfg Config) error {
|
||||||
memory := cfg.Memory
|
memory := cfg.Memory
|
||||||
if memory == 0 {
|
if memory == 0 {
|
||||||
|
|
@ -64,8 +52,47 @@ 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)
|
||||||
|
// 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 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,bootindex=%d", d.Dev, scsiID, bootindex),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
args = append(args,
|
||||||
|
"-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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
args = append(args,
|
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),
|
"-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),
|
"-device", fmt.Sprintf("virtio-net-pci,netdev=net0,mac=%s", cfg.Mac),
|
||||||
"-daemonize",
|
"-daemonize",
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,9 @@
|
||||||
|
|
||||||
package qemu
|
package qemu
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
type Config struct {
|
)
|
||||||
Name, Mac, VolumePath string
|
|
||||||
TapID, Memory, CPUs int
|
|
||||||
UEFICodePath string
|
|
||||||
UEFIVarsPath string
|
|
||||||
SerialDir string
|
|
||||||
MonitorDir string
|
|
||||||
QMPDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Start(_ Config) error {
|
func Start(_ Config) error {
|
||||||
return errors.New("vm: not supported on this platform")
|
return errors.New("vm: not supported on this platform")
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,16 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
|
||||||
return fmt.Errorf("start metadata: %w", err)
|
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{
|
qcfg := qemu.Config{
|
||||||
Name: name,
|
Name: name,
|
||||||
TapID: d.tapID,
|
TapID: d.tapID,
|
||||||
Mac: d.mac,
|
Mac: d.mac,
|
||||||
VolumePath: d.volumePath,
|
Disks: qDisks,
|
||||||
Memory: d.memory,
|
Memory: d.memory,
|
||||||
CPUs: d.cpus,
|
CPUs: d.cpus,
|
||||||
SerialDir: cfg.QEMU.SerialDir,
|
SerialDir: cfg.QEMU.SerialDir,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ import (
|
||||||
"github.com/dgraph-io/badger/v4"
|
"github.com/dgraph-io/badger/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type diskEntry struct {
|
||||||
|
path string
|
||||||
|
dev string
|
||||||
|
}
|
||||||
|
|
||||||
type vmData struct {
|
type vmData struct {
|
||||||
subnetName string
|
subnetName string
|
||||||
vpcName string
|
vpcName string
|
||||||
|
|
@ -20,7 +25,7 @@ type vmData struct {
|
||||||
ip string
|
ip string
|
||||||
metadataPort string
|
metadataPort string
|
||||||
mac string
|
mac string
|
||||||
volumePath string
|
disks []diskEntry
|
||||||
memory int
|
memory int
|
||||||
cpus int
|
cpus int
|
||||||
uefi bool
|
uefi bool
|
||||||
|
|
@ -82,11 +87,18 @@ func loadVM(db *badger.DB, name string) (vmData, error) {
|
||||||
}
|
}
|
||||||
d.mac = mac
|
d.mac = mac
|
||||||
|
|
||||||
volumePath, err := kv.GetFromDB(db, "vm/"+name+"/volume_path")
|
diskEntries, err := kv.ListByPrefix(db, "vm/"+name+"/disk/")
|
||||||
if err != nil {
|
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")
|
memoryStr, err := kv.GetFromDB(db, "vm/"+name+"/memory")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -33,25 +33,18 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error {
|
||||||
|
|
||||||
socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock")
|
socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock")
|
||||||
|
|
||||||
if _, err := qmp.Send(socketPath, []string{`{"execute":"system_powerdown"}`}); err != nil {
|
if _, err := os.Stat(socketPath); err == nil {
|
||||||
return fmt.Errorf("qmp system_powerdown: %w", err)
|
// 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
|
||||||
|
|
||||||
// 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 := netns.Call(d.vpcName, func() error {
|
if err := netns.Call(d.vpcName, func() error {
|
||||||
return iptables.DeleteMetadataRedirect(d.ip, d.interfaceIP, d.metadataPort)
|
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")
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
332
web/build.sh
Executable file
332
web/build.sh
Executable file
|
|
@ -0,0 +1,332 @@
|
||||||
|
#!/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/<name>/
|
||||||
|
# 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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 "$(forge_commits_url "$server" "$owner" "$repo" "$ref")" \
|
||||||
|
| 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
|
||||||
|
api="$(forge_contents_url "$server" "$owner" "$repo" "$ref" "$rpath")"
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Read 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")
|
||||||
|
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=()
|
||||||
|
|
||||||
|
# ── 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)"
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
# 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_len" -gt 0 ]]; then
|
||||||
|
NAV_ENTRIES=$(jq -n \
|
||||||
|
--argjson menu "$menu_json" \
|
||||||
|
--argjson pages "$PAGE_ENTRIES" \
|
||||||
|
'def resolve($pages):
|
||||||
|
. as $r | $pages[] | select(.route == $r) | { label, href: ("#/" + .route), icon };
|
||||||
|
[ $menu[] |
|
||||||
|
if type == "string"
|
||||||
|
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
|
||||||
|
]')
|
||||||
|
else
|
||||||
|
# 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 — 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
|
||||||
2
web/components.lock.json
Normal file
2
web/components.lock.json
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[
|
||||||
|
]
|
||||||
101
web/components.yml
Normal file
101
web/components.yml
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Component manifest — source of truth for the web UI.
|
||||||
|
#
|
||||||
|
# Edit this file, then run ./build.sh to:
|
||||||
|
# - download every remote component into components/<name>/
|
||||||
|
# - 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_components:
|
||||||
|
- login-gate
|
||||||
|
- api-client
|
||||||
|
- logout-button
|
||||||
|
- 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 <name>.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:
|
||||||
|
- 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.
|
||||||
|
# path: restrict download to a subdirectory of the repo (optional).
|
||||||
|
# dest: destination under web/ (default: vendor/<name>).
|
||||||
|
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
|
||||||
|
# generate navigation.json and pages.json.
|
||||||
|
#
|
||||||
|
# Page manifest.json must contain: tag, route, label, icon.
|
||||||
|
# Page JS must: customElements.define('<tag>', ...) and use display:contents.
|
||||||
|
|
||||||
|
local_pages:
|
||||||
|
- dashboard
|
||||||
|
- vpcs
|
||||||
|
- subnets
|
||||||
|
- vms
|
||||||
|
- account
|
||||||
|
|
||||||
|
# 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
|
||||||
|
- 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: group
|
||||||
|
label: Comptes
|
||||||
|
icon: account
|
||||||
|
children: []
|
||||||
|
- type: separator
|
||||||
|
- dashboard
|
||||||
|
- type: section
|
||||||
|
label: Réseau
|
||||||
|
- vpcs
|
||||||
|
- subnets
|
||||||
|
- vms
|
||||||
132
web/components/api-client/api-client.js
Normal file
132
web/components/api-client/api-client.js
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// API service component.
|
||||||
|
//
|
||||||
|
// Declare after login-gate in the shell:
|
||||||
|
// <login-gate></login-gate>
|
||||||
|
// <api-client></api-client>
|
||||||
|
//
|
||||||
|
// 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 login-gate for oidc-gate → this component unchanged.
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Resolves once login-gate is ready (or immediately if no gate in DOM).
|
||||||
|
#credentials = null
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
this.netbox = this.#buildNetbox()
|
||||||
|
this.agent = this.#buildAgent()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
get creds() {
|
||||||
|
return this.#credentials ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 ${this.creds.netbox_token}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = (path, params = {}) => {
|
||||||
|
const u = new URL(path, this.creds.netbox_url + '/api/')
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v != null) u.searchParams.set(k, v)
|
||||||
|
})
|
||||||
|
return u.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
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, this.creds.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),
|
||||||
|
|
||||||
|
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)
|
||||||
5
web/components/api-client/manifest.json
Normal file
5
web/components/api-client/manifest.json
Normal file
|
|
@ -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')."
|
||||||
|
}
|
||||||
38
web/components/date-display/date-display.css
Normal file
38
web/components/date-display/date-display.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
32
web/components/date-display/date-display.js
Normal file
32
web/components/date-display/date-display.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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 = `
|
||||||
|
<link rel="stylesheet" href="${CSS}">
|
||||||
|
<div class="label">Current time</div>
|
||||||
|
<div class="time" id="time"></div>
|
||||||
|
<div class="date" id="date"></div>
|
||||||
|
<div class="agent">agent → <span id="agent-url"></span></div>
|
||||||
|
`
|
||||||
|
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)
|
||||||
5
web/components/date-display/manifest.json
Normal file
5
web/components/date-display/manifest.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"tag": "date-display",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Displays current date and time, refreshed every second."
|
||||||
|
}
|
||||||
138
web/components/form-vm/form-vm.css
Normal file
138
web/components/form-vm/form-vm.css
Normal file
|
|
@ -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; }
|
||||||
311
web/components/form-vm/form-vm.js
Normal file
311
web/components/form-vm/form-vm.js
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
// VM create / edit form.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <form-vm></form-vm> → create mode
|
||||||
|
// <form-vm vm-name="i-test1"></form-vm> → 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 = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`
|
||||||
|
const TRASH_ICON = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>`
|
||||||
|
|
||||||
|
class FormVm extends HTMLElement {
|
||||||
|
#api = null
|
||||||
|
#mode = 'create'
|
||||||
|
#vmData = null // loaded in edit mode
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
this.attachShadow({ mode: 'open' })
|
||||||
|
this.shadowRoot.innerHTML = `<link rel="stylesheet" href="${CSS}"><div id="root"></div>`
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<form id="vm-form">
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Général</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Nom</label>
|
||||||
|
<input name="name" placeholder="i-mon-vm" value="${d?.name ?? ''}"
|
||||||
|
${edit ? 'readonly' : 'required'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Mémoire (MB)</label>
|
||||||
|
<input name="memory" type="number" min="128" step="128"
|
||||||
|
placeholder="1024" value="${d?.memory ?? ''}" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>vCPUs</label>
|
||||||
|
<input name="cpus" type="number" min="1" max="32"
|
||||||
|
placeholder="2" value="${d?.cpus ?? ''}" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field toggle-row">
|
||||||
|
<input name="uefi" type="checkbox" id="uefi-cb"
|
||||||
|
${d?.uefi ? 'checked' : ''} />
|
||||||
|
<label for="uefi-cb" class="toggle-label">UEFI (OVMF)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Authentification</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Clé SSH</label>
|
||||||
|
<input name="sshkey" placeholder="ssh-ed25519 AAAA…"
|
||||||
|
value="${d?.sshkey ?? ''}" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Mot de passe (optionnel)</label>
|
||||||
|
<input name="password" type="password" placeholder="laisser vide = désactivé" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Interface réseau</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Subnet</label>
|
||||||
|
<input name="subnet" placeholder="sn-000000"
|
||||||
|
value="${d?.interfaces?.[0]?.subnet ?? ''}" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>IP</label>
|
||||||
|
<input name="ip" placeholder="192.168.14.x"
|
||||||
|
value="${d?.interfaces?.[0]?.ip ?? ''}" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Stockage</div>
|
||||||
|
<div id="disks">
|
||||||
|
${this.#renderDisks(d?.storage ?? [{ dev: 'vda', path: '' }])}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost" id="add-disk">
|
||||||
|
${PLUS_ICON} Ajouter un disque
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status" id="status"></div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary" id="submit-btn">
|
||||||
|
${edit ? 'Mettre à jour' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
${edit ? `<button type="button" class="btn btn-danger" id="delete-btn">Supprimer</button>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
`
|
||||||
|
|
||||||
|
this.#bindEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderDisks(disks) {
|
||||||
|
return disks.map((disk, i) => `
|
||||||
|
<div class="disk-row" data-disk="${i}">
|
||||||
|
<input name="dev_${i}" placeholder="vda" value="${disk.dev ?? ''}" required />
|
||||||
|
<input name="path_${i}" placeholder="/vm/nom.qcow2" value="${disk.path ?? ''}" required />
|
||||||
|
<button type="button" class="btn btn-ghost" data-remove-disk="${i}">${TRASH_ICON}</button>
|
||||||
|
</div>
|
||||||
|
`).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 = `
|
||||||
|
<input name="dev_${idx}" placeholder="vda" required />
|
||||||
|
<input name="path_${idx}" placeholder="/vm/nom.qcow2" required />
|
||||||
|
<button type="button" class="btn btn-ghost" data-remove-disk="${idx}">${TRASH_ICON}</button>
|
||||||
|
`
|
||||||
|
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)
|
||||||
5
web/components/form-vm/manifest.json
Normal file
5
web/components/form-vm/manifest.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"tag": "form-vm",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Create or edit a VM. Auto-detects mode via vm-name attribute."
|
||||||
|
}
|
||||||
81
web/components/login-gate/login-gate.css
Normal file
81
web/components/login-gate/login-gate.css
Normal file
|
|
@ -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; }
|
||||||
111
web/components/login-gate/login-gate.js
Normal file
111
web/components/login-gate/login-gate.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// Auth service component.
|
||||||
|
//
|
||||||
|
// Declare before api-client in the shell:
|
||||||
|
// <login-gate></login-gate>
|
||||||
|
//
|
||||||
|
// Exposes:
|
||||||
|
// gate.ready → Promise<credentials> — 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 <oidc-gate> 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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<link rel="stylesheet" href="${CSS}">
|
||||||
|
<div class="overlay">
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<h1>two — connect</h1>
|
||||||
|
<p>Enter your Netbox and agent details to continue.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="fields" id="form" autocomplete="on">
|
||||||
|
<div class="field">
|
||||||
|
<label>Netbox URL</label>
|
||||||
|
<input id="netbox_url" type="url" placeholder="http://netbox.local"
|
||||||
|
value="${defaults.netbox_url ?? ''}" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Netbox Token</label>
|
||||||
|
<input id="netbox_token" type="password" placeholder="your-api-token"
|
||||||
|
value="${defaults.netbox_token ?? ''}" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Agent URL</label>
|
||||||
|
<input id="agent_url" type="url" placeholder="http://127.0.0.1:8080"
|
||||||
|
value="${defaults.agent_url ?? ''}" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Connect</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
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)
|
||||||
5
web/components/login-gate/manifest.json
Normal file
5
web/components/login-gate/manifest.json
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
30
web/components/logout-button/logout-button.css
Normal file
30
web/components/logout-button/logout-button.css
Normal file
|
|
@ -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; }
|
||||||
33
web/components/logout-button/logout-button.js
Normal file
33
web/components/logout-button/logout-button.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
const CSS = new URL('./logout-button.css', import.meta.url).href
|
||||||
|
|
||||||
|
const ICON = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`
|
||||||
|
|
||||||
|
class LogoutButton extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.attachShadow({ mode: 'open' })
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<link rel="stylesheet" href="${CSS}">
|
||||||
|
<button type="button" title="Log out">
|
||||||
|
<span class="icon">${ICON}</span>
|
||||||
|
<span class="label">Log out</span>
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
this.shadowRoot.querySelector('button')
|
||||||
|
.addEventListener('click', () => this.#logout())
|
||||||
|
}
|
||||||
|
|
||||||
|
#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)
|
||||||
5
web/components/logout-button/manifest.json
Normal file
5
web/components/logout-button/manifest.json
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
5
web/components/side-nav/manifest.json
Normal file
5
web/components/side-nav/manifest.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"tag": "side-nav",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Left sidebar navigation, driven by navigation.json."
|
||||||
|
}
|
||||||
107
web/components/side-nav/side-nav.css
Normal file
107
web/components/side-nav/side-nav.css
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
: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: 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: 25px;
|
||||||
|
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; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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; }
|
||||||
295
web/components/side-nav/side-nav.js
Normal file
295
web/components/side-nav/side-nav.js
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
// Side navigation component.
|
||||||
|
// Desktop : sidebar fixe à gauche dans le flux flex.
|
||||||
|
// Mobile (≤768px) : overlay depuis la gauche, déclenché par un bouton hamburger
|
||||||
|
// inséré dans <header>. Géométrie gérée en inline style pour
|
||||||
|
// éviter les conflits de spécificité avec le shadow DOM.
|
||||||
|
|
||||||
|
const HAMBURGER_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
const CHEVRON_ICON = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>`
|
||||||
|
|
||||||
|
const iconsRes = await fetch('./icons.json')
|
||||||
|
const ICONS = iconsRes.ok ? await iconsRes.json() : {}
|
||||||
|
|
||||||
|
function resolveIcon(name) {
|
||||||
|
return ICONS[name] ?? ICON_FALLBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
class SideNav extends HTMLElement {
|
||||||
|
#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' })
|
||||||
|
|
||||||
|
const navPath = this.dataset.src ?? './navigation.json'
|
||||||
|
const response = await fetch(navPath)
|
||||||
|
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.#readyResolve()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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() {
|
||||||
|
return window.location.hash || `#/${this.#items[0]?.href?.replace(/^#\//, '') ?? 'dashboard'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 `<div class="separator"></div>`
|
||||||
|
}
|
||||||
|
if (item.type === 'section') {
|
||||||
|
return `<div class="section-header">${item.label ?? ''}</div>`
|
||||||
|
}
|
||||||
|
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 `<a href="${child.href}" class="item child ${active ? 'active' : ''}">
|
||||||
|
<span class="icon">${resolveIcon(child.icon)}</span>
|
||||||
|
<span class="label">${child.label}</span>
|
||||||
|
</a>`
|
||||||
|
}).join('')
|
||||||
|
return `
|
||||||
|
<div class="group">
|
||||||
|
<button class="group-header ${hasActive ? 'has-active' : ''}" data-group="${item.label}">
|
||||||
|
<span class="icon">${resolveIcon(item.icon)}</span>
|
||||||
|
<span class="label">${item.label}</span>
|
||||||
|
<span class="chevron ${isOpen ? 'open' : ''}">${CHEVRON_ICON}</span>
|
||||||
|
</button>
|
||||||
|
<div class="group-children ${isOpen ? 'open' : ''}">
|
||||||
|
<div>${children}</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
// Default: leaf link
|
||||||
|
if (!item.href || !item.label) return ''
|
||||||
|
const active = item.href === current
|
||||||
|
return `<a href="${item.href}" class="item ${active ? 'active' : ''}">
|
||||||
|
<span class="icon">${resolveIcon(item.icon)}</span>
|
||||||
|
<span class="label">${item.label}</span>
|
||||||
|
</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const current = this.#currentHref()
|
||||||
|
const links = this.#items.map(item => this.#renderItem(item, current)).join('')
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<link rel="stylesheet" href="${CSS}">
|
||||||
|
${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() })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basculement desktop / mobile ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#applyMode() {
|
||||||
|
if (MQ.matches) this.#toMobile()
|
||||||
|
else this.#toDesktop()
|
||||||
|
}
|
||||||
|
|
||||||
|
#toMobile() {
|
||||||
|
this.#isMobile = true
|
||||||
|
|
||||||
|
// inline style = spécificité maximale, pas de conflit avec le shadow DOM
|
||||||
|
Object.assign(this.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0',
|
||||||
|
left: '0',
|
||||||
|
width: `${NAV_W}px`,
|
||||||
|
minWidth: `${NAV_W}px`,
|
||||||
|
height: '100vh',
|
||||||
|
zIndex: '500',
|
||||||
|
transform: 'translateX(-100%)',
|
||||||
|
transition: 'transform 0.25s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '4px 0 24px rgba(0,0,0,0.45)',
|
||||||
|
})
|
||||||
|
|
||||||
|
this.#addHamburger()
|
||||||
|
}
|
||||||
|
|
||||||
|
#toDesktop() {
|
||||||
|
this.#isMobile = false
|
||||||
|
// Réinitialise tous les styles inline → shadow DOM reprend le contrôle
|
||||||
|
this.style.cssText = ''
|
||||||
|
this.#removeHamburger()
|
||||||
|
this.#removeBackdrop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hamburger ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#addHamburger() {
|
||||||
|
if (this.#hamburger) return
|
||||||
|
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.innerHTML = HAMBURGER_ICON
|
||||||
|
btn.setAttribute('aria-label', 'Menu')
|
||||||
|
btn.setAttribute('data-sidenav-toggle', '')
|
||||||
|
Object.assign(btn.style, {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #313244',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '7px',
|
||||||
|
color: '#a6adc8',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '36px',
|
||||||
|
minHeight: '36px',
|
||||||
|
flexShrink: '0',
|
||||||
|
})
|
||||||
|
btn.addEventListener('click', () => this.#toggle())
|
||||||
|
this.#hamburger = btn
|
||||||
|
|
||||||
|
// Insère après le h1 dans le header, ou en premier si pas de h1
|
||||||
|
const header = document.querySelector('header')
|
||||||
|
if (!header) { document.body.prepend(btn); return }
|
||||||
|
const h1 = header.querySelector('h1')
|
||||||
|
h1 ? h1.after(btn) : header.prepend(btn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#removeHamburger() {
|
||||||
|
this.#hamburger?.remove()
|
||||||
|
this.#hamburger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open / Close (mobile) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#toggle() { this.#isMobile && (this.style.transform === 'translateX(0px)'
|
||||||
|
? this.#close() : this.#open()) }
|
||||||
|
|
||||||
|
#open() {
|
||||||
|
this.style.transform = 'translateX(0)'
|
||||||
|
|
||||||
|
const bd = document.createElement('div')
|
||||||
|
Object.assign(bd.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: '0',
|
||||||
|
background: 'rgba(0,0,0,0)',
|
||||||
|
zIndex: '499',
|
||||||
|
transition: 'background 0.25s',
|
||||||
|
})
|
||||||
|
bd.addEventListener('click', () => this.#close())
|
||||||
|
document.body.appendChild(bd)
|
||||||
|
this.#backdrop = bd
|
||||||
|
requestAnimationFrame(() => { bd.style.background = 'rgba(0,0,0,0.45)' })
|
||||||
|
}
|
||||||
|
|
||||||
|
#close() {
|
||||||
|
this.style.transform = 'translateX(-100%)'
|
||||||
|
this.#removeBackdrop()
|
||||||
|
}
|
||||||
|
|
||||||
|
#removeBackdrop() {
|
||||||
|
if (!this.#backdrop) return
|
||||||
|
const bd = this.#backdrop
|
||||||
|
this.#backdrop = null
|
||||||
|
bd.style.background = 'rgba(0,0,0,0)'
|
||||||
|
bd.addEventListener('transitionend', () => bd.remove(), { once: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('side-nav', SideNav)
|
||||||
5
web/components/side-panel/manifest.json
Normal file
5
web/components/side-panel/manifest.json
Normal file
|
|
@ -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()."
|
||||||
|
}
|
||||||
83
web/components/side-panel/side-panel.css
Normal file
83
web/components/side-panel/side-panel.css
Normal file
|
|
@ -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; }
|
||||||
165
web/components/side-panel/side-panel.js
Normal file
165
web/components/side-panel/side-panel.js
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// Configurable right-side drawer panel.
|
||||||
|
//
|
||||||
|
// ── Declarative ──────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// <side-panel title="VPC details" width="520px">
|
||||||
|
// <vpc-detail vpc="vp-admin"></vpc-detail>
|
||||||
|
// </side-panel>
|
||||||
|
// 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: '<vpc-detail vpc="vp-admin"></vpc-detail>',
|
||||||
|
// })
|
||||||
|
// 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 CSS = new URL('./side-panel.css', import.meta.url).href
|
||||||
|
|
||||||
|
const CLOSE_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>`
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
// Passe la largeur comme custom property — référencée dans le CSS via var(--width)
|
||||||
|
this.style.setProperty('--width', width)
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<link rel="stylesheet" href="${CSS}">
|
||||||
|
<div class="header">
|
||||||
|
<span class="title">${title}</span>
|
||||||
|
<button class="close" aria-label="Close">${CLOSE_ICON}</button>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
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)
|
||||||
4
web/components/vnc-viewer/manifest.json
Normal file
4
web/components/vnc-viewer/manifest.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"tag": "vnc-viewer",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
164
web/components/vnc-viewer/vnc-viewer.js
Normal file
164
web/components/vnc-viewer/vnc-viewer.js
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// VNC viewer component — wraps noVNC RFB.
|
||||||
|
//
|
||||||
|
// Usage (in a side-panel or page):
|
||||||
|
// <vnc-viewer vm="i-test1"></vnc-viewer>
|
||||||
|
//
|
||||||
|
// Connects to ws://<agent-host>:9000/vnc/<vm-name> 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 = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#screen { flex: 1; overflow: hidden; }
|
||||||
|
#statusbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-top: 1px solid #313244;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6c7086;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot.connected { background: #a6e3a1; }
|
||||||
|
.dot.disconnected { background: #f38ba8; }
|
||||||
|
.dot.connecting { background: #f9e2af; }
|
||||||
|
#status-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #45475a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: #a6adc8;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #313244; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="screen"></div>
|
||||||
|
<div id="statusbar">
|
||||||
|
<span class="dot connecting" id="dot"></span>
|
||||||
|
<span id="status-text">Connexion…</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="fullscreen-btn" title="Plein écran">⛶</button>
|
||||||
|
<button id="reconnect-btn" title="Reconnecter">↺</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
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)
|
||||||
5
web/config.json
Normal file
5
web/config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"netbox_url": "http://netbox.local",
|
||||||
|
"netbox_token": "your-token-here",
|
||||||
|
"agent_url": "http://127.0.0.1:8080"
|
||||||
|
}
|
||||||
4
web/core/config.js
Normal file
4
web/core/config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
const response = await fetch('./config.json')
|
||||||
|
if (!response.ok) throw new Error('Failed to load config.json')
|
||||||
|
|
||||||
|
export const config = await response.json()
|
||||||
37
web/core/menu.js
Normal file
37
web/core/menu.js
Normal file
|
|
@ -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',
|
||||||
|
})))
|
||||||
10
web/core/registry.js
Normal file
10
web/core/registry.js
Normal file
|
|
@ -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}`)
|
||||||
|
}
|
||||||
55
web/core/router.js
Normal file
55
web/core/router.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// 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 <link> — removed on navigation.
|
||||||
|
let activeCSS = null
|
||||||
|
|
||||||
|
function currentRoute() {
|
||||||
|
const hash = window.location.hash.replace(/^#\//, '') || PAGES[0]?.route || ''
|
||||||
|
return hash.split('?')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
24
web/favicon.svg
Normal file
24
web/favicon.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="32" height="32" rx="6" fill="#1e1e2e"/>
|
||||||
|
|
||||||
|
<!-- Server rack unit 1 -->
|
||||||
|
<rect x="5" y="7" width="22" height="5" rx="1.5" fill="#313244"/>
|
||||||
|
<circle cx="23" cy="9.5" r="1.2" fill="#a6e3a1"/>
|
||||||
|
<rect x="8" y="9" width="10" height="1" rx="0.5" fill="#585b70"/>
|
||||||
|
|
||||||
|
<!-- Server rack unit 2 -->
|
||||||
|
<rect x="5" y="14" width="22" height="5" rx="1.5" fill="#313244"/>
|
||||||
|
<circle cx="23" cy="16.5" r="1.2" fill="#89b4fa"/>
|
||||||
|
<rect x="8" y="16" width="10" height="1" rx="0.5" fill="#585b70"/>
|
||||||
|
|
||||||
|
<!-- Server rack unit 3 -->
|
||||||
|
<rect x="5" y="21" width="22" height="5" rx="1.5" fill="#313244"/>
|
||||||
|
<circle cx="23" cy="23.5" r="1.2" fill="#89b4fa"/>
|
||||||
|
<rect x="8" y="23" width="6" height="1" rx="0.5" fill="#585b70"/>
|
||||||
|
|
||||||
|
<!-- Rack frame left -->
|
||||||
|
<rect x="3" y="5" width="2" height="23" rx="1" fill="#45475a"/>
|
||||||
|
<!-- Rack frame right -->
|
||||||
|
<rect x="27" y="5" width="2" height="23" rx="1" fill="#45475a"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 996 B |
7
web/icons.json
Normal file
7
web/icons.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"dashboard": "<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\"/><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"/></svg>",
|
||||||
|
"vpc": "<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\"/><path d=\"M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5\"/><path d=\"M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3\"/></svg>",
|
||||||
|
"subnet": "<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"2\" width=\"20\" height=\"20\" rx=\"2\"/><path d=\"M8 12h8M12 8v8\"/></svg>",
|
||||||
|
"vm": "<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"3\" width=\"20\" height=\"14\" rx=\"2\"/><path d=\"M8 21h8M12 17v4\"/></svg>",
|
||||||
|
"account": "<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 20c0-4 3.6-7 8-7s8 3 8 7\"/></svg>"
|
||||||
|
}
|
||||||
117
web/index.html
Normal file
117
web/index.html
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>two — dashboard</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #181825;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
border-bottom: 1px solid #313244;
|
||||||
|
background: #1e1e2e;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #89b4fa;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: side-nav gère sa propre géométrie via inline styles (JS) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
main { padding: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#error {
|
||||||
|
display: none;
|
||||||
|
background: #313244;
|
||||||
|
border: 1px solid #f38ba8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
color: #f38ba8;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<login-gate></login-gate>
|
||||||
|
<api-client></api-client>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>two</h1>
|
||||||
|
<span>network orchestrator</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<logout-button></logout-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<side-nav></side-nav>
|
||||||
|
|
||||||
|
<main></main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
window.addEventListener('unhandledrejection', e => {
|
||||||
|
const el = document.getElementById('error')
|
||||||
|
if (el) {
|
||||||
|
el.style.display = 'block'
|
||||||
|
el.textContent = `Error: ${e.reason?.message ?? e.reason}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sequential: registry defines all custom elements,
|
||||||
|
// 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')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
121
web/pages/account/account.css
Normal file
121
web/pages/account/account.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
117
web/pages/account/account.js
Normal file
117
web/pages/account/account.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// Account page.
|
||||||
|
// Reads ?id=<account-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 => `
|
||||||
|
<tr>
|
||||||
|
<td class="date">${formatDate(tx.date)}</td>
|
||||||
|
<td>${tx.label}</td>
|
||||||
|
<td class="amount ${tx.amount >= 0 ? 'positive' : 'negative'}">
|
||||||
|
${formatAmount(tx.amount, data.currency)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
29
web/pages/account/index.html
Normal file
29
web/pages/account/index.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<div id="account-page">
|
||||||
|
<div id="account-header">
|
||||||
|
<div id="account-title">
|
||||||
|
<h2 id="account-name"></h2>
|
||||||
|
<span id="account-id"></span>
|
||||||
|
</div>
|
||||||
|
<div id="account-balance-block">
|
||||||
|
<span class="balance-label">Solde</span>
|
||||||
|
<span id="account-balance"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="account-transactions">
|
||||||
|
<h3>Transactions récentes</h3>
|
||||||
|
<table id="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Libellé</th>
|
||||||
|
<th class="amount-col">Montant</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tx-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="account-error" style="display:none"></div>
|
||||||
|
<div id="account-loading">Chargement…</div>
|
||||||
|
</div>
|
||||||
5
web/pages/account/manifest.json
Normal file
5
web/pages/account/manifest.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"route": "account",
|
||||||
|
"label": "Compte",
|
||||||
|
"icon": "account"
|
||||||
|
}
|
||||||
2
web/pages/dashboard/dashboard.css
Normal file
2
web/pages/dashboard/dashboard.css
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/* Styles scoped to the dashboard page.
|
||||||
|
Applied when the route is active, removed on navigation. */
|
||||||
57
web/pages/dashboard/dashboard.js
Normal file
57
web/pages/dashboard/dashboard.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { SidePanel } from '../../components/side-panel/side-panel.js'
|
||||||
|
|
||||||
|
export function init(main) {
|
||||||
|
main.querySelectorAll('[data-panel-title]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
SidePanel.open({
|
||||||
|
title: btn.dataset.panelTitle,
|
||||||
|
width: btn.dataset.panelWidth ?? '480px',
|
||||||
|
content: `<p style="color:#a6adc8;font-size:14px;line-height:1.6">
|
||||||
|
Contenu du panneau <strong style="color:#cdd6f4">${btn.dataset.panelTitle}</strong>.<br>
|
||||||
|
Largeur : ${btn.dataset.panelWidth}.<br><br>
|
||||||
|
Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche.
|
||||||
|
Ferme avec ✕, Échap, ou en cliquant le fond.
|
||||||
|
</p>`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
main.querySelectorAll('[data-action="create-vm"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
SidePanel.open({
|
||||||
|
title: 'Nouvelle VM',
|
||||||
|
width: '520px',
|
||||||
|
content: `<form-vm></form-vm>`,
|
||||||
|
})
|
||||||
|
// 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 vm-name="i-test1"></form-vm>`
|
||||||
|
})
|
||||||
|
// form-vm gère son propre submit et dispatche vm-saved — pas besoin de querySelector
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
main.querySelectorAll('[data-action="console"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
SidePanel.open({
|
||||||
|
title: 'afficher une consol',
|
||||||
|
width: '700px',
|
||||||
|
content: `<vnc-viewer vm="i-test1"></vnc-viewer>`
|
||||||
|
})
|
||||||
|
// 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>
|
||||||
|
main.addEventListener('vm-saved', e => {
|
||||||
|
console.log('vm-saved', e.detail)
|
||||||
|
})
|
||||||
|
}
|
||||||
29
web/pages/dashboard/index.html
Normal file
29
web/pages/dashboard/index.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<date-display></date-display>
|
||||||
|
|
||||||
|
<div style="gap:8px;margin-top:0px;">
|
||||||
|
<button data-panel-title="Panel A" data-panel-width="400px"
|
||||||
|
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||||
|
padding:7px 14px;color:#89b4fa;font-size:13px;cursor:pointer;">
|
||||||
|
Open panel A
|
||||||
|
</button>
|
||||||
|
<button data-panel-title="Panel B" data-panel-width="520px"
|
||||||
|
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||||
|
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||||
|
Open panel B
|
||||||
|
</button>
|
||||||
|
<button data-action="create-vm"
|
||||||
|
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||||
|
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||||
|
Create-vm
|
||||||
|
</button>
|
||||||
|
<button data-action="edit-vm-i-test1"
|
||||||
|
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||||
|
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||||
|
Edition
|
||||||
|
</button>
|
||||||
|
<button data-action="console"
|
||||||
|
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||||
|
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||||
|
Ouvrir la console
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
6
web/pages/dashboard/manifest.json
Normal file
6
web/pages/dashboard/manifest.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"route": "dashboard",
|
||||||
|
"label": "Dashboard",
|
||||||
|
"icon": "dashboard",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
1
web/pages/subnets/index.html
Normal file
1
web/pages/subnets/index.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<p>Subnets — à venir</p>
|
||||||
1
web/pages/subnets/manifest.json
Normal file
1
web/pages/subnets/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{ "route": "subnets", "label": "Subnets", "icon": "subnet", "version": "0.1.0" }
|
||||||
1
web/pages/vms/index.html
Normal file
1
web/pages/vms/index.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<p>VMs — à venir</p>
|
||||||
1
web/pages/vms/manifest.json
Normal file
1
web/pages/vms/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{ "route": "vms", "label": "VMs", "icon": "vm", "version": "0.1.0" }
|
||||||
1
web/pages/vpcs/index.html
Normal file
1
web/pages/vpcs/index.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<p>VPCs — à venir</p>
|
||||||
1
web/pages/vpcs/manifest.json
Normal file
1
web/pages/vpcs/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{ "route": "vpcs", "label": "VPCs", "icon": "vpc", "version": "0.1.0" }
|
||||||
Loading…
Add table
Add a link
Reference in a new issue