Add panel feature updates across API, daemon, and web

This commit is contained in:
2026-03-02 21:53:54 +00:00
parent 6b463c2b1a
commit afc64b83c1
49 changed files with 7040 additions and 305 deletions
+117 -30
View File
@@ -20,6 +20,20 @@ declare module 'fastify' {
}
type ConsolePermission = 'console.read' | 'console.write';
type ConsoleCommandAck = {
requestId: string | null;
ok: boolean;
error?: string;
};
interface SharedConsoleStream {
handle: DaemonConsoleStreamHandle;
subscribers: number;
}
function roomForServer(serverId: string): string {
return `server:console:${serverId}`;
}
export default fp(async (app: FastifyInstance) => {
const io = new SocketIOServer(app.server, {
@@ -32,7 +46,16 @@ export default fp(async (app: FastifyInstance) => {
app.decorate('io', io);
const activeStreams = new Map<string, DaemonConsoleStreamHandle>();
const serverStreams = new Map<string, SharedConsoleStream>();
const socketSubscriptions = new Map<string, string>();
const clearServerSubscriptions = (serverId: string) => {
for (const [socketId, subscribedServerId] of socketSubscriptions.entries()) {
if (subscribedServerId === serverId) {
socketSubscriptions.delete(socketId);
}
}
};
io.use((socket, next) => {
const token = typeof socket.handshake.auth?.token === 'string'
@@ -61,10 +84,20 @@ export default fp(async (app: FastifyInstance) => {
io.on('connection', (socket) => {
const cleanupSocketStream = () => {
const current = activeStreams.get(socket.id);
if (!current) return;
current.close();
activeStreams.delete(socket.id);
const subscribedServerId = socketSubscriptions.get(socket.id);
if (!subscribedServerId) return;
socketSubscriptions.delete(socket.id);
socket.leave(roomForServer(subscribedServerId));
const shared = serverStreams.get(subscribedServerId);
if (!shared) return;
shared.subscribers = Math.max(0, shared.subscribers - 1);
if (shared.subscribers === 0) {
shared.handle.close();
serverStreams.delete(subscribedServerId);
}
};
socket.on('server:console:join', async (payload: unknown) => {
@@ -94,34 +127,63 @@ export default fp(async (app: FastifyInstance) => {
return;
}
const previousSubscription = socketSubscriptions.get(socket.id);
if (previousSubscription === serverId) {
return;
}
cleanupSocketStream();
socket.join(roomForServer(serverId));
try {
const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid);
streamHandle.stream.on('data', (output) => {
socket.emit('server:console:output', { line: output.line });
});
streamHandle.stream.on('end', () => {
activeStreams.delete(socket.id);
socket.emit('server:console:output', { line: '[console] Stream ended' });
});
streamHandle.stream.on('error', (error) => {
activeStreams.delete(socket.id);
let shared = serverStreams.get(serverId);
if (!shared) {
try {
const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid);
const room = roomForServer(serverId);
streamHandle.stream.on('data', (output) => {
io.to(room).emit('server:console:output', { line: output.line });
});
streamHandle.stream.on('end', () => {
const current = serverStreams.get(serverId);
if (current?.handle !== streamHandle) return;
serverStreams.delete(serverId);
clearServerSubscriptions(serverId);
io.to(room).emit('server:console:output', { line: '[console] Stream ended' });
io.in(room).socketsLeave(room);
});
streamHandle.stream.on('error', (error) => {
const current = serverStreams.get(serverId);
if (current?.handle !== streamHandle) return;
serverStreams.delete(serverId);
clearServerSubscriptions(serverId);
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid },
'Console stream failed',
);
io.to(room).emit('server:console:output', { line: '[error] Console stream failed' });
io.in(room).socketsLeave(room);
});
shared = {
handle: streamHandle,
subscribers: 0,
};
serverStreams.set(serverId, shared);
} catch (error) {
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
'Console stream failed',
'Failed to open console stream',
);
socket.emit('server:console:output', { line: '[error] Console stream failed' });
});
activeStreams.set(socket.id, streamHandle);
} catch (error) {
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
'Failed to open console stream',
);
socket.emit('server:console:output', { line: '[error] Failed to open console stream' });
socket.leave(roomForServer(serverId));
socket.emit('server:console:output', { line: '[error] Failed to open console stream' });
return;
}
}
shared.subscribers += 1;
socketSubscriptions.set(socket.id, serverId);
});
socket.on('server:console:leave', () => {
@@ -133,43 +195,67 @@ export default fp(async (app: FastifyInstance) => {
serverId?: unknown;
orgId?: unknown;
command?: unknown;
requestId?: unknown;
};
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
const command = typeof body.command === 'string' ? body.command.trim() : '';
const requestId = typeof body.requestId === 'string' && body.requestId.trim()
? body.requestId.trim()
: null;
if (!serverId || !orgId || !command) {
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
const ack: ConsoleCommandAck = {
requestId,
ok: false,
error: 'Invalid command payload',
};
socket.emit('server:console:command:ack', ack);
return;
}
const user = (socket.data as { user?: AccessTokenPayload }).user;
if (!user) {
socket.emit('server:console:output', { line: '[error] Unauthorized' });
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Unauthorized' };
socket.emit('server:console:command:ack', ack);
return;
}
const server = await getServerContext(app, serverId, orgId);
if (!server) {
socket.emit('server:console:output', { line: '[error] Server not found' });
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Server not found' };
socket.emit('server:console:command:ack', ack);
return;
}
const allowed = await hasConsolePermission(app, user, orgId, 'console.write');
if (!allowed) {
socket.emit('server:console:output', { line: '[error] Missing permission: console.write' });
const ack: ConsoleCommandAck = {
requestId,
ok: false,
error: 'Missing permission: console.write',
};
socket.emit('server:console:command:ack', ack);
return;
}
try {
await daemonSendCommand(server.node, server.serverUuid, command);
const ack: ConsoleCommandAck = { requestId, ok: true };
socket.emit('server:console:command:ack', ack);
} catch (error) {
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
'Failed to send console command',
);
socket.emit('server:console:output', { line: '[error] Failed to send command' });
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Failed to send command' };
socket.emit('server:console:command:ack', ack);
}
});
@@ -179,10 +265,11 @@ export default fp(async (app: FastifyInstance) => {
});
app.addHook('onClose', async () => {
for (const handle of activeStreams.values()) {
handle.close();
for (const stream of serverStreams.values()) {
stream.handle.close();
}
activeStreams.clear();
serverStreams.clear();
socketSubscriptions.clear();
await new Promise<void>((resolve) => {
io.close(() => resolve());