momodebug 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,19 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MomoDebug CLI - Performance Monitoring Dashboard
5
+ * Usage: npx momodebug
6
+ */
7
+
8
+ import { fileURLToPath } from 'url';
9
+ import { dirname, join } from 'path';
10
+
11
+ // Get the package directory (where this script lives)
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ // Set the package root for the server to find views/public
16
+ process.env.MOMODEBUG_ROOT = join(__dirname, '..');
17
+
18
+ // Import and run the server
19
+ import('../src/index.js');
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "momodebug",
3
+ "version": "1.0.0",
4
+ "description": "Real-time performance monitoring dashboard for Android and iOS apps",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "momodebug": "./bin/momodebug.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "public"
14
+ ],
15
+ "scripts": {
16
+ "start": "node src/index.js",
17
+ "test": "echo \"Error: no test specified\" && exit 1"
18
+ },
19
+ "keywords": [
20
+ "performance",
21
+ "monitoring",
22
+ "android",
23
+ "ios",
24
+ "debug",
25
+ "dashboard"
26
+ ],
27
+ "author": "",
28
+ "license": "ISC",
29
+ "dependencies": {
30
+ "cors": "^2.8.5",
31
+ "ejs": "^3.1.10",
32
+ "express": "^5.2.1",
33
+ "express-ejs-layouts": "^2.5.1",
34
+ "uuid": "^13.0.0",
35
+ "ws": "^8.18.3"
36
+ }
37
+ }
@@ -0,0 +1,292 @@
1
+ :root {
2
+ --bg-dark: #080511;
3
+ --surface-dark: #0f0a1e;
4
+ --card-bg: rgba(20, 15, 40, 0.7);
5
+ --card-border: rgba(255, 255, 255, 0.1);
6
+ --text-primary: #ffffff;
7
+ --text-secondary: #9ea3b0;
8
+ --accent-purple: #a855f7;
9
+ --accent-blue: #3b82f6;
10
+ --accent-cyan: #06b6d4;
11
+ --status-ok: #22c55e;
12
+ --status-warning: #eab308;
13
+ --status-critical: #ef4444;
14
+ --glow: rgba(168, 85, 247, 0.4);
15
+ }
16
+
17
+ * {
18
+ margin: 0;
19
+ padding: 0;
20
+ box-sizing: border-box;
21
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
22
+ }
23
+
24
+ body {
25
+ background-color: var(--bg-dark);
26
+ color: var(--text-primary);
27
+ line-height: 1.6;
28
+ min-height: 100vh;
29
+ background-image:
30
+ radial-gradient(circle at 10% 20%, rgba(168, 85, 247, 0.1) 0%, transparent 40%),
31
+ radial-gradient(circle at 90% 80%, rgba(59, 130, 246, 0.1) 0%, transparent 40%);
32
+ background-attachment: fixed;
33
+ }
34
+
35
+ .container {
36
+ max-width: 1400px;
37
+ margin: 0 auto;
38
+ padding: 2.5rem;
39
+ }
40
+
41
+ header {
42
+ margin-bottom: 3rem;
43
+ display: flex;
44
+ justify-content: space-between;
45
+ align-items: center;
46
+ }
47
+
48
+ .logo {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 1rem;
52
+ }
53
+
54
+ .logo-icon {
55
+ width: 44px;
56
+ height: 44px;
57
+ background: linear-gradient(135deg, var(--accent-purple), var(--accent-blue));
58
+ border-radius: 12px;
59
+ box-shadow: 0 0 20px var(--glow);
60
+ }
61
+
62
+ .logo h1 {
63
+ font-size: 1.75rem;
64
+ font-weight: 800;
65
+ letter-spacing: -0.025em;
66
+ background: linear-gradient(to right, #fff, #9ea3b0);
67
+ -webkit-background-clip: text;
68
+ -webkit-text-fill-color: transparent;
69
+ }
70
+
71
+ nav {
72
+ display: flex;
73
+ background: rgba(255, 255, 255, 0.05);
74
+ padding: 0.4rem;
75
+ border-radius: 12px;
76
+ border: 1px solid var(--card-border);
77
+ }
78
+
79
+ .nav-link {
80
+ color: var(--text-secondary);
81
+ text-decoration: none;
82
+ padding: 0.6rem 1.25rem;
83
+ border-radius: 8px;
84
+ font-weight: 600;
85
+ font-size: 0.9rem;
86
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
87
+ }
88
+
89
+ .nav-link.active {
90
+ color: var(--text-primary);
91
+ background: rgba(255, 255, 255, 0.1);
92
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
93
+ }
94
+
95
+ /* Dashboard UI */
96
+ .dashboard-header {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: 0.5rem;
100
+ margin-bottom: 2.5rem;
101
+ }
102
+
103
+ .live-status {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 0.75rem;
107
+ font-weight: 700;
108
+ font-size: 0.85rem;
109
+ color: var(--status-ok);
110
+ text-transform: uppercase;
111
+ letter-spacing: 0.05em;
112
+ }
113
+
114
+ .dot {
115
+ width: 8px;
116
+ height: 8px;
117
+ background-color: currentColor;
118
+ border-radius: 50%;
119
+ box-shadow: 0 0 10px currentColor;
120
+ animation: pulse 2.5s infinite;
121
+ }
122
+
123
+ @keyframes pulse {
124
+ 0% {
125
+ opacity: 1;
126
+ transform: scale(1);
127
+ }
128
+
129
+ 50% {
130
+ opacity: 0.3;
131
+ transform: scale(1.4);
132
+ }
133
+
134
+ 100% {
135
+ opacity: 1;
136
+ transform: scale(1);
137
+ }
138
+ }
139
+
140
+ /* Metrics Cards */
141
+ .metrics-grid {
142
+ display: grid;
143
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
144
+ gap: 2rem;
145
+ }
146
+
147
+ .glass {
148
+ background: var(--card-bg);
149
+ backdrop-filter: blur(20px);
150
+ -webkit-backdrop-filter: blur(20px);
151
+ border: 1px solid var(--card-border);
152
+ border-radius: 24px;
153
+ padding: 1.75rem;
154
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
155
+ position: relative;
156
+ overflow: hidden;
157
+ display: flex;
158
+ flex-direction: column;
159
+ }
160
+
161
+ .glass::before {
162
+ content: '';
163
+ position: absolute;
164
+ top: 0;
165
+ left: 0;
166
+ right: 0;
167
+ height: 1px;
168
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
169
+ }
170
+
171
+ .metric-card:hover {
172
+ transform: translateY(-8px);
173
+ border-color: var(--accent-purple);
174
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), 0 0 20px rgba(168, 85, 247, 0.1);
175
+ }
176
+
177
+ .metric-header {
178
+ display: flex;
179
+ justify-content: space-between;
180
+ align-items: flex-start;
181
+ margin-bottom: 1rem;
182
+ }
183
+
184
+ .metric-title {
185
+ color: var(--text-secondary);
186
+ font-size: 0.9rem;
187
+ font-weight: 700;
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 0.5rem;
191
+ }
192
+
193
+ .metric-value-container {
194
+ display: flex;
195
+ align-items: baseline;
196
+ gap: 0.5rem;
197
+ margin-bottom: 1rem;
198
+ }
199
+
200
+ .metric-value {
201
+ font-size: 2.75rem;
202
+ font-weight: 800;
203
+ letter-spacing: -0.05em;
204
+ color: #fff;
205
+ }
206
+
207
+ .metric-unit {
208
+ font-size: 1rem;
209
+ color: var(--text-secondary);
210
+ font-weight: 500;
211
+ }
212
+
213
+ /* Chart Container */
214
+ .chart-container {
215
+ height: 80px;
216
+ margin-top: auto;
217
+ width: 100%;
218
+ }
219
+
220
+ /* Specialized Card Styles */
221
+ #card-fps .metric-value {
222
+ color: var(--status-ok);
223
+ text-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
224
+ }
225
+
226
+ /* Table Styles */
227
+ .comparison-table {
228
+ width: 100%;
229
+ border-spacing: 0;
230
+ margin-top: 2rem;
231
+ }
232
+
233
+ .comparison-table th {
234
+ padding: 1rem 1.5rem;
235
+ text-align: left;
236
+ color: var(--text-secondary);
237
+ font-size: 0.8rem;
238
+ font-weight: 700;
239
+ text-transform: uppercase;
240
+ letter-spacing: 0.05em;
241
+ }
242
+
243
+ .comparison-table td {
244
+ padding: 1.5rem;
245
+ background: rgba(255, 255, 255, 0.02);
246
+ border-top: 1px solid var(--card-border);
247
+ }
248
+
249
+ .comparison-table tr:first-child td {
250
+ border-top: none;
251
+ }
252
+
253
+ .status-badge {
254
+ padding: 0.5rem 1rem;
255
+ border-radius: 12px;
256
+ font-weight: 700;
257
+ font-size: 0.75rem;
258
+ display: inline-flex;
259
+ align-items: center;
260
+ gap: 0.4rem;
261
+ }
262
+
263
+ .status-ok {
264
+ background: rgba(34, 197, 94, 0.15);
265
+ color: #4ade80;
266
+ }
267
+
268
+ .status-warning {
269
+ background: rgba(234, 179, 8, 0.15);
270
+ color: #fbbf24;
271
+ }
272
+
273
+ .status-critical {
274
+ background: rgba(239, 68, 68, 0.15);
275
+ color: #f87171;
276
+ }
277
+
278
+ @media (max-width: 768px) {
279
+ .container {
280
+ padding: 1.5rem;
281
+ }
282
+
283
+ header {
284
+ flex-direction: column;
285
+ gap: 1.5rem;
286
+ align-items: flex-start;
287
+ }
288
+
289
+ .metrics-grid {
290
+ grid-template-columns: 1fr;
291
+ }
292
+ }
@@ -0,0 +1,99 @@
1
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
2
+ const ws = new WebSocket(`${protocol}//${window.location.host}?dashboard=true`);
3
+
4
+ const charts = {};
5
+
6
+ const metricConfigs = {
7
+ 'cpu_ms': { label: 'CPU Time', color: '#a855f7' },
8
+ 'memory_mb': { label: 'Memory (PSS)', color: '#3b82f6' },
9
+ 'frame_ms': { label: 'Frame Time', color: '#06b6d4' },
10
+ 'fps': { label: 'FPS', color: '#22c55e' },
11
+ 'disk_read_kbps': { label: 'Disk Read', color: '#f59e0b' },
12
+ 'disk_write_kbps': { label: 'Disk Write', color: '#ef4444' },
13
+ 'network_total_mb': { label: 'Net Total', color: '#6366f1' }
14
+ };
15
+
16
+ // Initialize charts
17
+ Object.entries(metricConfigs).forEach(([key, config]) => {
18
+ const ctx = document.getElementById(`chart-${key}`).getContext('2d');
19
+ charts[key] = new Chart(ctx, {
20
+ type: 'line',
21
+ data: {
22
+ labels: Array(20).fill(''),
23
+ datasets: [{
24
+ data: Array(20).fill(0),
25
+ borderColor: config.color,
26
+ borderWidth: 2,
27
+ pointRadius: 0,
28
+ fill: true,
29
+ backgroundColor: `${config.color}20`,
30
+ tension: 0.4
31
+ }]
32
+ },
33
+ options: {
34
+ responsive: true,
35
+ maintainAspectRatio: false,
36
+ plugins: { legend: { display: false } },
37
+ scales: {
38
+ x: { display: false },
39
+ y: {
40
+ display: false,
41
+ beginAtZero: true
42
+ }
43
+ },
44
+ animation: { duration: 0 }
45
+ }
46
+ });
47
+ });
48
+
49
+ ws.onopen = () => {
50
+ console.log('✅ Connected to metrics server');
51
+ };
52
+
53
+ ws.onmessage = (event) => {
54
+ const message = JSON.parse(event.data);
55
+
56
+ if (message.type === 'live_metric') {
57
+ const sample = message.data;
58
+ const valElement = document.getElementById(`val-${sample.metric}`);
59
+
60
+ if (valElement) {
61
+ animateValue(valElement, sample.value);
62
+
63
+ // Update Chart
64
+ const chart = charts[sample.metric];
65
+ if (chart) {
66
+ chart.data.datasets[0].data.push(sample.value);
67
+ chart.data.datasets[0].data.shift();
68
+ chart.update('none');
69
+ }
70
+
71
+ // Sync FPS if and only if frame_ms comes in (legacy fallback)
72
+ if (sample.metric === 'frame_ms') {
73
+ const fps = Math.round(1000 / sample.value);
74
+ const fpsVal = document.getElementById('val-fps');
75
+ if (fpsVal) {
76
+ animateValue(fpsVal, fps);
77
+ const fpsChart = charts['fps'];
78
+ if (fpsChart) {
79
+ fpsChart.data.datasets[0].data.push(fps);
80
+ fpsChart.data.datasets[0].data.shift();
81
+ fpsChart.update('none');
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ };
88
+
89
+ function animateValue(obj, value) {
90
+ const rounded = Math.round(value * 10) / 10;
91
+ obj.innerText = rounded;
92
+ }
93
+
94
+ ws.onclose = () => {
95
+ console.log('❌ Disconnected from server');
96
+ setTimeout(() => {
97
+ window.location.reload();
98
+ }, 5000);
99
+ };
@@ -0,0 +1,59 @@
1
+ import { spawn } from 'child_process';
2
+ import { broadcastToDashboards } from './websocket.js';
3
+
4
+ export function startAdbObserver() {
5
+ console.log('🔌 Starting ADB Logcat Observer (PerfObserverSDK)...');
6
+
7
+ const adb = spawn('adb', ['logcat', '-s', 'PerfObserverSDK']);
8
+
9
+ adb.stdout.on('data', (data) => {
10
+ const lines = data.toString().split('\n');
11
+ for (const line of lines) {
12
+ if (line.includes('METRIC_UPDATE:')) {
13
+ try {
14
+ const jsonStr = line.split('METRIC_UPDATE:')[1].trim();
15
+ const rawData = JSON.parse(jsonStr);
16
+
17
+ // Map ADB fields to internal metric names
18
+ const mappings = {
19
+ 'fps': 'fps',
20
+ 'frameTimeMs': 'frame_ms',
21
+ 'cpuUsagePercent': 'cpu_ms',
22
+ 'memoryPssMb': 'memory_mb',
23
+ 'diskReadKbps': 'disk_read_kbps',
24
+ 'diskWriteKbps': 'disk_write_kbps',
25
+ 'networkTotalMb': 'network_total_mb'
26
+ };
27
+
28
+ Object.entries(mappings).forEach(([adbKey, metricName]) => {
29
+ if (rawData[adbKey] !== undefined) {
30
+ broadcastToDashboards({
31
+ metric: metricName,
32
+ value: rawData[adbKey],
33
+ timestamp: rawData.timestamp,
34
+ appVersion: rawData.appVersion || 'Live',
35
+ scenario: rawData.scenario || 'Default',
36
+ deviceClass: rawData.deviceClass || 'ADB'
37
+ });
38
+ }
39
+ });
40
+
41
+ console.log(`📈 ADB Bundle processed at ${rawData.timestamp}`);
42
+ } catch (e) {
43
+ console.error('❌ Error parsing ADB logcat line:', e.message);
44
+ }
45
+ }
46
+ }
47
+ });
48
+
49
+ adb.stderr.on('data', (data) => {
50
+ console.error(`⚠️ ADB Error: ${data}`);
51
+ });
52
+
53
+ adb.on('close', (code) => {
54
+ console.log(`📡 ADB process exited with code ${code}. Restarting in 5s...`);
55
+ setTimeout(startAdbObserver, 5000);
56
+ });
57
+
58
+ return adb;
59
+ }
package/src/index.js ADDED
@@ -0,0 +1,88 @@
1
+ // Main unified server with Express and WebSocket
2
+ import express from 'express';
3
+ import { createServer } from 'http';
4
+ import { exec } from 'child_process';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import cors from 'cors';
8
+ import expressLayouts from 'express-ejs-layouts';
9
+ import { storage } from './storage.js';
10
+ import { setupWebSocket } from './websocket.js';
11
+ import { seedMockData } from './mockData.js';
12
+ import { startAdbObserver } from './adbObserver.js';
13
+ import { startIosObserver } from './iosObserver.js';
14
+ import { startIosDeviceObserver } from './iosDeviceObserver.js';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+
19
+ // Use package root when run via npx, otherwise use relative paths
20
+ const packageRoot = process.env.MOMODEBUG_ROOT || path.join(__dirname, '..');
21
+
22
+ const app = express();
23
+ const httpServer = createServer(app);
24
+ const PORT = process.env.PORT || 6969;
25
+
26
+ // Setup Middleware
27
+ app.use(cors());
28
+ app.use(express.json());
29
+ app.use(express.static(path.join(packageRoot, 'public')));
30
+
31
+ // Set EJS as view engine
32
+ app.set('view engine', 'ejs');
33
+ app.set('views', path.join(__dirname, 'views'));
34
+ app.use(expressLayouts);
35
+ app.set('layout', 'layout');
36
+
37
+ // Dashboard route (SSR)
38
+ app.get('/', (req, res) => {
39
+ const versions = storage.getVersions();
40
+ const scenarios = storage.getScenarios();
41
+ res.render('dashboard', {
42
+ versions,
43
+ scenarios,
44
+ title: 'Perf Observer | Realtime'
45
+ });
46
+ });
47
+
48
+ // Comparison route (SSR)
49
+ app.get('/compare', (req, res) => {
50
+ const { from, to, scenario, deviceClass } = req.query;
51
+ const versions = storage.getVersions();
52
+ const scenarios = storage.getScenarios();
53
+
54
+ let comparisons = [];
55
+ if (from && to) {
56
+ comparisons = storage.compareVersions(from, to, scenario, deviceClass);
57
+ }
58
+
59
+ res.render('compare', {
60
+ from, to, scenario, deviceClass,
61
+ versions, scenarios, comparisons,
62
+ title: 'Perf Observer | Compare'
63
+ });
64
+ });
65
+
66
+ // API Routes (Legacy compat)
67
+ app.get('/api/versions', (req, res) => res.json({ versions: storage.getVersions() }));
68
+ app.get('/api/scenarios', (req, res) => res.json({ scenarios: storage.getScenarios() }));
69
+
70
+ // Initialize WebSocket
71
+ setupWebSocket(httpServer);
72
+
73
+ // Seed initial data
74
+ seedMockData();
75
+
76
+ // Start observers
77
+ startAdbObserver(); // Android (adb logcat)
78
+ startIosObserver(); // iOS Simulator (simctl log stream)
79
+ startIosDeviceObserver(); // iOS Real Device (idevicesyslog)
80
+
81
+ // Start server
82
+ httpServer.listen(PORT, () => {
83
+ console.log(`\n🚀 Unified Performance Server running at http://localhost:${PORT}`);
84
+ console.log(`📡 WebSocket listening for metrics from SDK\n`);
85
+
86
+ // Auto-open browser
87
+ exec(`open http://localhost:${PORT}`);
88
+ });
@@ -0,0 +1,83 @@
1
+ import { spawn } from 'child_process';
2
+ import { broadcastToDashboards } from './websocket.js';
3
+
4
+ export function startIosDeviceObserver() {
5
+ console.log('📱 Starting iOS Real Device Observer (idevicesyslog)...');
6
+
7
+ // Check if idevicesyslog is installed
8
+ const checkCmd = spawn('which', ['idevicesyslog']);
9
+
10
+ checkCmd.on('close', (code) => {
11
+ if (code !== 0) {
12
+ console.log('⚠️ idevicesyslog not found. Install with: brew install libimobiledevice');
13
+ console.log(' Real device observation will be skipped.');
14
+ return;
15
+ }
16
+
17
+ startDeviceSyslog();
18
+ });
19
+ }
20
+
21
+ function startDeviceSyslog() {
22
+ // Stream logs from connected iOS device
23
+ // Filter for PerfObserverSDK subsystem
24
+ const deviceLog = spawn('idevicesyslog', ['--process', 'ExampleApp']);
25
+
26
+ deviceLog.stdout.on('data', (data) => {
27
+ const output = data.toString();
28
+ const lines = output.split('\n');
29
+
30
+ for (const line of lines) {
31
+ if (line.includes('METRIC_UPDATE:')) {
32
+ try {
33
+ // Extract only the JSON part using regex
34
+ const match = line.match(/METRIC_UPDATE:\s*(\{.*\})/);
35
+ if (!match) continue;
36
+
37
+ const jsonStr = match[1];
38
+ const rawData = JSON.parse(jsonStr);
39
+
40
+ // Map fields (same as simulator)
41
+ const mappings = {
42
+ 'fps': 'fps',
43
+ 'frameTimeMs': 'frame_ms',
44
+ 'cpuUsagePercent': 'cpu_ms',
45
+ 'memoryPssMb': 'memory_mb',
46
+ 'diskReadKbps': 'disk_read_kbps',
47
+ 'diskWriteKbps': 'disk_write_kbps',
48
+ 'networkTotalMb': 'network_total_mb'
49
+ };
50
+
51
+ Object.entries(mappings).forEach(([key, metricName]) => {
52
+ if (rawData[key] !== undefined) {
53
+ broadcastToDashboards({
54
+ metric: metricName,
55
+ value: rawData[key],
56
+ timestamp: rawData.timestamp || Date.now(),
57
+ appVersion: rawData.appVersion || 'iOS-Live',
58
+ scenario: rawData.scenario || 'Default',
59
+ deviceClass: rawData.deviceClass || 'iOS-Device'
60
+ });
61
+ }
62
+ });
63
+
64
+ console.log(`📱 iOS Device Metric processed at ${rawData.timestamp || 'now'}`);
65
+ } catch (e) {
66
+ console.error('❌ Error parsing iOS device log:', e.message);
67
+ }
68
+ }
69
+ }
70
+ });
71
+
72
+ deviceLog.stderr.on('data', (data) => {
73
+ const err = data.toString();
74
+ if (!err.includes('No device found')) {
75
+ console.error(`⚠️ iOS Device Observer Error: ${err}`);
76
+ }
77
+ });
78
+
79
+ deviceLog.on('close', (code) => {
80
+ console.log(`📱 idevicesyslog exited with code ${code}. Restarting in 10s...`);
81
+ setTimeout(startDeviceSyslog, 10000);
82
+ });
83
+ }
@@ -0,0 +1,94 @@
1
+ import { spawn, exec } from 'child_process';
2
+ import { broadcastToDashboards } from './websocket.js';
3
+
4
+ export function startIosObserver() {
5
+ console.log('🍎 Starting iOS Simulator Observer (PerfObserverSDK)...');
6
+
7
+ // First check if any simulator is booted
8
+ exec('xcrun simctl list devices | grep Booted', (error, stdout, stderr) => {
9
+ if (error || !stdout.trim()) {
10
+ console.log('⚠️ No iOS Simulator is booted. Waiting for simulator...');
11
+ // Retry after 30 seconds
12
+ setTimeout(startIosObserver, 30000);
13
+ return;
14
+ }
15
+
16
+ console.log('✅ Found booted simulator, starting log stream...');
17
+ startLogStream();
18
+ });
19
+ }
20
+
21
+ function startLogStream() {
22
+ // Command to stream logs from the booted simulator
23
+ // Filter by subsystem (set in os_log) to capture SDK logs
24
+ const iosLog = spawn('xcrun', [
25
+ 'simctl',
26
+ 'spawn',
27
+ 'booted',
28
+ 'log',
29
+ 'stream',
30
+ '--predicate',
31
+ 'subsystem == "PerfObserverSDK"'
32
+ ]);
33
+
34
+ iosLog.stdout.on('data', (data) => {
35
+ const output = data.toString();
36
+ const lines = output.split('\n');
37
+
38
+ for (const line of lines) {
39
+ if (line.includes('METRIC_UPDATE:')) {
40
+ try {
41
+ const jsonStr = line.split('METRIC_UPDATE:')[1].trim();
42
+ const rawData = JSON.parse(jsonStr);
43
+
44
+ // Map fields (same as Android for consistency)
45
+ const mappings = {
46
+ 'fps': 'fps',
47
+ 'frameTimeMs': 'frame_ms',
48
+ 'cpuUsagePercent': 'cpu_ms',
49
+ 'memoryPssMb': 'memory_mb',
50
+ 'diskReadKbps': 'disk_read_kbps',
51
+ 'diskWriteKbps': 'disk_write_kbps',
52
+ 'networkTotalMb': 'network_total_mb'
53
+ };
54
+
55
+ Object.entries(mappings).forEach(([key, metricName]) => {
56
+ if (rawData[key] !== undefined) {
57
+ broadcastToDashboards({
58
+ metric: metricName,
59
+ value: rawData[key],
60
+ timestamp: rawData.timestamp || Date.now(),
61
+ appVersion: rawData.appVersion || 'iOS-Live',
62
+ scenario: rawData.scenario || 'Default',
63
+ deviceClass: rawData.deviceClass || 'iOS-Sim'
64
+ });
65
+ }
66
+ });
67
+
68
+ console.log(`🍎 iOS Metric processed at ${rawData.timestamp || 'now'}`);
69
+ } catch (e) {
70
+ console.error('❌ Error parsing iOS log line:', e.message);
71
+ }
72
+ }
73
+ }
74
+ });
75
+
76
+ iosLog.stderr.on('data', (data) => {
77
+ const err = data.toString();
78
+ if (!err.includes('No devices are booted')) {
79
+ console.error(`⚠️ iOS Observer Error: ${err}`);
80
+ }
81
+ });
82
+
83
+ iosLog.on('close', (code) => {
84
+ if (code === 148 || code === 1) {
85
+ console.log('⚠️ Simulator stopped or not available. Waiting for simulator...');
86
+ setTimeout(startIosObserver, 30000);
87
+ } else {
88
+ console.log(`📡 iOS log process exited with code ${code}. Restarting in 10s...`);
89
+ setTimeout(startIosObserver, 10000);
90
+ }
91
+ });
92
+
93
+ return iosLog;
94
+ }
@@ -0,0 +1,35 @@
1
+ import { storage } from './storage.js';
2
+
3
+ export function seedMockData() {
4
+ const versions = ['1.0.0', '1.1.0', '1.2.0'];
5
+ const scenarios = ['HOME_LOAD', 'FEED_SCROLL', 'IMAGE_GRID'];
6
+ const deviceClasses = ['high', 'mid', 'low'];
7
+ const metrics = ['cpu_ms', 'memory_mb', 'frame_ms'];
8
+
9
+ versions.forEach(v => {
10
+ scenarios.forEach(s => {
11
+ deviceClasses.forEach(dc => {
12
+ metrics.forEach(m => {
13
+ // Generate 10 samples per group
14
+ for (let i = 0; i < 10; i++) {
15
+ const base = m === 'cpu_ms' ? 100 : (m === 'memory_mb' ? 150 : 16);
16
+ // Add some drift for different versions
17
+ const versionDrift = v === '1.2.0' ? 1.2 : 1.0;
18
+ const value = base * versionDrift + (Math.random() * 20 - 10);
19
+
20
+ storage.addSample({
21
+ appVersion: v,
22
+ scenario: s,
23
+ deviceClass: dc,
24
+ metric: m,
25
+ value: value,
26
+ timestamp: Date.now() - (Math.random() * 86400000)
27
+ });
28
+ }
29
+ });
30
+ });
31
+ });
32
+ });
33
+
34
+ console.log('✅ Mock data seeded for comparison');
35
+ }
package/src/storage.js ADDED
@@ -0,0 +1,109 @@
1
+ // In-memory storage and aggregation for performance metrics
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ class StorageService {
5
+ constructor() {
6
+ this.samples = [];
7
+ this.aggregates = {}; // key: version_scenario_deviceClass_metric
8
+ this.versions = new Set();
9
+ this.scenarios = new Set();
10
+ }
11
+
12
+ addSample(sample) {
13
+ const id = uuidv4();
14
+ const enrichedSample = { ...sample, id, serverTimestamp: Date.now() };
15
+
16
+ this.samples.push(enrichedSample);
17
+ this.versions.add(sample.appVersion);
18
+ this.scenarios.add(sample.scenario);
19
+
20
+ this.updateAggregate(enrichedSample);
21
+ return enrichedSample;
22
+ }
23
+
24
+ updateAggregate(sample) {
25
+ const key = `${sample.appVersion}_${sample.scenario}_${sample.deviceClass}_${sample.metric}`;
26
+
27
+ if (!this.aggregates[key]) {
28
+ this.aggregates[key] = {
29
+ version: sample.appVersion,
30
+ scenario: sample.scenario,
31
+ deviceClass: sample.deviceClass,
32
+ metric: sample.metric,
33
+ values: [],
34
+ count: 0,
35
+ mean: 0,
36
+ p95: 0,
37
+ max: 0
38
+ };
39
+ }
40
+
41
+ const agg = this.aggregates[key];
42
+ agg.values.push(sample.value);
43
+ agg.count = agg.values.length;
44
+
45
+ // Simple moving average for performance (in real world we'd use better windowing)
46
+ agg.mean = agg.values.reduce((a, b) => a + b, 0) / agg.count;
47
+
48
+ // P95 calculation
49
+ const sorted = [...agg.values].sort((a, b) => a - b);
50
+ const p95Idx = Math.floor(sorted.length * 0.95);
51
+ agg.p95 = sorted[p95Idx];
52
+
53
+ agg.max = Math.max(...agg.values);
54
+ }
55
+
56
+ getVersions() {
57
+ return Array.from(this.versions).sort().reverse();
58
+ }
59
+
60
+ getScenarios() {
61
+ return Array.from(this.scenarios).sort();
62
+ }
63
+
64
+ getAggregatesForVersion(version) {
65
+ return Object.values(this.aggregates).filter(a => a.version === version);
66
+ }
67
+
68
+ compareVersions(fromVersion, toVersion, scenario = null, deviceClass = null) {
69
+ const fromAggs = this.getAggregatesForVersion(fromVersion);
70
+ const toAggs = this.getAggregatesForVersion(toVersion);
71
+
72
+ const comparisons = [];
73
+
74
+ toAggs.forEach(to => {
75
+ if (scenario && to.scenario !== scenario) return;
76
+ if (deviceClass && to.deviceClass !== deviceClass) return;
77
+
78
+ const from = fromAggs.find(f =>
79
+ f.scenario === to.scenario &&
80
+ f.deviceClass === to.deviceClass &&
81
+ f.metric === to.metric
82
+ );
83
+
84
+ if (from) {
85
+ const meanDelta = ((to.mean - from.mean) / from.mean) * 100;
86
+ const p95Delta = ((to.p95 - from.p95) / from.p95) * 100;
87
+ const maxDelta = ((to.max - from.max) / from.max) * 100;
88
+
89
+ let status = 'OK';
90
+ if (p95Delta > 20 || meanDelta > 15) status = 'WARNING';
91
+ if (p95Delta > 40 || meanDelta > 25) status = 'CRITICAL';
92
+
93
+ comparisons.push({
94
+ metric: to.metric,
95
+ scenario: to.scenario,
96
+ deviceClass: to.deviceClass,
97
+ from: { version: fromVersion, mean: from.mean, p95: from.p95, max: from.max },
98
+ to: { version: toVersion, mean: to.mean, p95: to.p95, max: to.max },
99
+ delta: { mean: meanDelta, p95: p95Delta, max: maxDelta },
100
+ status
101
+ });
102
+ }
103
+ });
104
+
105
+ return comparisons;
106
+ }
107
+ }
108
+
109
+ export const storage = new StorageService();
@@ -0,0 +1,96 @@
1
+ <div class="filter-panel glass" style="padding: 1.5rem; border-radius: 16px;">
2
+ <form action="/compare" method="GET" style="display: flex; gap: 1rem; width: 100%;">
3
+ <div style="flex: 1;">
4
+ <label style="font-size: 0.75rem; color: var(--text-secondary); display: block; margin-bottom: 0.5rem;">From
5
+ Version</label>
6
+ <select name="from" style="width: 100%;">
7
+ <% versions.forEach(v=> { %>
8
+ <option value="<%= v %>" <%=v===from ? 'selected' : '' %>><%= v %>
9
+ </option>
10
+ <% }) %>
11
+ </select>
12
+ </div>
13
+ <div style="flex: 1;">
14
+ <label style="font-size: 0.75rem; color: var(--text-secondary); display: block; margin-bottom: 0.5rem;">To
15
+ Version</label>
16
+ <select name="to" style="width: 100%;">
17
+ <% versions.forEach(v=> { %>
18
+ <option value="<%= v %>" <%=v===to ? 'selected' : '' %>><%= v %>
19
+ </option>
20
+ <% }) %>
21
+ </select>
22
+ </div>
23
+ <div style="flex: 1;">
24
+ <label
25
+ style="font-size: 0.75rem; color: var(--text-secondary); display: block; margin-bottom: 0.5rem;">Scenario</label>
26
+ <select name="scenario" style="width: 100%;">
27
+ <option value="">All Scenarios</option>
28
+ <% scenarios.forEach(s=> { %>
29
+ <option value="<%= s %>" <%=s===scenario ? 'selected' : '' %>><%= s %>
30
+ </option>
31
+ <% }) %>
32
+ </select>
33
+ </div>
34
+ <div style="display: flex; align-items: flex-end;">
35
+ <button type="submit" class="primary">Compare</button>
36
+ </div>
37
+ </form>
38
+ </div>
39
+
40
+ <% if (comparisons.length> 0) { %>
41
+ <div class="glass" style="padding: 1rem; border-radius: 16px; overflow: hidden;">
42
+ <table class="comparison-table">
43
+ <thead>
44
+ <tr>
45
+ <th>Metric</th>
46
+ <th>Scenario</th>
47
+ <th>Device</th>
48
+ <th>Base (Mean)</th>
49
+ <th>Current (Mean)</th>
50
+ <th>Delta</th>
51
+ <th>Status</th>
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ <% comparisons.forEach(item=> { %>
56
+ <tr>
57
+ <td style="font-weight: 700;">
58
+ <%= item.metric.split('_')[0].toUpperCase() %>
59
+ </td>
60
+ <td><span
61
+ style="background: rgba(255,255,255,0.05); padding: 2px 6px; border-radius: 4px; font-size: 0.8rem;">
62
+ <%= item.scenario %>
63
+ </span></td>
64
+ <td>
65
+ <%= item.deviceClass.toUpperCase() %>
66
+ </td>
67
+ <td>
68
+ <%= item.from.mean.toFixed(1) %>
69
+ </td>
70
+ <td style="font-weight: 600;">
71
+ <%= item.to.mean.toFixed(1) %>
72
+ </td>
73
+ <td>
74
+ <span class="delta <%= item.delta.mean > 0 ? 'up' : 'down' %>">
75
+ <%= item.delta.mean> 0 ? '▲' : '▼' %> <%= Math.abs(item.delta.mean).toFixed(1) %>%
76
+ </span>
77
+ </td>
78
+ <td>
79
+ <span class="status-badge status-<%= item.status.toLowerCase() %>">
80
+ <%= item.status %>
81
+ </span>
82
+ </td>
83
+ </tr>
84
+ <% }) %>
85
+ </tbody>
86
+ </table>
87
+ </div>
88
+ <% } else if (from && to) { %>
89
+ <div class="glass" style="padding: 3rem; text-align: center; border-radius: 16px;">
90
+ <p style="color: var(--text-secondary);">No data found for the selected versions.</p>
91
+ </div>
92
+ <% } else { %>
93
+ <div class="glass" style="padding: 3rem; text-align: center; border-radius: 16px;">
94
+ <p style="color: var(--text-secondary);">Select versions to start comparison.</p>
95
+ </div>
96
+ <% } %>
@@ -0,0 +1,112 @@
1
+ <div class="dashboard-header">
2
+ <div class="live-status">
3
+ <div class="dot"></div>
4
+ <span>Live Streaming Active</span>
5
+ </div>
6
+ <p style="color: var(--text-secondary); margin-top: 0.5rem;">Awaiting connections from Android SDK...</p>
7
+ </div>
8
+
9
+ <div class="metrics-grid">
10
+ <!-- CPU Card -->
11
+ <div class="metric-card glass" id="card-cpu_ms">
12
+ <div class="metric-header">
13
+ <span class="metric-title">⚡ CPU Time</span>
14
+ <div class="delta" id="delta-cpu_ms"></div>
15
+ </div>
16
+ <div class="metric-value-container">
17
+ <span id="val-cpu_ms" class="metric-value">--</span>
18
+ <span class="metric-unit">ms</span>
19
+ </div>
20
+ <div class="chart-container">
21
+ <canvas id="chart-cpu_ms"></canvas>
22
+ </div>
23
+ </div>
24
+
25
+ <!-- Memory Card -->
26
+ <div class="metric-card glass" id="card-memory_mb">
27
+ <div class="metric-header">
28
+ <span class="metric-title">💾 Memory (PSS)</span>
29
+ <div class="delta" id="delta-memory_mb"></div>
30
+ </div>
31
+ <div class="metric-value-container">
32
+ <span id="val-memory_mb" class="metric-value">--</span>
33
+ <span class="metric-unit">MB</span>
34
+ </div>
35
+ <div class="chart-container">
36
+ <canvas id="chart-memory_mb"></canvas>
37
+ </div>
38
+ </div>
39
+
40
+ <!-- Frame Card -->
41
+ <div class="metric-card glass" id="card-frame_ms">
42
+ <div class="metric-header">
43
+ <span class="metric-title">🎮 Frame Time</span>
44
+ <div class="delta" id="delta-frame_ms"></div>
45
+ </div>
46
+ <div class="metric-value-container">
47
+ <span id="val-frame_ms" class="metric-value">--</span>
48
+ <span class="metric-unit">ms</span>
49
+ </div>
50
+ <div class="chart-container">
51
+ <canvas id="chart-frame_ms"></canvas>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- FPS Card -->
56
+ <div class="metric-card glass" id="card-fps">
57
+ <div class="metric-header">
58
+ <span class="metric-title">🚀 Realtime FPS</span>
59
+ </div>
60
+ <div class="metric-value-container">
61
+ <span id="val-fps" class="metric-value">--</span>
62
+ <span class="metric-unit">fps</span>
63
+ </div>
64
+ <div class="chart-container">
65
+ <canvas id="chart-fps"></canvas>
66
+ </div>
67
+ </div>
68
+
69
+ <!-- Disk Read Card -->
70
+ <div class="metric-card glass" id="card-disk_read_kbps">
71
+ <div class="metric-header">
72
+ <span class="metric-title">📥 Disk Read</span>
73
+ </div>
74
+ <div class="metric-value-container">
75
+ <span id="val-disk_read_kbps" class="metric-value">--</span>
76
+ <span class="metric-unit">KB/s</span>
77
+ </div>
78
+ <div class="chart-container">
79
+ <canvas id="chart-disk_read_kbps"></canvas>
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Disk Write Card -->
84
+ <div class="metric-card glass" id="card-disk_write_kbps">
85
+ <div class="metric-header">
86
+ <span class="metric-title">📤 Disk Write</span>
87
+ </div>
88
+ <div class="metric-value-container">
89
+ <span id="val-disk_write_kbps" class="metric-value">--</span>
90
+ <span class="metric-unit">KB/s</span>
91
+ </div>
92
+ <div class="chart-container">
93
+ <canvas id="chart-disk_write_kbps"></canvas>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- Network Total Card -->
98
+ <div class="metric-card glass" id="card-network_total_mb">
99
+ <div class="metric-header">
100
+ <span class="metric-title">🌐 Net Total</span>
101
+ </div>
102
+ <div class="metric-value-container">
103
+ <span id="val-network_total_mb" class="metric-value">--</span>
104
+ <span class="metric-unit">MB</span>
105
+ </div>
106
+ <div class="chart-container">
107
+ <canvas id="chart-network_total_mb"></canvas>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <script src="/js/dashboard.js"></script>
@@ -0,0 +1,36 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>
8
+ <%= title %>
9
+ </title>
10
+ <link rel="stylesheet" href="/css/style.css">
11
+ <link rel="preconnect" href="https://fonts.googleapis.com">
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
14
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
15
+ </head>
16
+
17
+ <body>
18
+ <div class="container">
19
+ <header>
20
+ <div class="logo">
21
+ <div class="logo-icon"></div>
22
+ <h1>PerfObserver</h1>
23
+ </div>
24
+ <nav>
25
+ <a href="/" class="nav-link <%= title.includes('Realtime') ? 'active' : '' %>">Realtime</a>
26
+ <a href="/compare" class="nav-link <%= title.includes('Compare') ? 'active' : '' %>">Comparison</a>
27
+ </nav>
28
+ </header>
29
+
30
+ <main>
31
+ <%- body %>
32
+ </main>
33
+ </div>
34
+ </body>
35
+
36
+ </html>
@@ -0,0 +1,62 @@
1
+ // WebSocket handler for metrics ingestion and real-time dashboard updates
2
+ import { WebSocketServer } from 'ws';
3
+ import { storage } from './storage.js';
4
+
5
+ const dashboardClients = new Set();
6
+
7
+ export function broadcastToDashboards(data) {
8
+ const enrichedSample = storage.addSample(data);
9
+ const update = JSON.stringify({
10
+ type: 'live_metric',
11
+ data: enrichedSample
12
+ });
13
+
14
+ dashboardClients.forEach(client => {
15
+ if (client.readyState === 1) {
16
+ client.send(update);
17
+ } else {
18
+ console.log('❌ broadcastToDashboards failed');
19
+ }
20
+ });
21
+ return enrichedSample;
22
+ }
23
+
24
+ export function setupWebSocket(server) {
25
+ const wss = new WebSocketServer({ server });
26
+
27
+ wss.on('connection', (ws, req) => {
28
+ const isDashboard = req.url.includes('dashboard');
29
+
30
+ if (isDashboard) {
31
+ dashboardClients.add(ws);
32
+ console.log('🆕 Browser dashboard connected');
33
+ } else {
34
+ console.log('📱 Android device connected');
35
+ }
36
+
37
+ ws.on('message', (message) => {
38
+ try {
39
+ const event = JSON.parse(message);
40
+ if (event.type === 'metric') {
41
+ // Broadcast to all connected dashboards
42
+ broadcastToDashboards(event.data);
43
+ } else if (event.type === 'ping') {
44
+ ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
45
+ }
46
+ } catch (error) {
47
+ console.error('WebSocket message error:', error);
48
+ }
49
+ });
50
+
51
+ ws.on('close', () => {
52
+ if (isDashboard) {
53
+ dashboardClients.delete(ws);
54
+ console.log('❌ Browser dashboard disconnected');
55
+ } else {
56
+ console.log('❌ Android device disconnected');
57
+ }
58
+ });
59
+ });
60
+
61
+ return wss;
62
+ }