feat(agent): v0.3.9 sync, UI details, self-update, centralized version

- Add agent/server startup sync (sync/sync_response)
- Centralize agent version in agent/VERSION + expose /api/agent/version
- Display agent version, nodeId and server version in local UI
- Add agent self-update detection/download/restart via helper scripts
- Run start/stop/delete/reset handlers in goroutines to avoid WebSocket blocking
- Update dashboard download links and SUIVI_VPN_ONDEMAND.md
- Document Podman stays installer-managed, not agent-updated
This commit is contained in:
EduBox Dev
2026-06-27 21:11:20 +00:00
parent cf8b66340a
commit e946b22a42
14 changed files with 613 additions and 59 deletions
+55
View File
@@ -0,0 +1,55 @@
import fs from "fs";
import path from "path";
const BIN_NAME = "studioE5-agent";
function findVersionFile(): string | null {
// Try a few common paths relative to the server workspace and Next.js build output.
const candidates = [
path.join(process.cwd(), "..", "agent", "VERSION"),
path.join(process.cwd(), "..", "..", "agent", "VERSION"),
path.join(process.cwd(), "agent", "VERSION"),
path.join(__dirname, "..", "..", "..", "agent", "VERSION"),
path.join(__dirname, "..", "..", "agent", "VERSION"),
"/app/agent-version",
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
export function getAgentVersion(): string {
const versionFile = findVersionFile();
if (versionFile) {
return fs.readFileSync(versionFile, "utf-8").trim();
}
// Fallback used when the agent workspace is not mounted (should not happen).
return "0.3.9";
}
export interface AgentDownloadUrls {
windows: string;
windowsZip: string;
linux: string;
mac: string;
}
export function getAgentDownloadUrls(version: string): AgentDownloadUrls {
return {
windows: `/${BIN_NAME}-v${version}.exe`,
windowsZip: `/${BIN_NAME}-v${version}-windows.zip`,
linux: `/${BIN_NAME}-v${version}`,
mac: `/${BIN_NAME}-v${version}-mac`,
};
}
export function getAgentVersionInfo() {
const version = getAgentVersion();
return {
version,
downloadUrls: getAgentDownloadUrls(version),
};
}
+60 -1
View File
@@ -3,6 +3,7 @@ import { randomBytes } from "crypto";
import type { IncomingMessage } from "http";
import { prisma } from "./prisma";
import { createEphemeralPreAuthKey, getHeadscaleUserId } from "./headscale";
import { getAgentVersion } from "./agent-version";
interface NodeMessage {
action: string;
@@ -12,10 +13,16 @@ interface NodeMessage {
type?: string;
port?: number;
composeConfig?: string;
initScript?: string;
studentName?: string;
error?: string;
tailscaleIp?: string;
token?: string;
serverVersion?: string;
instances?: Array<{ id: string; status: string; port: number; templateName?: string }>;
toStart?: Array<{ id: string; type: string; port: number; composeConfig?: string; initScript?: string }>;
toDelete?: string[];
toStop?: string[];
}
const nodes = new Map<string, WebSocket>();
@@ -126,7 +133,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
create: { id, status: "online", lastSeen: new Date() },
});
}
ws.send(JSON.stringify({ action: "registered" }));
ws.send(JSON.stringify({ action: "registered", serverVersion: getAgentVersion() }));
return;
}
@@ -263,6 +270,58 @@ export function initWebSocketServer(wss: WebSocketServer) {
return;
}
if (msg.action === "sync" && msg.instances) {
const serverInstances = await prisma.instance.findMany({
where: { nodeId },
include: { template: true },
});
const localIds = new Set(msg.instances.map((i) => i.id));
const serverIds = new Set(serverInstances.map((i) => i.id));
const toDelete = msg.instances
.filter((i) => !serverIds.has(i.id))
.map((i) => i.id);
const toStop = msg.instances
.filter((i) => {
const server = serverInstances.find((s) => s.id === i.id);
return server && server.status === "stopped" && i.status === "running";
})
.map((i) => i.id);
const toStart = serverInstances
.filter((s) => !localIds.has(s.id))
.map((s) => ({
id: s.id,
type: s.template.type,
port: s.port,
composeConfig: s.template.composeConfig,
initScript: s.template.initScript ?? undefined,
}));
console.log(
"[WS] Sync for",
nodeId,
"- toStart:",
toStart.length,
"toDelete:",
toDelete.length,
"toStop:",
toStop.length
);
ws.send(
JSON.stringify({
action: "sync_response",
toStart,
toDelete,
toStop,
})
);
return;
}
if (msg.action === "instance_started" && msg.instanceId) {
const { count } = await prisma.instance.updateMany({
where: { id: msg.instanceId },