fix: activation via connexion WS principale, cascade delete student→nodes, lien fiche étudiant

- agent/websocket.go: expose sendMessage() + notifyUI() pour broadcaster
  les résultats d'activation à tous les clients UI connectés
- agent/ui.go: supprime forwardActivation(), utilise sendMessage() sur
  la connexion WS principale au lieu d'une connexion temporaire
- agent/activation.go: ajoute os.MkdirAll avant l'écriture d'activation.json
- server/prisma/schema.prisma: onDelete Cascade sur Node→Student
- server/app/dashboard/students/page.tsx: nom cliquable vers fiche détail
- server/app/dashboard/students/[id]/actions.ts: deleteMany → delete
This commit is contained in:
root
2026-06-06 22:41:15 +00:00
parent 349c8d0e2a
commit 479a8de858
10 changed files with 130 additions and 41 deletions
+3
View File
@@ -29,6 +29,9 @@ func loadActivation(dataDir string) (*Activation, error) {
} }
func saveActivation(dataDir string, a *Activation) error { func saveActivation(dataDir string, a *Activation) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
f := activationFile(dataDir) f := activationFile(dataDir)
data, err := json.MarshalIndent(a, "", " ") data, err := json.MarshalIndent(a, "", " ")
if err != nil { if err != nil {
+21 -28
View File
@@ -29,13 +29,27 @@ func startUI(dataDir, nodeID, serverAddr string) {
return return
} }
defer conn.Close() defer conn.Close()
log.Printf("UI client connected from %s", r.RemoteAddr)
// Register notifier to forward activation results from main WS to this UI connection
notifierID := registerUINotifier(func(msg map[string]interface{}) {
log.Printf("UI notifier forwarding to browser: %+v", msg)
if err := conn.WriteJSON(msg); err != nil {
log.Printf("UI notify error: %v", err)
} else {
log.Printf("UI notifier sent successfully")
}
})
defer unregisterUINotifier(notifierID)
for { for {
var msg map[string]interface{} var msg map[string]interface{}
if err := conn.ReadJSON(&msg); err != nil { if err := conn.ReadJSON(&msg); err != nil {
log.Printf("UI client disconnected: %v", err)
break break
} }
action, _ := msg["action"].(string) action, _ := msg["action"].(string)
log.Printf("UI received action: %s", action)
switch action { switch action {
case "check": case "check":
act, err := loadActivation(dataDir) act, err := loadActivation(dataDir)
@@ -46,10 +60,13 @@ func startUI(dataDir, nodeID, serverAddr string) {
} }
case "activate": case "activate":
code, _ := msg["code"].(string) code, _ := msg["code"].(string)
// Forward to server WS log.Printf("UI handling activate with code: %s", code)
go func() { if err := sendMessage(WSMessage{Action: "activate", NodeID: nodeID, Code: code}); err != nil {
forwardActivation(serverAddr, nodeID, code, conn) log.Printf("UI sendMessage failed: %v", err)
}() conn.WriteJSON(map[string]interface{}{"action": "activation_failed", "error": err.Error()})
} else {
log.Printf("UI sendMessage succeeded, waiting for server response...")
}
case "instances": case "instances":
listInstances(dataDir, conn) listInstances(dataDir, conn)
} }
@@ -63,30 +80,6 @@ func startUI(dataDir, nodeID, serverAddr string) {
} }
} }
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) { func listInstances(dataDir string, conn *websocket.Conn) {
dir := filepath.Join(dataDir, "instances") dir := filepath.Join(dataDir, "instances")
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
+90 -9
View File
@@ -1,7 +1,9 @@
package main package main
import ( import (
"fmt"
"log" "log"
"sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -20,6 +22,61 @@ type WSMessage struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
var (
mainConn *websocket.Conn
mainConnMu sync.Mutex
)
func sendMessage(msg WSMessage) error {
mainConnMu.Lock()
defer mainConnMu.Unlock()
if mainConn == nil {
return fmt.Errorf("not connected to server")
}
log.Printf("sendMessage: sending %+v", msg)
return mainConn.WriteJSON(msg)
}
// UI notifier system: broadcast activation results to all connected UI clients
type uiNotifier func(msg map[string]interface{})
var (
uiNotifiers = make(map[int]uiNotifier)
uiNotifiersMu sync.Mutex
uiNotifierID int
)
func registerUINotifier(fn uiNotifier) int {
uiNotifiersMu.Lock()
defer uiNotifiersMu.Unlock()
id := uiNotifierID
uiNotifierID++
uiNotifiers[id] = fn
log.Printf("registerUINotifier: registered ID %d (total: %d)", id, len(uiNotifiers))
return id
}
func unregisterUINotifier(id int) {
uiNotifiersMu.Lock()
defer uiNotifiersMu.Unlock()
delete(uiNotifiers, id)
log.Printf("unregisterUINotifier: removed ID %d (total: %d)", id, len(uiNotifiers))
}
func notifyUI(msg map[string]interface{}) {
uiNotifiersMu.Lock()
notifiers := make([]uiNotifier, 0, len(uiNotifiers))
for _, fn := range uiNotifiers {
notifiers = append(notifiers, fn)
}
uiNotifiersMu.Unlock()
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
for _, fn := range notifiers {
go fn(msg)
}
}
func startWebSocket(serverAddr, nodeID, dataDir string) { func startWebSocket(serverAddr, nodeID, dataDir string) {
for { for {
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil) conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
@@ -31,10 +88,17 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
log.Printf("WS connected to %s", serverAddr) log.Printf("WS connected to %s", serverAddr)
mainConnMu.Lock()
mainConn = conn
mainConnMu.Unlock()
// Register // Register
if err := conn.WriteJSON(WSMessage{Action: "register", NodeID: nodeID}); err != nil { if err := conn.WriteJSON(WSMessage{Action: "register", NodeID: nodeID}); err != nil {
log.Printf("WS register error: %v", err) log.Printf("WS register error: %v", err)
conn.Close() conn.Close()
mainConnMu.Lock()
mainConn = nil
mainConnMu.Unlock()
continue continue
} }
@@ -54,7 +118,7 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
if err := conn.WriteJSON(WSMessage{Action: "heartbeat", NodeID: nodeID}); err != nil { if err := sendMessage(WSMessage{Action: "heartbeat", NodeID: nodeID}); err != nil {
return return
} }
case <-done: case <-done:
@@ -70,11 +134,15 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
log.Printf("WS read error: %v", err) log.Printf("WS read error: %v", err)
break break
} }
log.Printf("WS received from server: action=%s", msg.Action)
handleMessage(conn, msg, dataDir, nodeID) handleMessage(conn, msg, dataDir, nodeID)
} }
close(done) close(done)
conn.Close() conn.Close()
mainConnMu.Lock()
mainConn = nil
mainConnMu.Unlock()
log.Println("WS disconnected, reconnecting in 5s...") log.Println("WS disconnected, reconnecting in 5s...")
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
} }
@@ -82,26 +150,39 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) { func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
switch msg.Action { switch msg.Action {
case "activate": case "activated":
// handled by UI, but server can also push activation response log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
if msg.StudentName != "" { if msg.StudentName != "" {
act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code} act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
saveActivation(dataDir, act) if err := saveActivation(dataDir, act); err != nil {
log.Printf("saveActivation error: %v", err)
} else {
log.Printf("Activated as %s", msg.StudentName) log.Printf("Activated as %s", msg.StudentName)
} }
}
notifyUI(map[string]interface{}{
"action": "activated",
"studentName": msg.StudentName,
})
case "activation_failed":
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
notifyUI(map[string]interface{}{
"action": "activation_failed",
"error": msg.Error,
})
case "start": case "start":
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port) log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil { if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
log.Printf("writeCompose error: %v", err) log.Printf("writeCompose error: %v", err)
conn.WriteJSON(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()}) sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return return
} }
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil { if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err) log.Printf("dockerComposeUp error: %v", err)
conn.WriteJSON(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()}) sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return return
} }
conn.WriteJSON(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port}) sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
case "stop": case "stop":
log.Printf("Stop instance %s", msg.InstanceID) log.Printf("Stop instance %s", msg.InstanceID)
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil { if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
@@ -116,10 +197,10 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
} }
if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil { if err := dockerComposeUp(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeUp error: %v", err) log.Printf("dockerComposeUp error: %v", err)
conn.WriteJSON(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()}) sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return return
} }
conn.WriteJSON(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port}) sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
default: default:
log.Printf("Unknown action: %s", msg.Action) log.Printf("Unknown action: %s", msg.Action)
} }
@@ -23,12 +23,15 @@ export async function deleteStudent(formData: FormData) {
if (!id) return; if (!id) return;
const establishmentId = session.user.establishmentId; const establishmentId = session.user.establishmentId;
await prisma.student.deleteMany({ const student = await prisma.student.findFirst({
where: { where: {
id, id,
class: establishmentId ? { establishmentId } : undefined, class: establishmentId ? { establishmentId } : undefined,
}, },
}); });
if (!student) return;
await prisma.student.delete({ where: { id } });
redirect("/dashboard/students"); redirect("/dashboard/students");
} }
+5 -1
View File
@@ -47,7 +47,11 @@ export default async function StudentsPage() {
const node = s.nodes[0]; const node = s.nodes[0];
return ( return (
<TableRow key={s.id}> <TableRow key={s.id}>
<TableCell className="font-medium">{s.firstName} {s.lastName}</TableCell> <TableCell className="font-medium">
<Link href={`/dashboard/students/${s.id}`} className="hover:underline">
{s.firstName} {s.lastName}
</Link>
</TableCell>
<TableCell>{s.class.name}</TableCell> <TableCell>{s.class.name}</TableCell>
<TableCell>{s.email}</TableCell> <TableCell>{s.email}</TableCell>
<TableCell>{s.activationCode || "-"}</TableCell> <TableCell>{s.activationCode || "-"}</TableCell>
+5
View File
@@ -18,10 +18,12 @@ const nodes = new Map<string, WebSocket>();
export function initWebSocketServer(wss: WebSocketServer) { export function initWebSocketServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket) => { wss.on("connection", (ws: WebSocket) => {
let nodeId: string | null = null; let nodeId: string | null = null;
console.log("[WS] New connection");
ws.on("message", async (raw) => { ws.on("message", async (raw) => {
try { try {
const msg: NodeMessage = JSON.parse(raw.toString()); const msg: NodeMessage = JSON.parse(raw.toString());
console.log("[WS] Received:", msg.action, "from", msg.nodeId || nodeId);
if (msg.action === "register" && msg.nodeId) { if (msg.action === "register" && msg.nodeId) {
nodeId = msg.nodeId; nodeId = msg.nodeId;
@@ -41,6 +43,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
where: { activationCode: msg.code }, where: { activationCode: msg.code },
}); });
if (!student) { if (!student) {
console.log("[WS] Invalid code:", msg.code);
ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" })); ws.send(JSON.stringify({ action: "activation_failed", error: "Invalid code" }));
return; return;
} }
@@ -49,6 +52,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
update: { studentId: student.id, status: "online", lastSeen: new Date() }, update: { studentId: student.id, status: "online", lastSeen: new Date() },
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() }, create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
}); });
console.log("[WS] Activated:", student.firstName, student.lastName, "on", nodeId);
ws.send(JSON.stringify({ action: "activated", studentId: student.id, studentName: `${student.firstName} ${student.lastName}` })); ws.send(JSON.stringify({ action: "activated", studentId: student.id, studentName: `${student.firstName} ${student.lastName}` }));
return; return;
} }
@@ -83,6 +87,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
}); });
ws.on("close", async () => { ws.on("close", async () => {
console.log("[WS] Connection closed for", nodeId);
if (nodeId) { if (nodeId) {
nodes.delete(nodeId); nodes.delete(nodeId);
await prisma.node.upsert({ await prisma.node.upsert({
+1 -1
View File
@@ -63,7 +63,7 @@ model Student {
model Node { model Node {
id String @id id String @id
studentId String? studentId String?
student Student? @relation(fields: [studentId], references: [id], onDelete: SetNull) student Student? @relation(fields: [studentId], references: [id], onDelete: Cascade)
tailscaleIp String? tailscaleIp String?
status String @default("offline") status String @default("offline")
lastSeen DateTime? lastSeen DateTime?
Binary file not shown.
Binary file not shown.
Binary file not shown.