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:
@@ -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
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user