bs9 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +532 -0
- package/bin/bs9 +97 -0
- package/package.json +48 -0
- package/src/.gitkeep +0 -0
- package/src/alerting/config.ts +194 -0
- package/src/commands/alert.ts +98 -0
- package/src/commands/export.ts +69 -0
- package/src/commands/logs.ts +22 -0
- package/src/commands/monit.ts +248 -0
- package/src/commands/restart.ts +13 -0
- package/src/commands/start.ts +207 -0
- package/src/commands/status.ts +162 -0
- package/src/commands/stop.ts +13 -0
- package/src/commands/web.ts +49 -0
- package/src/docker/Dockerfile +44 -0
- package/src/injectors/otel.ts +66 -0
- package/src/k8s/bs9-deployment.yaml +197 -0
- package/src/storage/metrics.ts +204 -0
- package/src/web/dashboard.ts +286 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
apiVersion: v1
|
|
2
|
+
kind: Namespace
|
|
3
|
+
metadata:
|
|
4
|
+
name: bs9-system
|
|
5
|
+
---
|
|
6
|
+
apiVersion: v1
|
|
7
|
+
kind: ConfigMap
|
|
8
|
+
metadata:
|
|
9
|
+
name: bs9-config
|
|
10
|
+
namespace: bs9-system
|
|
11
|
+
data:
|
|
12
|
+
config.toml: |
|
|
13
|
+
[default]
|
|
14
|
+
port = 3000
|
|
15
|
+
otel_enabled = true
|
|
16
|
+
prometheus_enabled = true
|
|
17
|
+
environment = "production"
|
|
18
|
+
|
|
19
|
+
[security]
|
|
20
|
+
security_audit = true
|
|
21
|
+
block_eval = true
|
|
22
|
+
block_child_process_exec = true
|
|
23
|
+
block_fs_access = true
|
|
24
|
+
|
|
25
|
+
[monitoring]
|
|
26
|
+
refresh_interval = 2
|
|
27
|
+
health_check_timeout = 1000
|
|
28
|
+
|
|
29
|
+
[logging]
|
|
30
|
+
level = "info"
|
|
31
|
+
structured = true
|
|
32
|
+
---
|
|
33
|
+
apiVersion: apps/v1
|
|
34
|
+
kind: Deployment
|
|
35
|
+
metadata:
|
|
36
|
+
name: bs9-manager
|
|
37
|
+
namespace: bs9-system
|
|
38
|
+
labels:
|
|
39
|
+
app: bs9-manager
|
|
40
|
+
spec:
|
|
41
|
+
replicas: 1
|
|
42
|
+
selector:
|
|
43
|
+
matchLabels:
|
|
44
|
+
app: bs9-manager
|
|
45
|
+
template:
|
|
46
|
+
metadata:
|
|
47
|
+
labels:
|
|
48
|
+
app: bs9-manager
|
|
49
|
+
spec:
|
|
50
|
+
containers:
|
|
51
|
+
- name: bs9-manager
|
|
52
|
+
image: bs9:latest
|
|
53
|
+
imagePullPolicy: IfNotPresent
|
|
54
|
+
ports:
|
|
55
|
+
- containerPort: 8080
|
|
56
|
+
name: web-dashboard
|
|
57
|
+
- containerPort: 3000
|
|
58
|
+
name: app-port
|
|
59
|
+
env:
|
|
60
|
+
- name: NODE_ENV
|
|
61
|
+
value: "production"
|
|
62
|
+
- name: WEB_DASHBOARD_PORT
|
|
63
|
+
value: "8080"
|
|
64
|
+
volumeMounts:
|
|
65
|
+
- name: config-volume
|
|
66
|
+
mountPath: /app/.config/bs9
|
|
67
|
+
- name: apps-volume
|
|
68
|
+
mountPath: /app/examples
|
|
69
|
+
resources:
|
|
70
|
+
requests:
|
|
71
|
+
memory: "128Mi"
|
|
72
|
+
cpu: "100m"
|
|
73
|
+
limits:
|
|
74
|
+
memory: "512Mi"
|
|
75
|
+
cpu: "500m"
|
|
76
|
+
livenessProbe:
|
|
77
|
+
httpGet:
|
|
78
|
+
path: /
|
|
79
|
+
port: 8080
|
|
80
|
+
initialDelaySeconds: 30
|
|
81
|
+
periodSeconds: 10
|
|
82
|
+
readinessProbe:
|
|
83
|
+
httpGet:
|
|
84
|
+
path: /
|
|
85
|
+
port: 8080
|
|
86
|
+
initialDelaySeconds: 5
|
|
87
|
+
periodSeconds: 5
|
|
88
|
+
securityContext:
|
|
89
|
+
runAsNonRoot: true
|
|
90
|
+
runAsUser: 1000
|
|
91
|
+
allowPrivilegeEscalation: false
|
|
92
|
+
readOnlyRootFilesystem: true
|
|
93
|
+
capabilities:
|
|
94
|
+
drop:
|
|
95
|
+
- ALL
|
|
96
|
+
volumes:
|
|
97
|
+
- name: config-volume
|
|
98
|
+
configMap:
|
|
99
|
+
name: bs9-config
|
|
100
|
+
- name: apps-volume
|
|
101
|
+
hostPath:
|
|
102
|
+
path: /opt/bs9-apps
|
|
103
|
+
type: DirectoryOrCreate
|
|
104
|
+
---
|
|
105
|
+
apiVersion: v1
|
|
106
|
+
kind: Service
|
|
107
|
+
metadata:
|
|
108
|
+
name: bs9-manager-service
|
|
109
|
+
namespace: bs9-system
|
|
110
|
+
labels:
|
|
111
|
+
app: bs9-manager
|
|
112
|
+
spec:
|
|
113
|
+
selector:
|
|
114
|
+
app: bs9-manager
|
|
115
|
+
ports:
|
|
116
|
+
- name: web-dashboard
|
|
117
|
+
port: 8080
|
|
118
|
+
targetPort: 8080
|
|
119
|
+
protocol: TCP
|
|
120
|
+
- name: app-port
|
|
121
|
+
port: 3000
|
|
122
|
+
targetPort: 3000
|
|
123
|
+
protocol: TCP
|
|
124
|
+
type: ClusterIP
|
|
125
|
+
---
|
|
126
|
+
apiVersion: v1
|
|
127
|
+
kind: ServiceMonitor
|
|
128
|
+
metadata:
|
|
129
|
+
name: bs9-monitor
|
|
130
|
+
namespace: bs9-system
|
|
131
|
+
labels:
|
|
132
|
+
app: bs9-manager
|
|
133
|
+
spec:
|
|
134
|
+
selector:
|
|
135
|
+
matchLabels:
|
|
136
|
+
app: bs9-manager
|
|
137
|
+
endpoints:
|
|
138
|
+
- port: app-port
|
|
139
|
+
path: /metrics
|
|
140
|
+
interval: 30s
|
|
141
|
+
---
|
|
142
|
+
apiVersion: policy/v1
|
|
143
|
+
kind: PodSecurityPolicy
|
|
144
|
+
metadata:
|
|
145
|
+
name: bs9-psp
|
|
146
|
+
namespace: bs9-system
|
|
147
|
+
spec:
|
|
148
|
+
privileged: false
|
|
149
|
+
allowPrivilegeEscalation: false
|
|
150
|
+
requiredDropCapabilities:
|
|
151
|
+
- ALL
|
|
152
|
+
volumes:
|
|
153
|
+
- 'configMap'
|
|
154
|
+
- 'emptyDir'
|
|
155
|
+
- 'projected'
|
|
156
|
+
- 'secret'
|
|
157
|
+
- 'downwardAPI'
|
|
158
|
+
- 'persistentVolumeClaim'
|
|
159
|
+
runAsUser:
|
|
160
|
+
rule: 'MustRunAsNonRoot'
|
|
161
|
+
seLinux:
|
|
162
|
+
rule: 'RunAsAny'
|
|
163
|
+
fsGroup:
|
|
164
|
+
rule: 'RunAsAny'
|
|
165
|
+
---
|
|
166
|
+
apiVersion: rbac.authorization.k8s.io/v1
|
|
167
|
+
kind: Role
|
|
168
|
+
metadata:
|
|
169
|
+
name: bs9-role
|
|
170
|
+
namespace: bs9-system
|
|
171
|
+
rules:
|
|
172
|
+
- apiGroups: [""]
|
|
173
|
+
resources: ["pods", "services", "endpoints"]
|
|
174
|
+
verbs: ["get", "list", "watch"]
|
|
175
|
+
- apiGroups: ["apps"]
|
|
176
|
+
resources: ["deployments", "replicasets"]
|
|
177
|
+
verbs: ["get", "list", "watch"]
|
|
178
|
+
---
|
|
179
|
+
apiVersion: rbac.authorization.k8s.io/v1
|
|
180
|
+
kind: RoleBinding
|
|
181
|
+
metadata:
|
|
182
|
+
name: bs9-rolebinding
|
|
183
|
+
namespace: bs9-system
|
|
184
|
+
subjects:
|
|
185
|
+
- kind: ServiceAccount
|
|
186
|
+
name: bs9-account
|
|
187
|
+
namespace: bs9-system
|
|
188
|
+
roleRef:
|
|
189
|
+
kind: Role
|
|
190
|
+
name: bs9-role
|
|
191
|
+
apiGroup: rbac.authorization.k8s.io
|
|
192
|
+
---
|
|
193
|
+
apiVersion: v1
|
|
194
|
+
kind: ServiceAccount
|
|
195
|
+
metadata:
|
|
196
|
+
name: bs9-account
|
|
197
|
+
namespace: bs9-system
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
|
|
7
|
+
interface MetricSnapshot {
|
|
8
|
+
timestamp: number;
|
|
9
|
+
services: ServiceMetric[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ServiceMetric {
|
|
13
|
+
name: string;
|
|
14
|
+
cpu: string;
|
|
15
|
+
memory: number; // in bytes
|
|
16
|
+
uptime: string;
|
|
17
|
+
tasks: number;
|
|
18
|
+
health: 'healthy' | 'unhealthy' | 'unknown';
|
|
19
|
+
state: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class MetricsStorage {
|
|
23
|
+
private storageDir: string;
|
|
24
|
+
private maxSnapshots: number = 1000; // Keep last 1000 snapshots
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
this.storageDir = join(homedir(), ".config", "bs9", "metrics");
|
|
28
|
+
if (!existsSync(this.storageDir)) {
|
|
29
|
+
mkdirSync(this.storageDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
storeSnapshot(services: ServiceMetric[]): void {
|
|
34
|
+
const snapshot: MetricSnapshot = {
|
|
35
|
+
timestamp: Date.now(),
|
|
36
|
+
services,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const filename = `metrics-${snapshot.timestamp}.json`;
|
|
40
|
+
const filepath = join(this.storageDir, filename);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(filepath, JSON.stringify(snapshot, null, 2));
|
|
44
|
+
|
|
45
|
+
// Cleanup old snapshots
|
|
46
|
+
this.cleanupOldSnapshots();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('Failed to store metrics snapshot:', error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getHistoricalData(hours: number = 24): MetricSnapshot[] {
|
|
53
|
+
const cutoffTime = Date.now() - (hours * 60 * 60 * 1000);
|
|
54
|
+
const snapshots: MetricSnapshot[] = [];
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const files = this.getMetricFiles();
|
|
58
|
+
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
const filepath = join(this.storageDir, file);
|
|
61
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
62
|
+
const snapshot: MetricSnapshot = JSON.parse(content);
|
|
63
|
+
|
|
64
|
+
if (snapshot.timestamp >= cutoffTime) {
|
|
65
|
+
snapshots.push(snapshot);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('Failed to read historical data:', error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return snapshots.sort((a, b) => a.timestamp - b.timestamp);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getServiceMetrics(serviceName: string, hours: number = 24): ServiceMetric[] {
|
|
76
|
+
const snapshots = this.getHistoricalData(hours);
|
|
77
|
+
const metrics: ServiceMetric[] = [];
|
|
78
|
+
|
|
79
|
+
for (const snapshot of snapshots) {
|
|
80
|
+
const serviceMetric = snapshot.services.find(s => s.name === serviceName);
|
|
81
|
+
if (serviceMetric) {
|
|
82
|
+
metrics.push(serviceMetric);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return metrics;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getAggregatedMetrics(hours: number = 24): {
|
|
90
|
+
avgCpu: number;
|
|
91
|
+
avgMemory: number;
|
|
92
|
+
uptime: number;
|
|
93
|
+
totalRequests: number;
|
|
94
|
+
errorRate: number;
|
|
95
|
+
} {
|
|
96
|
+
const snapshots = this.getHistoricalData(hours);
|
|
97
|
+
|
|
98
|
+
if (snapshots.length === 0) {
|
|
99
|
+
return {
|
|
100
|
+
avgCpu: 0,
|
|
101
|
+
avgMemory: 0,
|
|
102
|
+
uptime: 0,
|
|
103
|
+
totalRequests: 0,
|
|
104
|
+
errorRate: 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let totalCpu = 0;
|
|
109
|
+
let totalMemory = 0;
|
|
110
|
+
let healthyCount = 0;
|
|
111
|
+
let totalCount = 0;
|
|
112
|
+
|
|
113
|
+
for (const snapshot of snapshots) {
|
|
114
|
+
for (const service of snapshot.services) {
|
|
115
|
+
// Parse CPU from string like "12.3ms" to number
|
|
116
|
+
const cpuMatch = service.cpu.match(/([\d.]+)ms/);
|
|
117
|
+
if (cpuMatch) {
|
|
118
|
+
totalCpu += parseFloat(cpuMatch[1]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
totalMemory += service.memory;
|
|
122
|
+
totalCount++;
|
|
123
|
+
|
|
124
|
+
if (service.health === 'healthy') {
|
|
125
|
+
healthyCount++;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
avgCpu: totalCpu / (snapshots.length * Math.max(1, snapshots[0]?.services.length || 1)),
|
|
132
|
+
avgMemory: totalMemory / Math.max(1, snapshots.length * Math.max(1, snapshots[0]?.services.length || 1)),
|
|
133
|
+
uptime: (healthyCount / Math.max(1, totalCount)) * 100,
|
|
134
|
+
totalRequests: totalCount,
|
|
135
|
+
errorRate: ((totalCount - healthyCount) / Math.max(1, totalCount)) * 100,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private getMetricFiles(): string[] {
|
|
140
|
+
try {
|
|
141
|
+
const { readdirSync } = require("node:fs");
|
|
142
|
+
const files = readdirSync(this.storageDir);
|
|
143
|
+
return files
|
|
144
|
+
.filter((file: string) => file.startsWith('metrics-') && file.endsWith('.json'))
|
|
145
|
+
.sort((a: string, b: string) => {
|
|
146
|
+
const timeA = parseInt(a.split('-')[1].split('.')[0]);
|
|
147
|
+
const timeB = parseInt(b.split('-')[1].split('.')[0]);
|
|
148
|
+
return timeB - timeA;
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private cleanupOldSnapshots(): void {
|
|
156
|
+
const files = this.getMetricFiles();
|
|
157
|
+
|
|
158
|
+
if (files.length > this.maxSnapshots) {
|
|
159
|
+
const filesToDelete = files.slice(this.maxSnapshots);
|
|
160
|
+
|
|
161
|
+
for (const file of filesToDelete) {
|
|
162
|
+
try {
|
|
163
|
+
const { unlinkSync } = require("node:fs");
|
|
164
|
+
unlinkSync(join(this.storageDir, file));
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error(`Failed to delete old metrics file ${file}:`, error);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
exportData(format: 'json' | 'csv' = 'json'): string {
|
|
173
|
+
const snapshots = this.getHistoricalData(24 * 7); // Last week
|
|
174
|
+
|
|
175
|
+
if (format === 'csv') {
|
|
176
|
+
const headers = ['timestamp', 'service_name', 'cpu_ms', 'memory_bytes', 'uptime', 'tasks', 'health', 'state'];
|
|
177
|
+
const rows = [headers.join(',')];
|
|
178
|
+
|
|
179
|
+
for (const snapshot of snapshots) {
|
|
180
|
+
for (const service of snapshot.services) {
|
|
181
|
+
const cpuMatch = service.cpu.match(/([\d.]+)ms/);
|
|
182
|
+
const cpuMs = cpuMatch ? cpuMatch[1] : '0';
|
|
183
|
+
|
|
184
|
+
rows.push([
|
|
185
|
+
new Date(snapshot.timestamp).toISOString(),
|
|
186
|
+
service.name,
|
|
187
|
+
cpuMs,
|
|
188
|
+
service.memory.toString(),
|
|
189
|
+
service.uptime,
|
|
190
|
+
service.tasks.toString(),
|
|
191
|
+
service.health,
|
|
192
|
+
service.state,
|
|
193
|
+
].join(','));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return rows.join('\n');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return JSON.stringify(snapshots, null, 2);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export { MetricsStorage, ServiceMetric, MetricSnapshot };
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { serve } from "bun";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
interface ServiceMetrics {
|
|
8
|
+
name: string;
|
|
9
|
+
loaded: string;
|
|
10
|
+
active: string;
|
|
11
|
+
sub: string;
|
|
12
|
+
state: string;
|
|
13
|
+
cpu: string;
|
|
14
|
+
memory: string;
|
|
15
|
+
uptime: string;
|
|
16
|
+
tasks: string;
|
|
17
|
+
health: string;
|
|
18
|
+
description: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const getMetrics = (): ServiceMetrics[] => {
|
|
22
|
+
try {
|
|
23
|
+
const listOutput = execSync("systemctl --user list-units --type=service --no-pager --no-legend", { encoding: "utf-8" });
|
|
24
|
+
const lines = listOutput.split("\n").filter(line => line.includes(".service"));
|
|
25
|
+
|
|
26
|
+
const services: ServiceMetrics[] = [];
|
|
27
|
+
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
if (!line.trim()) continue;
|
|
30
|
+
|
|
31
|
+
const match = line.match(/^(?:\s*([●\s○]))?\s*([^\s]+)\.service\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+(.+)$/);
|
|
32
|
+
if (!match) continue;
|
|
33
|
+
|
|
34
|
+
const [, statusIndicator, name, loaded, active, sub, description] = match;
|
|
35
|
+
|
|
36
|
+
if (!description.includes("Bun Service:") && !description.includes("BS9 Service:")) continue;
|
|
37
|
+
|
|
38
|
+
const service: ServiceMetrics = {
|
|
39
|
+
name,
|
|
40
|
+
loaded,
|
|
41
|
+
active,
|
|
42
|
+
sub,
|
|
43
|
+
state: `${active}/${sub}`,
|
|
44
|
+
cpu: '-',
|
|
45
|
+
memory: '-',
|
|
46
|
+
uptime: '-',
|
|
47
|
+
tasks: '-',
|
|
48
|
+
health: 'unknown',
|
|
49
|
+
description,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Get additional metrics
|
|
53
|
+
try {
|
|
54
|
+
const showOutput = execSync(`systemctl --user show ${name} -p CPUUsageNSec MemoryCurrent ActiveEnterTimestamp TasksCurrent State`, { encoding: "utf-8" });
|
|
55
|
+
const cpuMatch = showOutput.match(/CPUUsageNSec=(\d+)/);
|
|
56
|
+
const memMatch = showOutput.match(/MemoryCurrent=(\d+)/);
|
|
57
|
+
const timeMatch = showOutput.match(/ActiveEnterTimestamp=(.+)/);
|
|
58
|
+
const tasksMatch = showOutput.match(/TasksCurrent=(\d+)/);
|
|
59
|
+
|
|
60
|
+
if (cpuMatch) {
|
|
61
|
+
const cpuNs = Number(cpuMatch[1]);
|
|
62
|
+
service.cpu = `${(cpuNs / 1000000).toFixed(1)}ms`;
|
|
63
|
+
}
|
|
64
|
+
if (memMatch) {
|
|
65
|
+
const memBytes = Number(memMatch[1]);
|
|
66
|
+
service.memory = formatMemory(memBytes);
|
|
67
|
+
}
|
|
68
|
+
if (timeMatch) {
|
|
69
|
+
service.uptime = formatUptime(timeMatch[1]);
|
|
70
|
+
}
|
|
71
|
+
if (tasksMatch) {
|
|
72
|
+
service.tasks = tasksMatch[1];
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Ignore metrics errors
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check health endpoint
|
|
79
|
+
try {
|
|
80
|
+
const portMatch = description.match(/port[=:]?\s*(\d+)/i);
|
|
81
|
+
if (portMatch) {
|
|
82
|
+
const port = portMatch[1];
|
|
83
|
+
const healthCheck = execSync(`curl -s -o /dev/null -w "%{http_code}" http://localhost:${port}/healthz`, { encoding: "utf-8", timeout: 1000 });
|
|
84
|
+
service.health = healthCheck === "200" ? "healthy" : "unhealthy";
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
service.health = "unknown";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
services.push(service);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return services;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error('Error fetching metrics:', error);
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const formatMemory = (bytes: number): string => {
|
|
101
|
+
if (bytes === 0) return '0B';
|
|
102
|
+
const k = 1024;
|
|
103
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
104
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
105
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))}${sizes[i]}`;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const formatUptime = (timestamp: string): string => {
|
|
109
|
+
try {
|
|
110
|
+
const date = new Date(timestamp);
|
|
111
|
+
const now = new Date();
|
|
112
|
+
const diff = now.getTime() - date.getTime();
|
|
113
|
+
|
|
114
|
+
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
115
|
+
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
116
|
+
|
|
117
|
+
if (hours > 0) {
|
|
118
|
+
return `${hours}h ${minutes}m`;
|
|
119
|
+
}
|
|
120
|
+
return `${minutes}m`;
|
|
121
|
+
} catch {
|
|
122
|
+
return '-';
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const HTML_TEMPLATE = `
|
|
127
|
+
<!DOCTYPE html>
|
|
128
|
+
<html lang="en">
|
|
129
|
+
<head>
|
|
130
|
+
<meta charset="UTF-8">
|
|
131
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
132
|
+
<title>BS9 Web Dashboard</title>
|
|
133
|
+
<style>
|
|
134
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
135
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
|
|
136
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
137
|
+
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 10px; margin-bottom: 20px; }
|
|
138
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px; }
|
|
139
|
+
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
140
|
+
.stat-value { font-size: 2em; font-weight: bold; color: #333; }
|
|
141
|
+
.stat-label { color: #666; margin-top: 5px; }
|
|
142
|
+
.services-table { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
143
|
+
table { width: 100%; border-collapse: collapse; }
|
|
144
|
+
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
|
|
145
|
+
th { background: #f8f9fa; font-weight: 600; }
|
|
146
|
+
.status-healthy { color: #28a745; font-weight: bold; }
|
|
147
|
+
.status-unhealthy { color: #dc3545; font-weight: bold; }
|
|
148
|
+
.status-unknown { color: #ffc107; font-weight: bold; }
|
|
149
|
+
.state-active { color: #28a745; }
|
|
150
|
+
.state-failed { color: #dc3545; }
|
|
151
|
+
.refresh-btn { background: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; }
|
|
152
|
+
.refresh-btn:hover { background: #0056b3; }
|
|
153
|
+
</style>
|
|
154
|
+
</head>
|
|
155
|
+
<body>
|
|
156
|
+
<div class="container">
|
|
157
|
+
<div class="header">
|
|
158
|
+
<h1>🔍 BS9 Web Dashboard</h1>
|
|
159
|
+
<p>Real-time monitoring dashboard for BS9 services</p>
|
|
160
|
+
<button class="refresh-btn" onclick="location.reload()">Refresh</button>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div class="stats" id="stats">
|
|
164
|
+
<!-- Stats will be populated by JavaScript -->
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div class="services-table">
|
|
168
|
+
<table>
|
|
169
|
+
<thead>
|
|
170
|
+
<tr>
|
|
171
|
+
<th>Service</th>
|
|
172
|
+
<th>State</th>
|
|
173
|
+
<th>Health</th>
|
|
174
|
+
<th>CPU</th>
|
|
175
|
+
<th>Memory</th>
|
|
176
|
+
<th>Uptime</th>
|
|
177
|
+
<th>Tasks</th>
|
|
178
|
+
</tr>
|
|
179
|
+
</thead>
|
|
180
|
+
<tbody id="services-tbody">
|
|
181
|
+
<!-- Services will be populated by JavaScript -->
|
|
182
|
+
</tbody>
|
|
183
|
+
</table>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<script>
|
|
188
|
+
async function loadMetrics() {
|
|
189
|
+
try {
|
|
190
|
+
const response = await fetch('/api/metrics');
|
|
191
|
+
const data = await response.json();
|
|
192
|
+
|
|
193
|
+
// Update stats
|
|
194
|
+
const statsHtml = \`
|
|
195
|
+
<div class="stat-card">
|
|
196
|
+
<div class="stat-value">\${data.total}</div>
|
|
197
|
+
<div class="stat-label">Total Services</div>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="stat-card">
|
|
200
|
+
<div class="stat-value">\${data.running}</div>
|
|
201
|
+
<div class="stat-label">Running</div>
|
|
202
|
+
</div>
|
|
203
|
+
<div class="stat-card">
|
|
204
|
+
<div class="stat-value">\${data.totalMemory}</div>
|
|
205
|
+
<div class="stat-label">Total Memory</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="stat-card">
|
|
208
|
+
<div class="stat-value">\${data.lastUpdate}</div>
|
|
209
|
+
<div class="stat-label">Last Update</div>
|
|
210
|
+
</div>
|
|
211
|
+
\`;
|
|
212
|
+
document.getElementById('stats').innerHTML = statsHtml;
|
|
213
|
+
|
|
214
|
+
// Update services table
|
|
215
|
+
const tbody = document.getElementById('services-tbody');
|
|
216
|
+
tbody.innerHTML = data.services.map(service => \`
|
|
217
|
+
<tr>
|
|
218
|
+
<td><strong>\${service.name}</strong></td>
|
|
219
|
+
<td class="state-\${service.active === 'active' ? 'active' : 'failed'}">\${service.state}</td>
|
|
220
|
+
<td class="status-\${service.health}">\${service.health.toUpperCase()}</td>
|
|
221
|
+
<td>\${service.cpu}</td>
|
|
222
|
+
<td>\${service.memory}</td>
|
|
223
|
+
<td>\${service.uptime}</td>
|
|
224
|
+
<td>\${service.tasks}</td>
|
|
225
|
+
</tr>
|
|
226
|
+
\`).join('');
|
|
227
|
+
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error('Error loading metrics:', error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Load metrics on page load
|
|
234
|
+
loadMetrics();
|
|
235
|
+
|
|
236
|
+
// Auto-refresh every 5 seconds
|
|
237
|
+
setInterval(loadMetrics, 5000);
|
|
238
|
+
</script>
|
|
239
|
+
</body>
|
|
240
|
+
</html>
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
serve({
|
|
244
|
+
port: process.env.WEB_DASHBOARD_PORT || 8080,
|
|
245
|
+
fetch(req) {
|
|
246
|
+
const url = new URL(req.url);
|
|
247
|
+
|
|
248
|
+
if (url.pathname === '/api/metrics') {
|
|
249
|
+
const services = getMetrics();
|
|
250
|
+
const running = services.filter(s => s.active === 'active').length;
|
|
251
|
+
const totalMemory = services.reduce((sum, s) => {
|
|
252
|
+
if (s.memory !== '-') {
|
|
253
|
+
const match = s.memory.match(/([\d.]+)(B|KB|MB|GB)/);
|
|
254
|
+
if (match) {
|
|
255
|
+
const [, value, unit] = match;
|
|
256
|
+
const bytes = Number(value) * Math.pow(1024, ['B', 'KB', 'MB', 'GB'].indexOf(unit));
|
|
257
|
+
return sum + bytes;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return sum;
|
|
261
|
+
}, 0);
|
|
262
|
+
|
|
263
|
+
const data = {
|
|
264
|
+
services,
|
|
265
|
+
total: services.length,
|
|
266
|
+
running,
|
|
267
|
+
totalMemory: formatMemory(totalMemory),
|
|
268
|
+
lastUpdate: new Date().toLocaleTimeString(),
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
return new Response(JSON.stringify(data), {
|
|
272
|
+
headers: { 'Content-Type': 'application/json' }
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (url.pathname === '/') {
|
|
277
|
+
return new Response(HTML_TEMPLATE, {
|
|
278
|
+
headers: { 'Content-Type': 'text/html' }
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return new Response('Not Found', { status: 404 });
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
console.log(`🌐 BS9 Web Dashboard running on http://localhost:${process.env.WEB_DASHBOARD_PORT || 8080}`);
|