feat(ops-dashboard): stream user service logs to the ops dashboard and resolve service containers for Docker log streaming

This commit is contained in:
2026-03-17 23:39:24 +00:00
parent f63be883ce
commit 6defdb4431
11 changed files with 169 additions and 42 deletions

View File

@@ -1,5 +1,14 @@
# Changelog
## 2026-03-17 - 1.20.0 - feat(ops-dashboard)
stream user service logs to the ops dashboard and resolve service containers for Docker log streaming
- add typed socket support for pushing live user service log entries to the web app
- extend platform log streaming to include running user services with separate dashboard handlers
- fall back from direct container lookup to service-to-container resolution when streaming Docker logs
- update log parsing to preserve timestamps and infer log levels for service log entries
- bump @serve.zone/catalog to ^2.7.0
## 2026-03-17 - 1.19.12 - fix(repo)
no changes to commit

View File

@@ -58,7 +58,7 @@
"@api.global/typedsocket": "^4.1.2",
"@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6",
"@serve.zone/catalog": "^2.6.2"
"@serve.zone/catalog": "^2.7.0"
},
"devDependencies": {
"@git.zone/tsbundle": "^2.9.0",

10
pnpm-lock.yaml generated
View File

@@ -21,8 +21,8 @@ importers:
specifier: ^2.1.6
version: 2.2.3
'@serve.zone/catalog':
specifier: ^2.6.2
version: 2.6.2(@tiptap/pm@2.27.2)
specifier: ^2.7.0
version: 2.7.0(@tiptap/pm@2.27.2)
devDependencies:
'@git.zone/tsbundle':
specifier: ^2.9.0
@@ -839,8 +839,8 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@serve.zone/catalog@2.6.2':
resolution: {integrity: sha512-1XPdgkqjx80r3mjE03QOex0r48jz2SzQ8lwz/VBvPtwgJYH0DO5TBuMSgT56YeQ1c/e2vVpqdXIicbcJoreBYw==}
'@serve.zone/catalog@2.7.0':
resolution: {integrity: sha512-BSfLi9BZE5wvu5Dxh0p/mM9bE+9lf35PGHRZ1Cw/+YpWxOfIFPTZKkBz2OUn3yctWw+V7l1VBBYuLX1bVCKFfA==}
'@tempfix/idb@8.0.3':
resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==}
@@ -3477,7 +3477,7 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@serve.zone/catalog@2.6.2(@tiptap/pm@2.27.2)':
'@serve.zone/catalog@2.7.0(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.48.5(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.1

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.19.12',
version: '1.20.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}

View File

@@ -1019,7 +1019,23 @@ export class OneboxDockerManager {
callback: (line: string, isError: boolean) => void
): Promise<void> {
try {
const container = await this.dockerClient!.getContainerById(containerID);
let container: any = null;
try {
container = await this.dockerClient!.getContainerById(containerID);
} catch {
// Not a direct container ID — try Swarm service lookup
}
if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) {
try {
container = await this.dockerClient!.getContainerById(serviceContainerId);
} catch {
// Service container also not found
}
}
}
if (!container) {
throw new Error(`Container not found: ${containerID}`);

View File

@@ -15,30 +15,54 @@ export class PlatformHandler {
}
/**
* Start streaming logs from all running platform service containers
* Start streaming logs from all running containers (platform + user services)
* and push new entries to connected dashboard clients via TypedSocket
*/
private async startLogStreaming(): Promise<void> {
// Poll for running platform services every 10s and start streams for new ones
const checkAndStream = async () => {
const services = this.opsServerRef.oneboxRef.database.getAllPlatformServices();
for (const service of services) {
// Stream platform service containers
const platformServices = this.opsServerRef.oneboxRef.database.getAllPlatformServices();
for (const service of platformServices) {
if (service.status !== 'running' || !service.containerId) continue;
if (this.activeLogStreams.has(service.type)) continue;
const key = `platform:${service.type}`;
if (this.activeLogStreams.has(key)) continue;
this.activeLogStreams.set(service.type, true);
this.activeLogStreams.set(key, true);
logger.info(`Starting log stream for platform service: ${service.type}`);
try {
await this.opsServerRef.oneboxRef.docker.streamContainerLogs(
service.containerId,
(line: string, isError: boolean) => {
this.pushLogToClients(service.type as interfaces.data.TPlatformServiceType, line, isError);
this.pushPlatformLogToClients(service.type as interfaces.data.TPlatformServiceType, line, isError);
}
);
} catch (err) {
logger.warn(`Log stream failed for ${service.type}: ${(err as Error).message}`);
this.activeLogStreams.delete(service.type);
this.activeLogStreams.delete(key);
}
}
// Stream user service containers
const userServices = this.opsServerRef.oneboxRef.services.listServices();
for (const service of userServices) {
if (service.status !== 'running' || !service.containerID) continue;
const key = `service:${service.name}`;
if (this.activeLogStreams.has(key)) continue;
this.activeLogStreams.set(key, true);
logger.info(`Starting log stream for user service: ${service.name}`);
try {
await this.opsServerRef.oneboxRef.docker.streamContainerLogs(
service.containerID,
(line: string, isError: boolean) => {
this.pushServiceLogToClients(service.name, line, isError);
}
);
} catch (err) {
logger.warn(`Log stream failed for ${service.name}: ${(err as Error).message}`);
this.activeLogStreams.delete(key);
}
}
};
@@ -49,15 +73,7 @@ export class PlatformHandler {
setInterval(() => checkAndStream(), 15000);
}
private pushLogToClients(
serviceType: interfaces.data.TPlatformServiceType,
line: string,
isError: boolean,
): void {
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
if (!typedsocket) return;
// Parse timestamp from Docker log line
private parseLogLine(line: string, isError: boolean): { timestamp: string; level: string; message: string } {
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
const timestamp = tsMatch ? tsMatch[1] : new Date().toISOString();
const message = tsMatch ? tsMatch[2] : line;
@@ -67,21 +83,51 @@ export class PlatformHandler {
: msgLower.includes('warn')
? 'warn'
: 'info';
return { timestamp, level, message };
}
private pushPlatformLogToClients(
serviceType: interfaces.data.TPlatformServiceType,
line: string,
isError: boolean,
): void {
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
if (!typedsocket) return;
const entry = this.parseLogLine(line, isError);
// Find all dashboard clients and push
typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard')
.then((connections: any[]) => {
for (const conn of connections) {
typedsocket.createTypedRequest<interfaces.requests.IReq_PushPlatformServiceLog>(
'pushPlatformServiceLog',
conn,
).fire({
serviceType,
entry: { timestamp, level, message },
}).catch(() => {}); // fire-and-forget
).fire({ serviceType, entry }).catch(() => {});
}
})
.catch(() => {}); // no connections, ignore
.catch(() => {});
}
private pushServiceLogToClients(
serviceName: string,
line: string,
isError: boolean,
): void {
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
if (!typedsocket) return;
const entry = this.parseLogLine(line, isError);
typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard')
.then((connections: any[]) => {
for (const conn of connections) {
typedsocket.createTypedRequest<interfaces.requests.IReq_PushServiceLog>(
'pushServiceLog',
conn,
).fire({ serviceName, entry }).catch(() => {});
}
})
.catch(() => {});
}
private registerHandlers(): void {

File diff suppressed because one or more lines are too long

View File

@@ -212,3 +212,19 @@ export interface IReq_GetServiceBackupSchedules extends plugins.typedrequestInte
schedules: data.IBackupSchedule[];
};
}
export interface IReq_PushServiceLog extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PushServiceLog
> {
method: 'pushServiceLog';
request: {
serviceName: string;
entry: {
timestamp: string;
level: string;
message: string;
};
};
response: {};
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.19.12',
version: '1.20.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}

View File

@@ -997,6 +997,37 @@ socketRouter.addTypedHandler(
),
);
// Handle server-pushed user service log entries
socketRouter.addTypedHandler(
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushServiceLog>(
'pushServiceLog',
async (dataArg) => {
const state = servicesStatePart.getState();
// Only append if we're currently viewing this service
if (!state.currentService || state.currentService.name !== dataArg.serviceName) {
return {};
}
const entry: interfaces.data.ILogEntry = {
id: state.currentServiceLogs.length,
serviceId: 0,
timestamp: new Date(dataArg.entry.timestamp).getTime(),
message: dataArg.entry.message,
level: dataArg.entry.level as 'info' | 'warn' | 'error' | 'debug',
source: 'stdout',
};
const updated = [...state.currentServiceLogs, entry];
if (updated.length > 2000) {
updated.splice(0, updated.length - 2000);
}
servicesStatePart.setState({
...state,
currentServiceLogs: updated,
});
return {};
},
),
);
async function connectSocket() {
if (socketClient) return;
try {

View File

@@ -76,20 +76,29 @@ function toServiceStats(stats: interfaces.data.IContainerStats) {
};
}
function parseLogs(logs: any): Array<{ timestamp: string; message: string }> {
function parseLogs(logs: any): Array<{ timestamp: string; message: string; level?: string }> {
if (Array.isArray(logs)) {
return logs.map((entry: any) => ({
timestamp: entry.timestamp ? String(entry.timestamp) : '',
message: entry.message || String(entry),
}));
return logs.map((entry: any) => {
const ts = entry.timestamp
? (typeof entry.timestamp === 'number' ? new Date(entry.timestamp).toISOString() : String(entry.timestamp))
: new Date().toISOString();
const message = entry.message || String(entry);
const level = entry.level || 'info';
return { timestamp: ts, message, level };
});
}
if (typeof logs === 'string' && logs.trim()) {
return logs.split('\n').filter((line: string) => line.trim()).map((line: string) => {
const match = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
if (match) {
return { timestamp: match[1], message: match[2] };
}
return { timestamp: '', message: line };
const timestamp = match ? match[1] : new Date().toISOString();
const message = match ? match[2] : line;
const msgLower = message.toLowerCase();
const level = msgLower.includes('error') || msgLower.includes('fatal')
? 'error'
: msgLower.includes('warn')
? 'warn'
: 'info';
return { timestamp, message, level };
});
}
return [];