From 0a73a708208d329e91b524e061dc0b5149841f88 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 6 Jun 2026 19:55:41 +0000 Subject: [PATCH] Initial commit: EduBox V2 platform --- .env.example | 10 + .gitignore | 15 + Caddyfile | 8 + agent/activation.go | 37 + agent/docker.go | 47 + agent/go.mod | 54 + agent/go.sum | 233 ++ agent/main.go | 52 + agent/tailscale.go | 28 + agent/ui.go | 104 + agent/ui/index.html | 77 + agent/websocket.go | 125 + docker-compose.yml | 93 + server/Dockerfile | 9 + server/app/(auth)/login/LoginForm.tsx | 46 + server/app/(auth)/login/page.tsx | 15 + server/app/api/auth/[...nextauth]/route.ts | 6 + server/app/api/classes/route.ts | 24 + server/app/api/download/route.ts | 9 + server/app/api/establishments/route.ts | 37 + server/app/api/instances/route.ts | 86 + server/app/api/nodes/route.ts | 21 + server/app/api/students/route.ts | 44 + server/app/api/templates/route.ts | 42 + server/app/api/users/route.ts | 41 + server/app/api/websocket/route.ts | 17 + server/app/dashboard/DashboardNav.tsx | 72 + server/app/dashboard/classes/page.tsx | 56 + server/app/dashboard/download/page.tsx | 41 + .../dashboard/instances/InstanceActions.tsx | 37 + .../dashboard/instances/assign/AssignForm.tsx | 60 + .../app/dashboard/instances/assign/page.tsx | 34 + server/app/dashboard/instances/page.tsx | 77 + server/app/dashboard/layout.tsx | 18 + server/app/dashboard/nodes/page.tsx | 65 + server/app/dashboard/page.tsx | 72 + server/app/dashboard/students/page.tsx | 68 + server/app/dashboard/templates/page.tsx | 61 + server/app/globals.css | 37 + server/app/layout.tsx | 22 + server/app/page.tsx | 5 + server/app/superadmin/establishments/page.tsx | 58 + server/app/superadmin/layout.tsx | 11 + server/app/superadmin/page.tsx | 46 + server/components/ui/badge.tsx | 25 + server/components/ui/button.tsx | 36 + server/components/ui/card.tsx | 24 + server/components/ui/dialog.tsx | 29 + server/components/ui/input.tsx | 21 + server/components/ui/select.tsx | 20 + server/components/ui/table.tsx | 36 + server/components/ui/tabs.tsx | 28 + server/lib/auth-config.ts | 45 + server/lib/auth.ts | 16 + server/lib/prisma.ts | 7 + server/lib/utils.ts | 6 + server/lib/websocket.ts | 105 + server/middleware.ts | 35 + server/next-env.d.ts | 6 + server/next.config.js | 4 + server/package-lock.json | 2687 +++++++++++++++++ server/package.json | 42 + server/postcss.config.js | 6 + server/prisma/schema.prisma | 97 + server/prisma/seed.ts | 66 + server/tailwind.config.ts | 57 + server/tsconfig.json | 41 + server/types/next-auth.d.ts | 26 + setup.sh | 49 + 69 files changed, 5634 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 agent/activation.go create mode 100644 agent/docker.go create mode 100644 agent/go.mod create mode 100644 agent/go.sum create mode 100644 agent/main.go create mode 100644 agent/tailscale.go create mode 100644 agent/ui.go create mode 100644 agent/ui/index.html create mode 100644 agent/websocket.go create mode 100644 docker-compose.yml create mode 100644 server/Dockerfile create mode 100644 server/app/(auth)/login/LoginForm.tsx create mode 100644 server/app/(auth)/login/page.tsx create mode 100644 server/app/api/auth/[...nextauth]/route.ts create mode 100644 server/app/api/classes/route.ts create mode 100644 server/app/api/download/route.ts create mode 100644 server/app/api/establishments/route.ts create mode 100644 server/app/api/instances/route.ts create mode 100644 server/app/api/nodes/route.ts create mode 100644 server/app/api/students/route.ts create mode 100644 server/app/api/templates/route.ts create mode 100644 server/app/api/users/route.ts create mode 100644 server/app/api/websocket/route.ts create mode 100644 server/app/dashboard/DashboardNav.tsx create mode 100644 server/app/dashboard/classes/page.tsx create mode 100644 server/app/dashboard/download/page.tsx create mode 100644 server/app/dashboard/instances/InstanceActions.tsx create mode 100644 server/app/dashboard/instances/assign/AssignForm.tsx create mode 100644 server/app/dashboard/instances/assign/page.tsx create mode 100644 server/app/dashboard/instances/page.tsx create mode 100644 server/app/dashboard/layout.tsx create mode 100644 server/app/dashboard/nodes/page.tsx create mode 100644 server/app/dashboard/page.tsx create mode 100644 server/app/dashboard/students/page.tsx create mode 100644 server/app/dashboard/templates/page.tsx create mode 100644 server/app/globals.css create mode 100644 server/app/layout.tsx create mode 100644 server/app/page.tsx create mode 100644 server/app/superadmin/establishments/page.tsx create mode 100644 server/app/superadmin/layout.tsx create mode 100644 server/app/superadmin/page.tsx create mode 100644 server/components/ui/badge.tsx create mode 100644 server/components/ui/button.tsx create mode 100644 server/components/ui/card.tsx create mode 100644 server/components/ui/dialog.tsx create mode 100644 server/components/ui/input.tsx create mode 100644 server/components/ui/select.tsx create mode 100644 server/components/ui/table.tsx create mode 100644 server/components/ui/tabs.tsx create mode 100644 server/lib/auth-config.ts create mode 100644 server/lib/auth.ts create mode 100644 server/lib/prisma.ts create mode 100644 server/lib/utils.ts create mode 100644 server/lib/websocket.ts create mode 100644 server/middleware.ts create mode 100644 server/next-env.d.ts create mode 100644 server/next.config.js create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/postcss.config.js create mode 100644 server/prisma/schema.prisma create mode 100644 server/prisma/seed.ts create mode 100644 server/tailwind.config.ts create mode 100644 server/tsconfig.json create mode 100644 server/types/next-auth.d.ts create mode 100755 setup.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3801a3d --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +DATABASE_URL=postgresql://edubox:CHANGE_ME@postgres:5432/edubox +POSTGRES_PASSWORD=CHANGE_ME +NEXTAUTH_SECRET=CHANGE_ME +NEXTAUTH_URL=http://localhost +SUPERADMIN_EMAIL=admin@edudeploy.fr +SUPERADMIN_PASSWORD=CHANGE_ME +HEADSCALE_URL=http://headscale:8080 +HEADSCALE_AUTH_KEY=CHANGE_ME +GITEA_URL=http://gitea:3000 +GITEA_TOKEN=CHANGE_ME diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..574301b --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +node_modules/ +.next/ +*.log +edubox-data/ +dist/ +coverage/ +*.exe +*.dll +*.so +*.dylib +agent/edubox-agent +agent/edubox-agent.exe +agent/edubox-agent-mac +agent/ui/*.go.html diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..f3b90a4 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,8 @@ +{ + auto_https off +} + +:80 { + reverse_proxy /gitea* gitea:3000 + reverse_proxy server:3000 +} diff --git a/agent/activation.go b/agent/activation.go new file mode 100644 index 0000000..e651199 --- /dev/null +++ b/agent/activation.go @@ -0,0 +1,37 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type Activation struct { + Activated bool `json:"activated"` + StudentName string `json:"studentName,omitempty"` + Code string `json:"code,omitempty"` +} + +func activationFile(dataDir string) string { + return filepath.Join(dataDir, "activation.json") +} + +func loadActivation(dataDir string) (*Activation, error) { + f := activationFile(dataDir) + data, err := os.ReadFile(f) + if err != nil { + return nil, err + } + var a Activation + err = json.Unmarshal(data, &a) + return &a, err +} + +func saveActivation(dataDir string, a *Activation) error { + f := activationFile(dataDir) + data, err := json.MarshalIndent(a, "", " ") + if err != nil { + return err + } + return os.WriteFile(f, data, 0644) +} diff --git a/agent/docker.go b/agent/docker.go new file mode 100644 index 0000000..ea9742c --- /dev/null +++ b/agent/docker.go @@ -0,0 +1,47 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" +) + +func instanceDir(dataDir, instanceID string) string { + return filepath.Join(dataDir, "instances", instanceID) +} + +func writeCompose(dataDir, instanceID, compose string) error { + dir := instanceDir(dataDir, instanceID) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + f := filepath.Join(dir, "docker-compose.yml") + return os.WriteFile(f, []byte(compose), 0644) +} + +func dockerComposeUp(dataDir, instanceID string) error { + dir := instanceDir(dataDir, instanceID) + cmd := exec.Command("docker", "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func dockerComposeDown(dataDir, instanceID string) error { + dir := instanceDir(dataDir, instanceID) + cmd := exec.Command("docker", "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func dockerComposeRm(dataDir, instanceID string) error { + dir := instanceDir(dataDir, instanceID) + cmd := exec.Command("docker", "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + return os.RemoveAll(dir) +} diff --git a/agent/go.mod b/agent/go.mod new file mode 100644 index 0000000..f3420f8 --- /dev/null +++ b/agent/go.mod @@ -0,0 +1,54 @@ +module edubox-agent + +go 1.26.4 + +require ( + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + tailscale.com v1.100.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/creachadair/msync v0.7.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gaissmai/bart v0.26.1 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad // indirect + github.com/x448/float16 v0.8.4 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect +) diff --git a/agent/go.sum b/agent/go.sum new file mode 100644 index 0000000..3e947f5 --- /dev/null +++ b/agent/go.sum @@ -0,0 +1,233 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= +github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= +github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= +github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA= +github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= +github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= +github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= +github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8= +github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc= +github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg= +github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.100.0 h1:nm/M/dEaW9RaRsGUjW2HsSDpsZ60Jwd9k4gNW9tTFiE= +tailscale.com v1.100.0/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE= diff --git a/agent/main.go b/agent/main.go new file mode 100644 index 0000000..1d9e06c --- /dev/null +++ b/agent/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" +) + +var ( + serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur") + nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)") + dataDir = flag.String("data-dir", "./edubox-data", "Répertoire de données") + uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX") +) + +func defaultNodeID() string { + h, err := os.Hostname() + if err != nil { + return "unknown" + } + return h +} + +func main() { + flag.Parse() + + dd := *dataDir + if !filepath.IsAbs(dd) { + ex, err := os.Executable() + if err == nil { + dd = filepath.Join(filepath.Dir(ex), dd) + } else { + wd, _ := os.Getwd() + dd = filepath.Join(wd, dd) + } + } + *dataDir = dd + + if err := os.MkdirAll(*dataDir, 0755); err != nil { + log.Fatalf("Cannot create data-dir: %v", err) + } + + fmt.Printf("EduBox Agent - node: %s - data-dir: %s\n", *nodeID, *dataDir) + + if *uiEnabled { + go startUI(*dataDir, *nodeID, *serverAddr) + } + + startWebSocket(*serverAddr, *nodeID, *dataDir) +} diff --git a/agent/tailscale.go b/agent/tailscale.go new file mode 100644 index 0000000..253b7a3 --- /dev/null +++ b/agent/tailscale.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "log" + "net" + + "tailscale.com/tsnet" +) + +func startTailscale(dataDir string, nodeID string) (net.Listener, error) { + s := &tsnet.Server{ + Hostname: nodeID, + Dir: dataDir, + Logf: log.Printf, + } + + if err := s.Start(); err != nil { + return nil, fmt.Errorf("tailscale start: %w", err) + } + + ln, err := s.Listen("tcp", ":0") + if err != nil { + return nil, fmt.Errorf("tailscale listen: %w", err) + } + + return ln, nil +} diff --git a/agent/ui.go b/agent/ui.go new file mode 100644 index 0000000..fb20385 --- /dev/null +++ b/agent/ui.go @@ -0,0 +1,104 @@ +package main + +import ( + _ "embed" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + + "github.com/gorilla/websocket" +) + +//go:embed ui/index.html +var uiHTML string + +var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + +func startUI(dataDir, nodeID, serverAddr string) { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, uiHTML) + }) + + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("UI WS upgrade error: %v", err) + return + } + defer conn.Close() + + for { + var msg map[string]interface{} + if err := conn.ReadJSON(&msg); err != nil { + break + } + action, _ := msg["action"].(string) + switch action { + case "check": + act, err := loadActivation(dataDir) + if err == nil && act.Activated { + conn.WriteJSON(map[string]interface{}{"action": "activated", "studentName": act.StudentName}) + } else { + conn.WriteJSON(map[string]interface{}{"action": "not_activated"}) + } + case "activate": + code, _ := msg["code"].(string) + // Forward to server WS + go func() { + forwardActivation(serverAddr, nodeID, code, conn) + }() + case "instances": + listInstances(dataDir, conn) + } + } + }) + + port := "7070" + log.Printf("UI starting on http://localhost:%s", port) + if err := http.ListenAndServe("127.0.0.1:"+port, nil); err != nil { + log.Fatalf("UI server error: %v", err) + } +} + +func forwardActivation(serverAddr, nodeID, code string, uiConn *websocket.Conn) { + conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil) + if err != nil { + uiConn.WriteJSON(map[string]interface{}{"action": "activation_failed", "error": err.Error()}) + return + } + defer conn.Close() + + conn.WriteJSON(map[string]interface{}{"action": "register", "nodeId": nodeID}) + conn.WriteJSON(map[string]interface{}{"action": "activate", "code": code, "nodeId": nodeID}) + + for { + var msg map[string]interface{} + if err := conn.ReadJSON(&msg); err != nil { + break + } + action, _ := msg["action"].(string) + if action == "activated" || action == "activation_failed" { + uiConn.WriteJSON(msg) + break + } + } +} + +func listInstances(dataDir string, conn *websocket.Conn) { + dir := filepath.Join(dataDir, "instances") + entries, err := os.ReadDir(dir) + if err != nil { + conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": []interface{}{}}) + return + } + var instances []map[string]interface{} + for _, e := range entries { + if e.IsDir() { + instances = append(instances, map[string]interface{}{"id": e.Name()}) + } + } + conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": instances}) +} diff --git a/agent/ui/index.html b/agent/ui/index.html new file mode 100644 index 0000000..30cff67 --- /dev/null +++ b/agent/ui/index.html @@ -0,0 +1,77 @@ + + + + + EduBox Agent + + + + +
+

EduBox Agent

+
+

Chargement...

+
+
+ + + + diff --git a/agent/websocket.go b/agent/websocket.go new file mode 100644 index 0000000..ddc795f --- /dev/null +++ b/agent/websocket.go @@ -0,0 +1,125 @@ +package main + +import ( + "log" + "time" + + "github.com/gorilla/websocket" +) + +type WSMessage struct { + Action string `json:"action"` + NodeID string `json:"nodeId,omitempty"` + Code string `json:"code,omitempty"` + InstanceID string `json:"instanceId,omitempty"` + Type string `json:"type,omitempty"` + Port int `json:"port,omitempty"` + ComposeConfig string `json:"composeConfig,omitempty"` + StudentName string `json:"studentName,omitempty"` + Error string `json:"error,omitempty"` +} + +func startWebSocket(serverAddr, nodeID, dataDir string) { + for { + conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil) + if err != nil { + log.Printf("WS connect error: %v, retrying in 5s...", err) + time.Sleep(5 * time.Second) + continue + } + + log.Printf("WS connected to %s", serverAddr) + + // Register + if err := conn.WriteJSON(WSMessage{Action: "register", NodeID: nodeID}); err != nil { + log.Printf("WS register error: %v", err) + conn.Close() + continue + } + + // Activation flow + act, err := loadActivation(dataDir) + if err != nil || !act.Activated { + log.Println("Waiting for activation...") + } else { + log.Printf("Already activated as %s", act.StudentName) + } + + // Heartbeat goroutine + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := conn.WriteJSON(WSMessage{Action: "heartbeat", NodeID: nodeID}); err != nil { + return + } + case <-done: + return + } + } + }() + + // Read loop + for { + var msg WSMessage + if err := conn.ReadJSON(&msg); err != nil { + log.Printf("WS read error: %v", err) + break + } + handleMessage(conn, msg, dataDir, nodeID) + } + + close(done) + conn.Close() + log.Println("WS disconnected, reconnecting in 5s...") + time.Sleep(5 * time.Second) + } +} + +func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) { + switch msg.Action { + case "activate": + // handled by UI, but server can also push activation response + if msg.StudentName != "" { + act := &Activation{Activated: true, StudentName: msg.StudentName, Code: msg.Code} + saveActivation(dataDir, act) + log.Printf("Activated as %s", msg.StudentName) + } + case "start": + log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port) + if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil { + log.Printf("writeCompose error: %v", err) + conn.WriteJSON(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()}) + return + } + if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil { + log.Printf("dockerComposeUp error: %v", err) + conn.WriteJSON(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()}) + return + } + conn.WriteJSON(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port}) + case "stop": + log.Printf("Stop instance %s", msg.InstanceID) + if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil { + log.Printf("dockerComposeDown error: %v", err) + } + case "reset": + log.Printf("Reset instance %s", msg.InstanceID) + dockerComposeRm(dataDir, msg.InstanceID) + if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil { + log.Printf("writeCompose error: %v", err) + return + } + if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil { + log.Printf("dockerComposeUp error: %v", err) + conn.WriteJSON(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()}) + return + } + conn.WriteJSON(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port}) + default: + log.Printf("Unknown action: %s", msg.Action) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c0b0757 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,93 @@ +services: + postgres: + image: postgres:18-alpine + container_name: edubox-postgres + restart: unless-stopped + environment: + POSTGRES_USER: edubox + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: edubox + volumes: + - pg_data:/var/lib/postgresql + networks: + - edubox + healthcheck: + test: ["CMD-SHELL", "pg_isready -U edubox -d edubox"] + interval: 5s + timeout: 5s + retries: 5 + + server: + build: + context: ./server + dockerfile: Dockerfile + container_name: edubox-server + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + NEXTAUTH_URL: ${NEXTAUTH_URL} + SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL} + SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD} + HEADSCALE_URL: ${HEADSCALE_URL} + HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY} + GITEA_URL: ${GITEA_URL} + GITEA_TOKEN: ${GITEA_TOKEN} + depends_on: + postgres: + condition: service_healthy + networks: + - edubox + + caddy: + image: caddy:2-alpine + container_name: edubox-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - edubox + + headscale: + image: headscale/headscale:latest + container_name: edubox-headscale + restart: unless-stopped + command: serve + ports: + - "41641:41641/udp" + volumes: + - headscale_data:/etc/headscale + networks: + - edubox + + gitea: + image: gitea/gitea:latest + container_name: edubox-gitea + restart: unless-stopped + ports: + - "3001:3000" + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__database__PATH=/data/gitea/gitea.db + volumes: + - gitea_data:/data + networks: + - edubox + +volumes: + pg_data: + caddy_data: + caddy_config: + headscale_data: + gitea_data: + +networks: + edubox: + driver: bridge diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..276d57e --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,9 @@ +FROM node:24-alpine +WORKDIR /app +RUN apk add --no-cache openssl +COPY package.json package-lock.json* ./ +COPY prisma ./prisma +RUN npm ci +COPY . . +RUN npm run build +CMD ["node_modules/.bin/next", "start"] diff --git a/server/app/(auth)/login/LoginForm.tsx b/server/app/(auth)/login/LoginForm.tsx new file mode 100644 index 0000000..bf7c324 --- /dev/null +++ b/server/app/(auth)/login/LoginForm.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +export default function LoginForm() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(""); + const res = await signIn("credentials", { email, password, redirect: false }); + setLoading(false); + if (res?.error) { + setError("Email ou mot de passe invalide"); + } else { + router.push("/dashboard"); + router.refresh(); + } + } + + return ( +
+ {error &&
{error}
} +
+ + setEmail(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required /> +
+ +
+ ); +} diff --git a/server/app/(auth)/login/page.tsx b/server/app/(auth)/login/page.tsx new file mode 100644 index 0000000..32b85ad --- /dev/null +++ b/server/app/(auth)/login/page.tsx @@ -0,0 +1,15 @@ +import LoginForm from "./LoginForm"; + +export const dynamic = "force-dynamic"; + +export default function LoginPage() { + return ( +
+
+

EduBox V2

+

Connexion à la plateforme

+ +
+
+ ); +} diff --git a/server/app/api/auth/[...nextauth]/route.ts b/server/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7c33a2f --- /dev/null +++ b/server/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth-config"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/server/app/api/classes/route.ts b/server/app/api/classes/route.ts new file mode 100644 index 0000000..58a1c04 --- /dev/null +++ b/server/app/api/classes/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const establishmentId = searchParams.get("establishmentId"); + if (!establishmentId) return NextResponse.json({ error: "Missing establishmentId" }, { status: 400 }); + + const classes = await prisma.class.findMany({ + where: { establishmentId }, + include: { _count: { select: { students: true } } }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(classes); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { establishmentId, name, level } = body; + const cls = await prisma.class.create({ + data: { establishmentId, name, level }, + }); + return NextResponse.json(cls, { status: 201 }); +} diff --git a/server/app/api/download/route.ts b/server/app/api/download/route.ts new file mode 100644 index 0000000..ca7bb6d --- /dev/null +++ b/server/app/api/download/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + windows: "/agent/edubox-agent.exe", + linux: "/agent/edubox-agent", + mac: "/agent/edubox-agent-mac", + }); +} diff --git a/server/app/api/establishments/route.ts b/server/app/api/establishments/route.ts new file mode 100644 index 0000000..259cb2b --- /dev/null +++ b/server/app/api/establishments/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { hashPassword } from "@/lib/auth"; + +export async function GET() { + const establishments = await prisma.establishment.findMany({ + include: { subscription: true, _count: { select: { users: true, classes: true } } }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(establishments); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { name, slug, adminEmail, adminPassword } = body; + + const establishment = await prisma.establishment.create({ + data: { name, slug }, + }); + + await prisma.subscription.create({ + data: { establishmentId: establishment.id, plan: "trial", status: "active" }, + }); + + if (adminEmail && adminPassword) { + await prisma.user.create({ + data: { + email: adminEmail, + password: await hashPassword(adminPassword), + role: "admin", + establishmentId: establishment.id, + }, + }); + } + + return NextResponse.json(establishment, { status: 201 }); +} diff --git a/server/app/api/instances/route.ts b/server/app/api/instances/route.ts new file mode 100644 index 0000000..58c7473 --- /dev/null +++ b/server/app/api/instances/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { sendToNode } from "@/lib/websocket"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const nodeId = searchParams.get("nodeId"); + const establishmentId = searchParams.get("establishmentId"); + + let where: any = {}; + if (nodeId) where.nodeId = nodeId; + if (establishmentId) { + const classes = await prisma.class.findMany({ where: { establishmentId }, select: { id: true } }); + const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } }); + const nodes = await prisma.node.findMany({ where: { studentId: { in: students.map((s) => s.id) } }, select: { id: true } }); + where.nodeId = { in: nodes.map((n) => n.id) }; + } + + const instances = await prisma.instance.findMany({ + where, + include: { node: { include: { student: { include: { class: true } } } }, template: true }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(instances); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { nodeId, templateId, port } = body; + + const template = await prisma.template.findUnique({ where: { id: templateId } }); + if (!template) return NextResponse.json({ error: "Template not found" }, { status: 404 }); + + const instance = await prisma.instance.create({ + data: { nodeId, templateId, port: port || 8080, status: "stopped" }, + }); + + const sent = sendToNode(nodeId, { + action: "start", + instanceId: instance.id, + type: template.type, + port: instance.port, + composeConfig: template.composeConfig.replace(/{PORT}/g, String(instance.port)).replace(/{INSTANCE_ID}/g, instance.id), + }); + + if (!sent) { + await prisma.instance.update({ where: { id: instance.id }, data: { status: "error" } }); + } + + return NextResponse.json(instance, { status: 201 }); +} + +export async function PATCH(req: NextRequest) { + const body = await req.json(); + const { id, action } = body; + const instance = await prisma.instance.findUnique({ where: { id }, include: { template: true } }); + if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + if (action === "stop") { + sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id }); + await prisma.instance.update({ where: { id }, data: { status: "stopped" } }); + } else if (action === "start") { + const sent = sendToNode(instance.nodeId, { + action: "start", + instanceId: instance.id, + type: instance.template.type, + port: instance.port, + composeConfig: instance.template.composeConfig.replace(/{PORT}/g, String(instance.port)).replace(/{INSTANCE_ID}/g, instance.id), + }); + if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } }); + } else if (action === "reset") { + sendToNode(instance.nodeId, { action: "reset", instanceId: instance.id }); + } + + return NextResponse.json({ ok: true }); +} + +export async function DELETE(req: NextRequest) { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); + const instance = await prisma.instance.findUnique({ where: { id } }); + if (instance) sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id }); + await prisma.instance.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/server/app/api/nodes/route.ts b/server/app/api/nodes/route.ts new file mode 100644 index 0000000..860f960 --- /dev/null +++ b/server/app/api/nodes/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const establishmentId = searchParams.get("establishmentId"); + + let where: any = {}; + if (establishmentId) { + const classes = await prisma.class.findMany({ where: { establishmentId }, select: { id: true } }); + const students = await prisma.student.findMany({ where: { classId: { in: classes.map((c) => c.id) } }, select: { id: true } }); + where.studentId = { in: students.map((s) => s.id) }; + } + + const nodes = await prisma.node.findMany({ + where, + include: { student: { include: { class: true } }, instances: true }, + orderBy: { lastSeen: "desc" }, + }); + return NextResponse.json(nodes); +} diff --git a/server/app/api/students/route.ts b/server/app/api/students/route.ts new file mode 100644 index 0000000..d4fe7b3 --- /dev/null +++ b/server/app/api/students/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +function generateCode(length = 6) { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let code = ""; + for (let i = 0; i < length; i++) code += chars.charAt(Math.floor(Math.random() * chars.length)); + return code; +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const classId = searchParams.get("classId"); + const establishmentId = searchParams.get("establishmentId"); + + const where: any = {}; + if (classId) where.classId = classId; + if (establishmentId) { + const classes = await prisma.class.findMany({ where: { establishmentId }, select: { id: true } }); + where.classId = { in: classes.map((c) => c.id) }; + } + + const students = await prisma.student.findMany({ + where, + include: { class: true, nodes: true }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(students); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { classId, firstName, lastName, email } = body; + const student = await prisma.student.create({ + data: { + classId, + firstName, + lastName, + email, + activationCode: generateCode(), + }, + }); + return NextResponse.json(student, { status: 201 }); +} diff --git a/server/app/api/templates/route.ts b/server/app/api/templates/route.ts new file mode 100644 index 0000000..3a24234 --- /dev/null +++ b/server/app/api/templates/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const establishmentId = searchParams.get("establishmentId"); + + const templates = await prisma.template.findMany({ + where: { + OR: [ + { isPublic: true }, + ...(establishmentId ? [{ establishmentId }] : []), + ], + }, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(templates); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy } = body; + const template = await prisma.template.create({ + data: { name, type, dockerImage, composeConfig, isPublic, establishmentId, createdBy }, + }); + return NextResponse.json(template, { status: 201 }); +} + +export async function PUT(req: NextRequest) { + const body = await req.json(); + const { id, ...data } = body; + const template = await prisma.template.update({ where: { id }, data }); + return NextResponse.json(template); +} + +export async function DELETE(req: NextRequest) { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); + await prisma.template.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/server/app/api/users/route.ts b/server/app/api/users/route.ts new file mode 100644 index 0000000..5912b30 --- /dev/null +++ b/server/app/api/users/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { hashPassword } from "@/lib/auth"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const establishmentId = searchParams.get("establishmentId"); + const role = searchParams.get("role"); + + const where: any = {}; + if (establishmentId) where.establishmentId = establishmentId; + if (role) where.role = role; + + const users = await prisma.user.findMany({ + where, + orderBy: { createdAt: "desc" }, + }); + return NextResponse.json(users); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { email, password, role, establishmentId } = body; + const user = await prisma.user.create({ + data: { + email, + password: await hashPassword(password), + role, + establishmentId, + }, + }); + return NextResponse.json(user, { status: 201 }); +} + +export async function DELETE(req: NextRequest) { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); + await prisma.user.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/server/app/api/websocket/route.ts b/server/app/api/websocket/route.ts new file mode 100644 index 0000000..c763e43 --- /dev/null +++ b/server/app/api/websocket/route.ts @@ -0,0 +1,17 @@ +import { WebSocketServer } from "ws"; +import { initWebSocketServer } from "@/lib/websocket"; + +const globalWss = globalThis as typeof globalThis & { __eduboxWss?: WebSocketServer }; + +if (!globalWss.__eduboxWss) { + try { + globalWss.__eduboxWss = new WebSocketServer({ port: 3001 }); + initWebSocketServer(globalWss.__eduboxWss); + } catch { + // Port may be in use during build or hot reload + } +} + +export async function GET() { + return new Response("WebSocket server running on port 3001", { status: 200 }); +} diff --git a/server/app/dashboard/DashboardNav.tsx b/server/app/dashboard/DashboardNav.tsx new file mode 100644 index 0000000..8e1fd5e --- /dev/null +++ b/server/app/dashboard/DashboardNav.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { signOut } from "next-auth/react"; +import { cn } from "@/lib/utils"; + +const links = [ + { href: "/dashboard", label: "Accueil" }, + { href: "/dashboard/classes", label: "Classes" }, + { href: "/dashboard/students", label: "Étudiants" }, + { href: "/dashboard/nodes", label: "Nœuds" }, + { href: "/dashboard/instances", label: "Instances" }, + { href: "/dashboard/templates", label: "Templates" }, + { href: "/dashboard/download", label: "Téléchargements" }, +]; + +const superadminLinks = [ + { href: "/superadmin", label: "Super Admin" }, + { href: "/superadmin/establishments", label: "Établissements" }, +]; + +export default function DashboardNav({ role }: { role: string }) { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/server/app/dashboard/classes/page.tsx b/server/app/dashboard/classes/page.tsx new file mode 100644 index 0000000..2fc6f18 --- /dev/null +++ b/server/app/dashboard/classes/page.tsx @@ -0,0 +1,56 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth-config"; +import { redirect } from "next/navigation"; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; + +export const dynamic = "force-dynamic"; + +export default async function ClassesPage() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login"); + if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard"); + + const establishmentId = session.user.establishmentId; + const classes = await prisma.class.findMany({ + where: establishmentId ? { establishmentId } : {}, + include: { _count: { select: { students: true } } }, + orderBy: { createdAt: "desc" }, + }); + + return ( +
+

Classes

+ + + + + + Nom + Niveau + Étudiants + Créée le + + + + {classes.map((cls) => ( + + {cls.name} + {cls.level} + {cls._count.students} + {new Date(cls.createdAt).toLocaleDateString("fr-FR")} + + ))} + {classes.length === 0 && ( + + Aucune classe + + )} + +
+
+
+
+ ); +} diff --git a/server/app/dashboard/download/page.tsx b/server/app/dashboard/download/page.tsx new file mode 100644 index 0000000..92b54b3 --- /dev/null +++ b/server/app/dashboard/download/page.tsx @@ -0,0 +1,41 @@ +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export const dynamic = "force-dynamic"; + +export default function DownloadPage() { + return ( +
+

Téléchargements Agent

+
+ + + Windows + + +

Agent EduBox pour Windows (64 bits)

+ Télécharger (.exe) +
+
+ + + Linux + + +

Agent EduBox pour Linux (64 bits)

+ Télécharger +
+
+ + + macOS + + +

Agent EduBox pour macOS (Intel & Apple Silicon)

+ Télécharger +
+
+
+
+ ); +} diff --git a/server/app/dashboard/instances/InstanceActions.tsx b/server/app/dashboard/instances/InstanceActions.tsx new file mode 100644 index 0000000..53cb64e --- /dev/null +++ b/server/app/dashboard/instances/InstanceActions.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export default function InstanceActions({ instanceId, status }: { instanceId: string; status: string }) { + const [loading, setLoading] = useState(null); + + async function action(type: string) { + setLoading(type); + await fetch("/api/instances", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: instanceId, action: type }), + }); + setLoading(null); + window.location.reload(); + } + + return ( +
+ {status !== "running" && ( + + )} + {status === "running" && ( + + )} + +
+ ); +} diff --git a/server/app/dashboard/instances/assign/AssignForm.tsx b/server/app/dashboard/instances/assign/AssignForm.tsx new file mode 100644 index 0000000..dbdcfcb --- /dev/null +++ b/server/app/dashboard/instances/assign/AssignForm.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Input } from "@/components/ui/input"; +import { Select } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; + +export default function AssignForm({ templates, nodes }: { templates: any[]; nodes: any[] }) { + const [templateId, setTemplateId] = useState(""); + const [nodeId, setNodeId] = useState(""); + const [port, setPort] = useState("8080"); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + await fetch("/api/instances", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templateId, nodeId, port: parseInt(port) }), + }); + setLoading(false); + router.push("/dashboard/instances"); + router.refresh(); + } + + return ( +
+
+ + +
+
+ + +
+
+ + setPort(e.target.value)} required /> +
+ +
+ ); +} diff --git a/server/app/dashboard/instances/assign/page.tsx b/server/app/dashboard/instances/assign/page.tsx new file mode 100644 index 0000000..0f44531 --- /dev/null +++ b/server/app/dashboard/instances/assign/page.tsx @@ -0,0 +1,34 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth-config"; +import { redirect } from "next/navigation"; +import AssignForm from "./AssignForm"; + +export const dynamic = "force-dynamic"; + +export default async function AssignPage() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login"); + if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard"); + + const establishmentId = session.user.establishmentId; + const templates = await prisma.template.findMany({ + where: { OR: [{ isPublic: true }, ...(establishmentId ? [{ establishmentId }] : [])] }, + orderBy: { createdAt: "desc" }, + }); + + const nodes = await prisma.node.findMany({ + where: establishmentId + ? { student: { class: { establishmentId } } } + : {}, + include: { student: { include: { class: true } } }, + orderBy: { createdAt: "desc" }, + }); + + return ( +
+

Attribuer une instance

+ +
+ ); +} diff --git a/server/app/dashboard/instances/page.tsx b/server/app/dashboard/instances/page.tsx new file mode 100644 index 0000000..6bc4615 --- /dev/null +++ b/server/app/dashboard/instances/page.tsx @@ -0,0 +1,77 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth-config"; +import { redirect } from "next/navigation"; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import InstanceActions from "./InstanceActions"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export const dynamic = "force-dynamic"; + +export default async function InstancesPage() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login"); + if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard"); + + const establishmentId = session.user.establishmentId; + const instances = await prisma.instance.findMany({ + where: establishmentId + ? { node: { student: { class: { establishmentId } } } } + : {}, + include: { node: { include: { student: { include: { class: true } } } }, template: true }, + orderBy: { createdAt: "desc" }, + }); + + return ( +
+
+

Instances

+ + + +
+ + + + + + ID + Template + Nœud + Étudiant + Port + Statut + Actions + + + + {instances.map((inst) => ( + + {inst.id.slice(0, 8)}... + {inst.template.name} + {inst.node.id.slice(0, 8)}... + {inst.node.student ? `${inst.node.student.firstName} ${inst.node.student.lastName}` : "-"} + {inst.port} + + {inst.status} + + + + + + ))} + {instances.length === 0 && ( + + Aucune instance + + )} + +
+
+
+
+ ); +} diff --git a/server/app/dashboard/layout.tsx b/server/app/dashboard/layout.tsx new file mode 100644 index 0000000..f4e8ada --- /dev/null +++ b/server/app/dashboard/layout.tsx @@ -0,0 +1,18 @@ +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth-config"; +import { redirect } from "next/navigation"; +import DashboardNav from "./DashboardNav"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login"); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/server/app/dashboard/nodes/page.tsx b/server/app/dashboard/nodes/page.tsx new file mode 100644 index 0000000..b8557df --- /dev/null +++ b/server/app/dashboard/nodes/page.tsx @@ -0,0 +1,65 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth-config"; +import { redirect } from "next/navigation"; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export const dynamic = "force-dynamic"; + +export default async function NodesPage() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login"); + if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard"); + + const establishmentId = session.user.establishmentId; + const nodes = await prisma.node.findMany({ + where: establishmentId + ? { student: { class: { establishmentId } } } + : {}, + include: { student: { include: { class: true } }, instances: true }, + orderBy: { lastSeen: "desc" }, + }); + + return ( +
+

Nœuds

+ + + + + + ID + Étudiant + IP Tailscale + Statut + Instances + Dernière vue + + + + {nodes.map((n) => ( + + {n.id} + {n.student ? `${n.student.firstName} ${n.student.lastName}` : "-"} + {n.tailscaleIp || "-"} + + {n.status} + + {n.instances.length} + {n.lastSeen ? new Date(n.lastSeen).toLocaleString("fr-FR") : "-"} + + ))} + {nodes.length === 0 && ( + + Aucun nœud + + )} + +
+
+
+
+ ); +} diff --git a/server/app/dashboard/page.tsx b/server/app/dashboard/page.tsx new file mode 100644 index 0000000..f5c8c99 --- /dev/null +++ b/server/app/dashboard/page.tsx @@ -0,0 +1,72 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth-config"; +import { redirect } from "next/navigation"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login"); + + const isSuperadmin = session.user.role === "superadmin"; + const establishmentId = session.user.establishmentId; + + const where = isSuperadmin ? {} : { establishmentId }; + + const nodesCount = await prisma.node.count({ + where: isSuperadmin ? {} : { student: { class: { establishmentId } } }, + }); + const onlineNodes = await prisma.node.count({ + where: isSuperadmin ? { status: "online" } : { status: "online", student: { class: { establishmentId } } }, + }); + const instancesRunning = await prisma.instance.count({ + where: isSuperadmin ? { status: "running" } : { status: "running", node: { student: { class: { establishmentId } } } }, + }); + const studentsCount = await prisma.student.count({ + where: isSuperadmin ? {} : { class: { establishmentId } }, + }); + + return ( +
+

Tableau de bord

+
+ + + Nœuds + + +
{nodesCount}
+ {onlineNodes} en ligne +
+
+ + + Instances actives + + +
{instancesRunning}
+
+
+ + + Étudiants + + +
{studentsCount}
+
+
+ + + Statut + + + Opérationnel + + +
+
+ ); +} diff --git a/server/app/dashboard/students/page.tsx b/server/app/dashboard/students/page.tsx new file mode 100644 index 0000000..1738da0 --- /dev/null +++ b/server/app/dashboard/students/page.tsx @@ -0,0 +1,68 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth-config"; +import { redirect } from "next/navigation"; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export const dynamic = "force-dynamic"; + +export default async function StudentsPage() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login"); + if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard"); + + const establishmentId = session.user.establishmentId; + const students = await prisma.student.findMany({ + where: establishmentId ? { class: { establishmentId } } : {}, + include: { class: true, nodes: true }, + orderBy: { createdAt: "desc" }, + }); + + return ( +
+

Étudiants

+ + + + + + Nom + Classe + Email + Code activation + Nœud + + + + {students.map((s) => { + const node = s.nodes[0]; + return ( + + {s.firstName} {s.lastName} + {s.class.name} + {s.email} + {s.activationCode || "-"} + + {node ? ( + {node.status} + ) : ( + Non lié + )} + + + ); + })} + {students.length === 0 && ( + + Aucun étudiant + + )} + +
+
+
+
+ ); +} diff --git a/server/app/dashboard/templates/page.tsx b/server/app/dashboard/templates/page.tsx new file mode 100644 index 0000000..fcb107a --- /dev/null +++ b/server/app/dashboard/templates/page.tsx @@ -0,0 +1,61 @@ +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth-config"; +import { redirect } from "next/navigation"; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export const dynamic = "force-dynamic"; + +export default async function TemplatesPage() { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/login"); + + const establishmentId = session.user.establishmentId; + const templates = await prisma.template.findMany({ + where: { OR: [{ isPublic: true }, ...(establishmentId ? [{ establishmentId }] : [])] }, + orderBy: { createdAt: "desc" }, + }); + + return ( +
+

Templates

+ + + + + + Nom + Type + Image Docker + Visibilité + Créé par + + + + {templates.map((t) => ( + + {t.name} + + {t.type} + + {t.dockerImage} + + {t.isPublic ? "Public" : "Privé"} + + {t.createdBy} + + ))} + {templates.length === 0 && ( + + Aucun template + + )} + +
+
+
+
+ ); +} diff --git a/server/app/globals.css b/server/app/globals.css new file mode 100644 index 0000000..4e5670c --- /dev/null +++ b/server/app/globals.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/server/app/layout.tsx b/server/app/layout.tsx new file mode 100644 index 0000000..98c9549 --- /dev/null +++ b/server/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "EduBox V2", + description: "Plateforme de gestion d'instances pour l'enseignement BTS", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/server/app/page.tsx b/server/app/page.tsx new file mode 100644 index 0000000..28c5ca1 --- /dev/null +++ b/server/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function Home() { + redirect('/dashboard') +} diff --git a/server/app/superadmin/establishments/page.tsx b/server/app/superadmin/establishments/page.tsx new file mode 100644 index 0000000..16e56e0 --- /dev/null +++ b/server/app/superadmin/establishments/page.tsx @@ -0,0 +1,58 @@ +import { prisma } from "@/lib/prisma"; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export const dynamic = "force-dynamic"; + +export default async function EstablishmentsPage() { + const establishments = await prisma.establishment.findMany({ + include: { subscription: true, _count: { select: { users: true, classes: true } } }, + orderBy: { createdAt: "desc" }, + }); + + return ( +
+

Établissements

+ + + + + + Nom + Slug + Plan + Statut + Utilisateurs + Classes + Expiration + + + + {establishments.map((e) => ( + + {e.name} + {e.slug} + + {e.subscription?.plan || "-"} + + + {e.subscription?.status || "-"} + + {e._count.users} + {e._count.classes} + {e.subscription?.expiresAt ? new Date(e.subscription.expiresAt).toLocaleDateString("fr-FR") : "-"} + + ))} + {establishments.length === 0 && ( + + Aucun établissement + + )} + +
+
+
+
+ ); +} diff --git a/server/app/superadmin/layout.tsx b/server/app/superadmin/layout.tsx new file mode 100644 index 0000000..7b0a2f6 --- /dev/null +++ b/server/app/superadmin/layout.tsx @@ -0,0 +1,11 @@ +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth-config"; +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default async function SuperAdminLayout({ children }: { children: React.ReactNode }) { + const session = await getServerSession(authOptions); + if (!session?.user || session.user.role !== "superadmin") redirect("/dashboard"); + return <>{children}; +} diff --git a/server/app/superadmin/page.tsx b/server/app/superadmin/page.tsx new file mode 100644 index 0000000..0b3a328 --- /dev/null +++ b/server/app/superadmin/page.tsx @@ -0,0 +1,46 @@ +import { prisma } from "@/lib/prisma"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export const dynamic = "force-dynamic"; + +export default async function SuperAdminPage() { + const establishments = await prisma.establishment.count(); + const users = await prisma.user.count(); + const students = await prisma.student.count(); + const nodesOnline = await prisma.node.count({ where: { status: "online" } }); + const instancesRunning = await prisma.instance.count({ where: { status: "running" } }); + const activeSubs = await prisma.subscription.count({ where: { status: "active" } }); + + return ( +
+

Super Admin — Vue globale

+
+ + Établissements +
{establishments}
+
+ + Utilisateurs +
{users}
+
+ + Étudiants +
{students}
+
+ + Nœuds en ligne +
{nodesOnline}
+
+ + Instances running +
{instancesRunning}
+
+ + Abonnements actifs +
{activeSubs}
+
+
+
+ ); +} diff --git a/server/components/ui/badge.tsx b/server/components/ui/badge.tsx new file mode 100644 index 0000000..f353b48 --- /dev/null +++ b/server/components/ui/badge.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +interface BadgeProps extends React.HTMLAttributes { + variant?: "default" | "secondary" | "destructive" | "outline" | "success"; +} + +function Badge({ className, variant = "default", ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge }; diff --git a/server/components/ui/button.tsx b/server/components/ui/button.tsx new file mode 100644 index 0000000..ec600d3 --- /dev/null +++ b/server/components/ui/button.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: "default" | "outline" | "ghost" | "destructive"; + size?: "default" | "sm" | "lg"; + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant = "default", size = "default", asChild = false, children, ...props }, ref) => { + const Comp = asChild ? "span" : "button"; + return ( + + {children} + + ); + } +); +Button.displayName = "Button"; + +export { Button }; diff --git a/server/components/ui/card.tsx b/server/components/ui/card.tsx new file mode 100644 index 0000000..c7b0d56 --- /dev/null +++ b/server/components/ui/card.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardContent = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = "CardContent"; + +export { Card, CardHeader, CardTitle, CardContent }; diff --git a/server/components/ui/dialog.tsx b/server/components/ui/dialog.tsx new file mode 100644 index 0000000..38d8e7c --- /dev/null +++ b/server/components/ui/dialog.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Dialog = ({ open, onOpenChange, children }: { open?: boolean; onOpenChange?: (v: boolean) => void; children: React.ReactNode }) => { + if (!open) return null; + return ( +
onOpenChange?.(false)}> +
e.stopPropagation()}> + {children} +
+
+ ); +}; + +const DialogContent = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +DialogContent.displayName = "DialogContent"; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); + +const DialogTitle = React.forwardRef>(({ className, ...props }, ref) => ( +

+)); +DialogTitle.displayName = "DialogTitle"; + +export { Dialog, DialogContent, DialogHeader, DialogTitle }; diff --git a/server/components/ui/input.tsx b/server/components/ui/input.tsx new file mode 100644 index 0000000..d43e89a --- /dev/null +++ b/server/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; diff --git a/server/components/ui/select.tsx b/server/components/ui/select.tsx new file mode 100644 index 0000000..5711ef4 --- /dev/null +++ b/server/components/ui/select.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Select = React.forwardRef>( + ({ className, ...props }, ref) => { + return ( +