feat(opsserver,web): add real-time platform service log streaming to the dashboard
This commit is contained in:
BIN
caddy-detail-with-chart-log.png
Normal file
BIN
caddy-detail-with-chart-log.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-16 - 1.19.0 - feat(opsserver,web)
|
||||
add real-time platform service log streaming to the dashboard
|
||||
|
||||
- stream running platform service container logs from the ops server to connected dashboard clients via TypedSocket
|
||||
- parse Docker log timestamps and levels for both pushed and fetched platform service log entries
|
||||
- enhance the platform service detail view with mapped statuses and predefined host, port, version, and config metadata
|
||||
- add the typedsocket dependency and update the catalog package for dashboard support
|
||||
|
||||
## 2026-03-16 - 1.18.5 - fix(platform-services)
|
||||
fix platform service detail view navigation and log display
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1"
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.2"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
|
||||
BIN
mongodb-detail-enterprise.png
Normal file
BIN
mongodb-detail-enterprise.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -55,9 +55,10 @@
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@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.1"
|
||||
"@serve.zone/catalog": "^2.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbundle": "^2.9.0",
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@api.global/typedrequest-interfaces':
|
||||
specifier: ^3.0.19
|
||||
version: 3.0.19
|
||||
'@api.global/typedsocket':
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2(@push.rocks/smartserve@2.0.1)
|
||||
'@design.estate/dees-catalog':
|
||||
specifier: ^3.43.3
|
||||
version: 3.48.5(@tiptap/pm@2.27.2)
|
||||
@@ -18,8 +21,8 @@ importers:
|
||||
specifier: ^2.1.6
|
||||
version: 2.2.3
|
||||
'@serve.zone/catalog':
|
||||
specifier: ^2.6.1
|
||||
version: 2.6.1(@tiptap/pm@2.27.2)
|
||||
specifier: ^2.6.2
|
||||
version: 2.6.2(@tiptap/pm@2.27.2)
|
||||
devDependencies:
|
||||
'@git.zone/tsbundle':
|
||||
specifier: ^2.9.0
|
||||
@@ -836,8 +839,8 @@ packages:
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
'@serve.zone/catalog@2.6.1':
|
||||
resolution: {integrity: sha512-8L78lyTvZPin3ELkaPOvUmeKuzqMNZunnyvF9LR2q4Eakxgc/KgbV0Q1M0fF0ENpGxQH+2kOWobwQsH2RfDmVA==}
|
||||
'@serve.zone/catalog@2.6.2':
|
||||
resolution: {integrity: sha512-1XPdgkqjx80r3mjE03QOex0r48jz2SzQ8lwz/VBvPtwgJYH0DO5TBuMSgT56YeQ1c/e2vVpqdXIicbcJoreBYw==}
|
||||
|
||||
'@tempfix/idb@8.0.3':
|
||||
resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==}
|
||||
@@ -3474,7 +3477,7 @@ snapshots:
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@serve.zone/catalog@2.6.1(@tiptap/pm@2.27.2)':
|
||||
'@serve.zone/catalog@2.6.2(@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
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/onebox',
|
||||
version: '1.18.4',
|
||||
version: '1.19.0',
|
||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||
}
|
||||
|
||||
@@ -6,10 +6,82 @@ import { requireValidIdentity } from '../helpers/guards.ts';
|
||||
|
||||
export class PlatformHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private activeLogStreams = new Map<string, boolean>();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
this.startLogStreaming();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start streaming logs from all running platform service containers
|
||||
* 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) {
|
||||
if (service.status !== 'running' || !service.containerId) continue;
|
||||
if (this.activeLogStreams.has(service.type)) continue;
|
||||
|
||||
this.activeLogStreams.set(service.type, 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);
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(`Log stream failed for ${service.type}: ${(err as Error).message}`);
|
||||
this.activeLogStreams.delete(service.type);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check after a short delay (let services start first)
|
||||
setTimeout(() => checkAndStream(), 5000);
|
||||
// Re-check periodically for newly started services
|
||||
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
|
||||
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;
|
||||
const msgLower = message.toLowerCase();
|
||||
const level = isError || msgLower.includes('error') || msgLower.includes('fatal')
|
||||
? 'error'
|
||||
: msgLower.includes('warn')
|
||||
? 'warn'
|
||||
: 'info';
|
||||
|
||||
// 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
|
||||
}
|
||||
})
|
||||
.catch(() => {}); // no connections, ignore
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
@@ -186,13 +258,18 @@ export class PlatformHandler {
|
||||
.filter((line: string) => line.trim());
|
||||
|
||||
const logs = logLines.map((line: string, index: number) => {
|
||||
const isError = line.toLowerCase().includes('error') || line.toLowerCase().includes('fatal');
|
||||
const isWarn = line.toLowerCase().includes('warn');
|
||||
// Try to parse Docker timestamp from beginning of line
|
||||
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
|
||||
const timestamp = tsMatch ? new Date(tsMatch[1]).getTime() : Date.now();
|
||||
const message = tsMatch ? tsMatch[2] : line;
|
||||
const msgLower = message.toLowerCase();
|
||||
const isError = msgLower.includes('error') || msgLower.includes('fatal');
|
||||
const isWarn = msgLower.includes('warn');
|
||||
return {
|
||||
id: index,
|
||||
serviceId: 0,
|
||||
timestamp: Date.now(),
|
||||
message: line,
|
||||
timestamp,
|
||||
message,
|
||||
level: (isError ? 'error' : isWarn ? 'warn' : 'info') as 'info' | 'warn' | 'error' | 'debug',
|
||||
source: 'stdout' as const,
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -84,3 +84,19 @@ export interface IReq_GetPlatformServiceLogs extends plugins.typedrequestInterfa
|
||||
logs: data.ILogEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_PushPlatformServiceLog extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushPlatformServiceLog
|
||||
> {
|
||||
method: 'pushPlatformServiceLog';
|
||||
request: {
|
||||
serviceType: data.TPlatformServiceType;
|
||||
entry: {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/onebox',
|
||||
version: '1.18.4',
|
||||
version: '1.19.0',
|
||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||
}
|
||||
|
||||
@@ -961,3 +961,73 @@ const startAutoRefresh = () => {
|
||||
uiStatePart.select((s) => s).subscribe(() => startAutoRefresh());
|
||||
loginStatePart.select((s) => s).subscribe(() => startAutoRefresh());
|
||||
startAutoRefresh();
|
||||
|
||||
// ============================================================================
|
||||
// TypedSocket — real-time server push (logs, events)
|
||||
// ============================================================================
|
||||
|
||||
let socketClient: InstanceType<typeof plugins.typedsocket.TypedSocket> | null = null;
|
||||
const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Handle server-pushed platform service log entries
|
||||
socketRouter.addTypedHandler(
|
||||
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushPlatformServiceLog>(
|
||||
'pushPlatformServiceLog',
|
||||
async (dataArg) => {
|
||||
const state = servicesStatePart.getState();
|
||||
const entry: interfaces.data.ILogEntry = {
|
||||
id: state.currentPlatformServiceLogs.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.currentPlatformServiceLogs, entry];
|
||||
// Cap at 2000 entries
|
||||
if (updated.length > 2000) {
|
||||
updated.splice(0, updated.length - 2000);
|
||||
}
|
||||
servicesStatePart.setState({
|
||||
...state,
|
||||
currentPlatformServiceLogs: updated,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
async function connectSocket() {
|
||||
if (socketClient) return;
|
||||
try {
|
||||
socketClient = await plugins.typedsocket.TypedSocket.createClient(
|
||||
socketRouter,
|
||||
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
|
||||
);
|
||||
await socketClient.setTag('role', 'ops_dashboard');
|
||||
console.log('TypedSocket dashboard connection established');
|
||||
} catch (err) {
|
||||
console.error('TypedSocket connection failed:', err);
|
||||
socketClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSocket() {
|
||||
if (socketClient) {
|
||||
try {
|
||||
await socketClient.disconnect();
|
||||
} catch {
|
||||
// ignore disconnect errors
|
||||
}
|
||||
socketClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect socket when logged in, disconnect when logged out
|
||||
loginStatePart.select((s) => s).subscribe((loginState) => {
|
||||
if (loginState.isLoggedIn) {
|
||||
connectSocket();
|
||||
} else {
|
||||
disconnectSocket();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,9 +222,20 @@ export class ObViewServices extends DeesElement {
|
||||
domain: s.domain || null,
|
||||
status: mapStatus(s.status),
|
||||
}));
|
||||
const displayStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running': return 'Running';
|
||||
case 'stopped': return 'Stopped';
|
||||
case 'starting': return 'Starting...';
|
||||
case 'stopping': return 'Stopping...';
|
||||
case 'failed': return 'Failed';
|
||||
case 'not-deployed': return 'Not Deployed';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
const mappedPlatformServices = this.servicesState.platformServices.map((ps) => ({
|
||||
name: ps.displayName,
|
||||
status: ps.status === 'running' ? `Running` : ps.status,
|
||||
status: displayStatus(ps.status),
|
||||
running: ps.status === 'running',
|
||||
type: ps.type,
|
||||
}));
|
||||
@@ -384,14 +395,36 @@ export class ObViewServices extends DeesElement {
|
||||
(ps) => ps.type === this.selectedPlatformType,
|
||||
);
|
||||
const stats = this.servicesState.currentPlatformServiceStats;
|
||||
const metrics = stats
|
||||
? {
|
||||
cpu: Math.round(stats.cpuPercent),
|
||||
memory: Math.round(stats.memoryPercent),
|
||||
storage: 0,
|
||||
connections: 0,
|
||||
}
|
||||
: undefined;
|
||||
const metrics = {
|
||||
cpu: stats ? Math.round(stats.cpuPercent) : 0,
|
||||
memory: stats ? Math.round(stats.memoryPercent) : 0,
|
||||
storage: 0,
|
||||
connections: undefined as number | undefined,
|
||||
};
|
||||
|
||||
// Real service info per platform type
|
||||
const serviceInfo: Record<string, { host: string; port: number; version: string; config: Record<string, any> }> = {
|
||||
mongodb: { host: 'onebox-mongodb', port: 27017, version: '4.4', config: { engine: 'WiredTiger', authEnabled: true } },
|
||||
minio: { host: 'onebox-minio', port: 9000, version: 'latest', config: { consolePort: 9001, region: 'us-east-1' } },
|
||||
clickhouse: { host: 'onebox-clickhouse', port: 8123, version: 'latest', config: { nativePort: 9000, httpPort: 8123 } },
|
||||
caddy: { host: 'onebox-caddy', port: 80, version: '2-alpine', config: { httpsPort: 443, adminApi: 2019 } },
|
||||
};
|
||||
const info = platformService
|
||||
? serviceInfo[platformService.type] || { host: 'unknown', port: 0, version: '', config: {} }
|
||||
: { host: '', port: 0, version: '', config: {} };
|
||||
|
||||
// Map backend status to catalog-compatible status
|
||||
const mapPlatformStatus = (status: string): 'running' | 'stopped' | 'error' => {
|
||||
switch (status) {
|
||||
case 'running': return 'running';
|
||||
case 'failed': return 'error';
|
||||
case 'starting':
|
||||
case 'stopping':
|
||||
case 'stopped':
|
||||
case 'not-deployed':
|
||||
default: return 'stopped';
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<ob-sectionheading>Platform Service</ob-sectionheading>
|
||||
@@ -406,15 +439,11 @@ export class ObViewServices extends DeesElement {
|
||||
id: platformService.type,
|
||||
name: platformService.displayName,
|
||||
type: platformService.type,
|
||||
status: platformService.status === 'running'
|
||||
? 'running'
|
||||
: platformService.status === 'failed'
|
||||
? 'error'
|
||||
: 'stopped',
|
||||
version: '',
|
||||
host: 'localhost',
|
||||
port: 0,
|
||||
config: {},
|
||||
status: mapPlatformStatus(platformService.status),
|
||||
version: info.version,
|
||||
host: info.host,
|
||||
port: info.port,
|
||||
config: info.config,
|
||||
metrics,
|
||||
}
|
||||
: null}
|
||||
|
||||
@@ -5,9 +5,13 @@ import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
// @serve.zone scope — side-effect import registers all sz-* custom elements
|
||||
import '@serve.zone/catalog';
|
||||
|
||||
// TypedSocket for real-time server push (logs, events)
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export {
|
||||
deesElement,
|
||||
deesCatalog,
|
||||
typedsocket,
|
||||
};
|
||||
|
||||
// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities
|
||||
|
||||
Reference in New Issue
Block a user