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.
@@ -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}`);