feat(agent): amélioration UI étudiante - instances, statut et lien site

This commit is contained in:
root
2026-06-12 18:36:31 +00:00
parent 479a8de858
commit 7ecc2abe0a
4 changed files with 259 additions and 36 deletions
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
type InstanceInfo struct {
ID string `json:"id"`
TemplateName string `json:"templateName,omitempty"`
Port int `json:"port"`
Status string `json:"status"`
}
func instancesFile(dataDir string) string {
return filepath.Join(dataDir, "instances.json")
}
func loadInstances(dataDir string) (map[string]*InstanceInfo, error) {
f := instancesFile(dataDir)
data, err := os.ReadFile(f)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]*InstanceInfo), nil
}
return nil, err
}
var list []*InstanceInfo
if err := json.Unmarshal(data, &list); err != nil {
return nil, err
}
m := make(map[string]*InstanceInfo)
for _, inst := range list {
m[inst.ID] = inst
}
return m, nil
}
func saveInstances(dataDir string, instances map[string]*InstanceInfo) error {
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
list := make([]*InstanceInfo, 0, len(instances))
for _, inst := range instances {
list = append(list, inst)
}
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
return os.WriteFile(instancesFile(dataDir), data, 0644)
}
func upsertInstance(dataDir string, inst *InstanceInfo) error {
instances, err := loadInstances(dataDir)
if err != nil {
return err
}
instances[inst.ID] = inst
return saveInstances(dataDir, instances)
}
func removeInstance(dataDir, instanceID string) error {
instances, err := loadInstances(dataDir)
if err != nil {
return err
}
delete(instances, instanceID)
return saveInstances(dataDir, instances)
}
func instanceURL(inst *InstanceInfo) string {
return fmt.Sprintf("http://localhost:%d", inst.Port)
}
func getInstanceStatus(dataDir, instanceID string) string {
dir := instanceDir(dataDir, instanceID)
composeFile := filepath.Join(dir, "docker-compose.yml")
if _, err := os.Stat(composeFile); err != nil {
return "stopped"
}
engine := getContainerEngine()
cmd := exec.Command(engine, "compose", "-f", composeFile, "ps", "--format", "json")
out, err := cmd.Output()
if err != nil {
return "error"
}
outStr := strings.TrimSpace(string(out))
if outStr == "" || outStr == "[]" {
return "stopped"
}
// Docker compose JSON output can be a single object or an array
if strings.HasPrefix(outStr, "[") {
var containers []map[string]interface{}
if err := json.Unmarshal(out, &containers); err != nil {
return "error"
}
for _, c := range containers {
state, _ := c["State"].(string)
if state == "running" {
return "running"
}
}
return "stopped"
}
var c map[string]interface{}
if err := json.Unmarshal(out, &c); err != nil {
return "error"
}
state, _ := c["State"].(string)
if state == "running" {
return "running"
}
return "stopped"
}
+18 -9
View File
@@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -81,17 +79,28 @@ func startUI(dataDir, nodeID, serverAddr string) {
} }
func listInstances(dataDir string, conn *websocket.Conn) { func listInstances(dataDir string, conn *websocket.Conn) {
dir := filepath.Join(dataDir, "instances") instances, err := loadInstances(dataDir)
entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
log.Printf("loadInstances error: %v", err)
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": []interface{}{}}) conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": []interface{}{}})
return return
} }
var instances []map[string]interface{}
for _, e := range entries { var list []map[string]interface{}
if e.IsDir() { for _, inst := range instances {
instances = append(instances, map[string]interface{}{"id": e.Name()}) status := getInstanceStatus(dataDir, inst.ID)
if status != inst.Status {
inst.Status = status
_ = upsertInstance(dataDir, inst)
} }
list = append(list, map[string]interface{}{
"id": inst.ID,
"templateName": inst.TemplateName,
"port": inst.Port,
"status": inst.Status,
"url": instanceURL(inst),
})
} }
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": instances})
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list})
} }
+91 -24
View File
@@ -3,32 +3,54 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>EduBox Agent</title> <title>EduBox Agent</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style> <style>
body { font-family: system-ui, sans-serif; background: #f8fafc; margin: 0; padding: 2rem; } * { box-sizing: border-box; }
.card { background: white; border-radius: 8px; padding: 1.5rem; max-width: 400px; margin: 2rem auto; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f1f5f9; margin: 0; padding: 2rem; color: #1e293b; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #1e293b; } .container { max-width: 640px; margin: 0 auto; }
input { width: 100%; padding: 0.5rem; border: 1px solid #cbd5e1; border-radius: 4px; margin-bottom: 0.75rem; box-sizing: border-box; } .card { background: white; border-radius: 12px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 1rem; }
button { width: 100%; padding: 0.6rem; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; } h1 { font-size: 1.5rem; margin: 0 0 1rem; }
h2 { font-size: 1.125rem; margin: 0 0 1rem; }
input { width: 100%; padding: 0.6rem; border: 1px solid #cbd5e1; border-radius: 8px; margin-bottom: 0.75rem; font-size: 1rem; }
button { width: 100%; padding: 0.7rem; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 1rem; }
button:hover { background: #1d4ed8; } button:hover { background: #1d4ed8; }
.status { margin-top: 1rem; font-size: 0.875rem; } .status { margin-top: 0.75rem; font-size: 0.9rem; min-height: 1.2rem; }
.success { color: #16a34a; } .success { color: #16a34a; }
.error { color: #dc2626; } .error { color: #dc2626; }
.instance-list { margin-top: 1rem; } .info { color: #64748b; }
.instance-item { padding: 0.5rem; border: 1px solid #e2e8f0; border-radius: 4px; margin-bottom: 0.5rem; } .instance-list { display: flex; flex-direction: column; gap: 0.75rem; }
.instance-item { border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.instance-meta { flex: 1; }
.instance-name { font-weight: 600; margin-bottom: 0.25rem; }
.instance-port { font-size: 0.85rem; color: #64748b; }
.badge { display: inline-block; padding: 0.25rem 0.6rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
.badge-running { background: #dcfce7; color: #166534; }
.badge-starting { background: #fef9c3; color: #854d0e; }
.badge-stopped { background: #f1f5f9; color: #475569; }
.badge-error { background: #fee2e2; color: #991b1b; }
.instance-link { font-size: 0.875rem; color: #2563eb; text-decoration: none; font-weight: 500; }
.instance-link:hover { text-decoration: underline; }
.empty { text-align: center; color: #64748b; padding: 1rem 0; }
</style> </style>
</head> </head>
<body> <body>
<div class="container">
<div class="card"> <div class="card">
<h1>EduBox Agent</h1> <h1>EduBox Agent</h1>
<div id="main"> <div id="main">
<p>Chargement...</p> <p class="info">Connexion en cours...</p>
</div>
</div>
<div id="instances-card" class="card" style="display:none;">
<h2>Mes instances</h2>
<div id="instances" class="instance-list"></div>
</div> </div>
</div> </div>
<script> <script>
const ws = new WebSocket('ws://' + location.host + '/ws'); const ws = new WebSocket('ws://' + location.host + '/ws');
const main = document.getElementById('main'); const main = document.getElementById('main');
const instancesCard = document.getElementById('instances-card');
const instancesContainer = document.getElementById('instances');
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify({action: 'check'})); ws.send(JSON.stringify({action: 'check'}));
@@ -36,35 +58,42 @@
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
if (msg.action === 'not_activated') { if (msg.action === 'not_activated') {
main.innerHTML = ` main.innerHTML = `
<p>Veuillez entrer votre code d'activation (6 caractères) :</p> <p>Entre ton code d'activation (6 caractères) :</p>
<input type="text" id="code" maxlength="6" placeholder="XXXXXX"> <input type="text" id="code" maxlength="6" placeholder="XXXXXX" onkeydown="if(event.key==='Enter')activate()">
<button onclick="activate()">Activer</button> <button onclick="activate()">Activer</button>
<div id="status" class="status"></div> <div id="status" class="status"></div>
`; `;
} else if (msg.action === 'activated') { } else if (msg.action === 'activated') {
main.innerHTML = ` main.innerHTML = `
<p class="success">✅ Activé : <strong>${msg.studentName}</strong></p> <p class="success">✅ Activé : <strong>${escapeHtml(msg.studentName || '')}</strong></p>
<div class="instance-list" id="instances"></div> <p class="info">Tes instances apparaissent ci-dessous.</p>
`; `;
instancesCard.style.display = 'block';
ws.send(JSON.stringify({action: 'instances'})); ws.send(JSON.stringify({action: 'instances'}));
} else if (msg.action === 'activation_failed') { } else if (msg.action === 'activation_failed') {
document.getElementById('status').innerHTML = `<span class="error">❌ ${msg.error || 'Code invalide'}</span>`; const status = document.getElementById('status');
if (status) status.innerHTML = `<span class="error">❌ ${escapeHtml(msg.error || 'Code invalide')}</span>`;
} else if (msg.action === 'instances_list') { } else if (msg.action === 'instances_list') {
const container = document.getElementById('instances'); renderInstances(msg.instances);
if (!container) return; } else if (msg.action === 'instances_updated') {
if (msg.instances.length === 0) { ws.send(JSON.stringify({action: 'instances'}));
container.innerHTML = '<p class="status">Aucune instance assignée.</p>';
} else {
container.innerHTML = '<p class="status"><strong>Instances :</strong></p>' +
msg.instances.map(i => `<div class="instance-item">${i.id}</div>`).join('');
}
} }
}; };
ws.onclose = () => {
main.innerHTML = `<p class="error">Déconnecté. Recharge la page.</p>`;
};
ws.onerror = (err) => {
main.innerHTML = `<p class="error">Erreur de connexion.</p>`;
};
function activate() { function activate() {
const code = document.getElementById('code').value.trim().toUpperCase(); const input = document.getElementById('code');
const code = input.value.trim().toUpperCase();
if (code.length !== 6) { if (code.length !== 6) {
document.getElementById('status').innerHTML = '<span class="error">Le code doit faire 6 caractères.</span>'; document.getElementById('status').innerHTML = '<span class="error">Le code doit faire 6 caractères.</span>';
return; return;
@@ -72,6 +101,44 @@
document.getElementById('status').innerHTML = 'Activation en cours...'; document.getElementById('status').innerHTML = 'Activation en cours...';
ws.send(JSON.stringify({action: 'activate', code})); ws.send(JSON.stringify({action: 'activate', code}));
} }
function renderInstances(instances) {
if (!instances || instances.length === 0) {
instancesContainer.innerHTML = '<p class="empty">Aucune instance assignée.</p>';
return;
}
instancesContainer.innerHTML = instances.map(i => {
const badgeClass = i.status === 'running' ? 'badge-running' :
i.status === 'starting' ? 'badge-starting' :
i.status === 'error' ? 'badge-error' : 'badge-stopped';
const link = i.status === 'running' && i.url
? `<a class="instance-link" href="${escapeHtml(i.url)}" target="_blank" rel="noopener">Ouvrir le site →</a>`
: '';
const name = escapeHtml(i.templateName || i.id);
return `
<div class="instance-item">
<div class="instance-meta">
<div class="instance-name">${name}</div>
<div class="instance-port">Port ${i.port || '-'}</div>
</div>
<div style="text-align:right;">
<span class="badge ${badgeClass}">${i.status || 'stopped'}</span>
${link ? '<div style="margin-top:0.4rem;">' + link + '</div>' : ''}
</div>
</div>
`;
}).join('');
}
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
</script> </script>
</body> </body>
</html> </html>
+24
View File
@@ -172,35 +172,59 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
}) })
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 := upsertInstance(dataDir, &InstanceInfo{
ID: msg.InstanceID,
TemplateName: msg.Type,
Port: msg.Port,
Status: "starting",
}); err != nil {
log.Printf("upsertInstance error: %v", err)
}
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)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(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)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()}) sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return return
} }
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port}) sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
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 {
log.Printf("dockerComposeDown error: %v", err) log.Printf("dockerComposeDown error: %v", err)
} }
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
inst[msg.InstanceID].Status = "stopped"
_ = saveInstances(dataDir, inst)
}
notifyUI(map[string]interface{}{"action": "instances_updated"})
case "reset": case "reset":
log.Printf("Reset instance %s", msg.InstanceID) log.Printf("Reset instance %s", msg.InstanceID)
dockerComposeRm(dataDir, msg.InstanceID) dockerComposeRm(dataDir, msg.InstanceID)
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)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "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)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()}) sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
return return
} }
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port}) sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
notifyUI(map[string]interface{}{"action": "instances_updated"})
default: default:
log.Printf("Unknown action: %s", msg.Action) log.Printf("Unknown action: %s", msg.Action)
} }