letfixit 0.1.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/.env.example +33 -0
- package/README.md +226 -0
- package/adapters/android/build.gradle +28 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/ChoreographerAdapter.kt +43 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/LogcatReader.kt +64 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/MemoryObserver.kt +46 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/OkHttpInterceptor.kt +53 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/PerfAnalyzer.kt +100 -0
- package/adapters/flutter/lib/perf_analyzer.dart +73 -0
- package/adapters/flutter/lib/src/frame_observer.dart +45 -0
- package/adapters/flutter/lib/src/network_observer.dart +72 -0
- package/adapters/flutter/lib/src/rebuild_observer.dart +57 -0
- package/adapters/flutter/lib/src/universal_event.dart +50 -0
- package/adapters/flutter/lib/src/vm_service_adapter.dart +81 -0
- package/adapters/flutter/pubspec.lock +184 -0
- package/adapters/flutter/pubspec.yaml +13 -0
- package/config/model.manifest.example.json +108 -0
- package/config/model.manifest.json +108 -0
- package/core/schema/universal_event.dart +63 -0
- package/dashboard/dist/assets/index-D5TCSsvB.js +107 -0
- package/dashboard/dist/index.html +118 -0
- package/package.json +62 -0
- package/scripts/encrypt-model.js +86 -0
- package/scripts/fill-manifest.js +67 -0
- package/scripts/load-env.js +20 -0
- package/scripts/model_crypto.js +14 -0
- package/scripts/setup-model.js +269 -0
- package/server/adb_bridge.js +201 -0
- package/server/adb_score.js +77 -0
- package/server/ai_proxy.js +373 -0
- package/server/analysis_engine.js +222 -0
- package/server/android_bridge.js +124 -0
- package/server/android_live.js +167 -0
- package/server/data/device_gpu_db.json +465 -0
- package/server/device_db.js +94 -0
- package/server/device_review.js +146 -0
- package/server/finding_generator.js +56 -0
- package/server/flutter_bridge.js +414 -0
- package/server/index.js +619 -0
- package/server/ios_bridge.js +181 -0
- package/server/model_config.js +54 -0
- package/server/report_template.js +169 -0
- package/server/session_manager.js +370 -0
- package/server/widget_gpu_classifier.js +77 -0
- package/server/widget_triage.js +78 -0
- package/setup.sh +160 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// ── iOS bridge ────────────────────────────────────────────────────────────────
|
|
2
|
+
// Connected-device performance capture for iOS — physical devices (xcrun
|
|
3
|
+
// devicectl, Xcode 15+) and the Simulator (xcrun simctl). Render metrics come
|
|
4
|
+
// from a per-iteration Instruments trace (`xcrun xctrace record`, Core Animation
|
|
5
|
+
// template), which yields an FPS series we convert to frame-time percentiles +
|
|
6
|
+
// jank.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ Unlike the Android path, this cannot be verified without a Mac + device/
|
|
9
|
+
// simulator on hand. Commands follow the documented Xcode 15+ CLI; parsing is
|
|
10
|
+
// defensive and degrades to null metrics (never throws) so the report still
|
|
11
|
+
// renders. The xctrace export schema varies by Xcode version — parseFpsExport()
|
|
12
|
+
// is the spot most likely to need tuning on real hardware.
|
|
13
|
+
const { execFile, spawn } = require('child_process');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
function xc(args, { timeout = 30000 } = {}) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
execFile('xcrun', args, { timeout, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
21
|
+
if (err) return reject(new Error((stderr || err.message || '').toString().trim()));
|
|
22
|
+
resolve(stdout || '');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
|
|
29
|
+
async function isAvailable() {
|
|
30
|
+
try { await xc(['xctrace', 'version']); return true; }
|
|
31
|
+
catch { try { await xc(['simctl', 'help']); return true; } catch { return false; } }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Device discovery ───────────────────────────────────────────────────────────
|
|
35
|
+
// `xctrace list devices` lists physical devices and simulators in one shot.
|
|
36
|
+
function parseXctraceDevices(text) {
|
|
37
|
+
const out = [];
|
|
38
|
+
let section = null;
|
|
39
|
+
for (const rawLine of text.split('\n')) {
|
|
40
|
+
const line = rawLine.trim();
|
|
41
|
+
if (/^==\s*Devices\s*==/.test(line)) { section = 'device'; continue; }
|
|
42
|
+
if (/^==\s*Devices Offline\s*==/.test(line)) { section = 'offline'; continue; }
|
|
43
|
+
if (/^==\s*Simulators\s*==/.test(line)) { section = 'simulator'; continue; }
|
|
44
|
+
if (!line || section === 'offline' || section == null) continue;
|
|
45
|
+
// "iPhone 15 Pro (17.5) (ABCDEF01-2345-...)" or "My Mac (14.5) (UUID)"
|
|
46
|
+
const m = line.match(/^(.*?)\s*\(([\d.]+)\)\s*\(([0-9A-Fa-f-]{8,})\)\s*$/);
|
|
47
|
+
if (!m) continue;
|
|
48
|
+
const [, name, osVersion, id] = m;
|
|
49
|
+
// Under Devices, keep only real iOS-device UDIDs — "8hex-16hex" (A12+) or a
|
|
50
|
+
// 40-hex legacy id. The host Mac uses a standard 8-4-4-4-12 UUID, so this
|
|
51
|
+
// reliably drops it (name heuristics miss custom-named Macs like "…MacBook…").
|
|
52
|
+
if (section === 'device') {
|
|
53
|
+
const isIosUdid = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}$/.test(id) || /^[0-9A-Fa-f]{40}$/.test(id);
|
|
54
|
+
if (!isIosUdid) continue;
|
|
55
|
+
}
|
|
56
|
+
out.push({ platform: 'ios', id, model: name.trim(), osVersion, kind: section });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function listDevices() {
|
|
62
|
+
try {
|
|
63
|
+
const text = await xc(['xctrace', 'list', 'devices']);
|
|
64
|
+
return parseXctraceDevices(text);
|
|
65
|
+
} catch { return []; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function deviceInfo(dev) {
|
|
69
|
+
return { brand: 'Apple', model: dev.model, os: 'iOS', osVersion: dev.osVersion || '', sdk: '', abi: 'arm64' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function isInstalled(dev, bundleId) {
|
|
73
|
+
try {
|
|
74
|
+
if (dev.kind === 'simulator') {
|
|
75
|
+
const out = await xc(['simctl', 'get_app_container', dev.id, bundleId]).catch(() => '');
|
|
76
|
+
return !!out.trim();
|
|
77
|
+
}
|
|
78
|
+
const out = await xc(['devicectl', 'device', 'info', 'apps', '--device', dev.id]).catch(() => '');
|
|
79
|
+
return out.includes(bundleId);
|
|
80
|
+
} catch { return true; } // don't block the run on an uncertain check
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function terminate(dev, bundleId) {
|
|
84
|
+
try {
|
|
85
|
+
if (dev.kind === 'simulator') await xc(['simctl', 'terminate', dev.id, bundleId]);
|
|
86
|
+
// devicectl has no clean force-stop for arbitrary apps; the next xctrace
|
|
87
|
+
// --launch starts a fresh instance, which is sufficient for iteration.
|
|
88
|
+
} catch (_) {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Capture (Instruments / xctrace) ─────────────────────────────────────────────
|
|
92
|
+
// begin: launch the app under an Instruments recording. capture: stop, export,
|
|
93
|
+
// and parse the FPS series → frame-time percentiles.
|
|
94
|
+
async function beginCapture(dev, bundleId) {
|
|
95
|
+
const tracePath = path.join(os.tmpdir(), `parityfix-${Date.now()}.trace`);
|
|
96
|
+
// Boot the simulator if needed so the launch/record succeeds.
|
|
97
|
+
if (dev.kind === 'simulator') {
|
|
98
|
+
await xc(['simctl', 'bootstatus', dev.id, '-b']).catch(() => {});
|
|
99
|
+
}
|
|
100
|
+
// xctrace records AND launches the app; we stop it (SIGINT) when the user is done.
|
|
101
|
+
const args = ['xctrace', 'record', '--template', 'Core Animation',
|
|
102
|
+
'--device', dev.id, '--output', tracePath, '--launch', '--', bundleId];
|
|
103
|
+
const proc = spawn('xcrun', args, { stdio: 'ignore' });
|
|
104
|
+
const failed = new Promise((resolve) => proc.on('error', () => resolve(true)));
|
|
105
|
+
await Promise.race([sleep(1200), failed]); // let recording spin up
|
|
106
|
+
return { proc, tracePath };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function endCapture(dev, bundleId, ctx) {
|
|
110
|
+
const empty = { jankPct: null, p50: null, p90: null, p99: null, fpsAvg: null, fpsMin: null, pssMb: null, cpuPct: null, gpuMemMb: null };
|
|
111
|
+
if (!ctx || !ctx.proc) return empty;
|
|
112
|
+
// Stop the recording gracefully so xctrace finalizes the .trace bundle.
|
|
113
|
+
try { ctx.proc.kill('SIGINT'); } catch (_) {}
|
|
114
|
+
await new Promise((resolve) => {
|
|
115
|
+
let done = false;
|
|
116
|
+
const finish = () => { if (!done) { done = true; resolve(); } };
|
|
117
|
+
ctx.proc.on('exit', finish);
|
|
118
|
+
setTimeout(finish, 12000); // don't hang if xctrace lingers
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
let fps = [];
|
|
122
|
+
try {
|
|
123
|
+
if (fs.existsSync(ctx.tracePath)) {
|
|
124
|
+
// Export the FPS-estimate table to XML, then pull the numbers.
|
|
125
|
+
const xml = await xc(['xctrace', 'export', '--input', ctx.tracePath,
|
|
126
|
+
'--xpath', '/trace-toc/run[@number="1"]/data/table[@schema="core-animation-fps-estimate"]']).catch(() => '');
|
|
127
|
+
fps = parseFpsExport(xml);
|
|
128
|
+
}
|
|
129
|
+
} catch (_) {}
|
|
130
|
+
try { fs.rmSync(ctx.tracePath, { recursive: true, force: true }); } catch (_) {}
|
|
131
|
+
|
|
132
|
+
if (fps.length === 0) return empty;
|
|
133
|
+
return { ...empty, ...fpsToMetrics(fps) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Pure parsers / math ──────────────────────────────────────────────────────
|
|
137
|
+
// Pull FPS sample values out of an xctrace XML export. Schema differs across
|
|
138
|
+
// Xcode versions, so we accept several shapes and fall back to any <fps>NN</fps>.
|
|
139
|
+
function parseFpsExport(xml) {
|
|
140
|
+
if (!xml) return [];
|
|
141
|
+
const vals = [];
|
|
142
|
+
const re = /<fps[^>]*>([\d.]+)<\/fps>/gi;
|
|
143
|
+
let m;
|
|
144
|
+
while ((m = re.exec(xml))) { const v = parseFloat(m[1]); if (v > 0) vals.push(v); }
|
|
145
|
+
if (vals.length === 0) {
|
|
146
|
+
// Fallback: some exports emit fps inside a generic <value fmt="… fps">NN</value>.
|
|
147
|
+
const re2 = /<value[^>]*fps[^>]*>([\d.]+)<\/value>/gi;
|
|
148
|
+
while ((m = re2.exec(xml))) { const v = parseFloat(m[1]); if (v > 0) vals.push(v); }
|
|
149
|
+
}
|
|
150
|
+
return vals;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function percentile(sortedAsc, p) {
|
|
154
|
+
if (!sortedAsc.length) return 0;
|
|
155
|
+
const idx = Math.min(sortedAsc.length - 1, Math.floor((p / 100) * sortedAsc.length));
|
|
156
|
+
return sortedAsc[idx];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Convert an FPS series to the common metric shape. Frame time = 1000/fps, so a
|
|
160
|
+
// LOW fps percentile is a HIGH (worst) frame time — p90 frame time uses the 10th
|
|
161
|
+
// fps percentile, etc. Jank = share of samples under 55fps.
|
|
162
|
+
function fpsToMetrics(fps) {
|
|
163
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
164
|
+
const ascFps = [...fps].sort((a, b) => a - b);
|
|
165
|
+
const frameMs = (f) => (f > 0 ? 1000 / f : 0);
|
|
166
|
+
const jank = fps.filter((f) => f < 55).length;
|
|
167
|
+
return {
|
|
168
|
+
fpsAvg: round(fps.reduce((a, b) => a + b, 0) / fps.length),
|
|
169
|
+
fpsMin: round(Math.min(...fps)),
|
|
170
|
+
jankPct: round((jank / fps.length) * 100),
|
|
171
|
+
p50: round(frameMs(percentile(ascFps, 50))),
|
|
172
|
+
p90: round(frameMs(percentile(ascFps, 10))), // 10th-pctile fps → 90th-pctile frame time
|
|
173
|
+
p99: round(frameMs(percentile(ascFps, 1))),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
isAvailable, listDevices, deviceInfo, isInstalled,
|
|
179
|
+
beginCapture, endCapture, terminate,
|
|
180
|
+
parseXctraceDevices, parseFpsExport, fpsToMetrics,
|
|
181
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// ── Ultron n0-beta — model identity & locations ──────────────────────────────
|
|
2
|
+
// Single source of truth for the local model: its display name, on-disk path,
|
|
3
|
+
// readiness check, and the encrypted-chunk manifest loader. Everything else
|
|
4
|
+
// (setup script, server, ai_proxy) imports from here so the model can be
|
|
5
|
+
// renamed / relocated in one place.
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
|
|
9
|
+
const ROOT = path.join(__dirname, '..');
|
|
10
|
+
|
|
11
|
+
const MODEL_NAME = 'Ultron n0-beta';
|
|
12
|
+
const MODEL_ID = 'ultron-n0-beta';
|
|
13
|
+
const MODEL_DIR = path.join(ROOT, 'bin', 'ultron');
|
|
14
|
+
const MODEL_PATH = path.join(MODEL_DIR, `${MODEL_ID}.gguf`);
|
|
15
|
+
const MIN_BYTES = 100 * 1024 * 1024; // real model is ~2.4 GB; guards against stubs
|
|
16
|
+
|
|
17
|
+
// Max size per encrypted chunk. Smaller chunks host/mirror well, download in
|
|
18
|
+
// parallel-friendly pieces, and resume cheaply. Override with PARITYFIX_CHUNK_MB.
|
|
19
|
+
const CHUNK_MAX_BYTES = (parseInt(process.env.PARITYFIX_CHUNK_MB, 10) || 300) * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
// Shown once after setup and available via /api/ai-status.
|
|
22
|
+
const DISCLAIMER =
|
|
23
|
+
`${MODEL_NAME} is an initial Q4-quantized, fully-local model — fast and private, ` +
|
|
24
|
+
`but it can make mistakes or hallucinate. Treat its suggestions as hints: review ` +
|
|
25
|
+
`before applying, and add a cloud fallback key for higher-stakes analysis.`;
|
|
26
|
+
|
|
27
|
+
function isReady() {
|
|
28
|
+
try { return fs.existsSync(MODEL_PATH) && fs.statSync(MODEL_PATH).size > MIN_BYTES; }
|
|
29
|
+
catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sizeGB() {
|
|
33
|
+
try { return parseFloat((fs.statSync(MODEL_PATH).size / (1024 ** 3)).toFixed(2)); }
|
|
34
|
+
catch { return 0; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The encrypted-chunk manifest describes where the 3 chunks live and how to
|
|
38
|
+
// decrypt/verify them. The real file is git-ignored (it carries Drive IDs); an
|
|
39
|
+
// .example template is committed. Returns null if none is present.
|
|
40
|
+
function loadManifest() {
|
|
41
|
+
const candidates = [
|
|
42
|
+
process.env.PARITYFIX_MODEL_MANIFEST,
|
|
43
|
+
path.join(ROOT, 'config', 'model.manifest.json'),
|
|
44
|
+
].filter(Boolean);
|
|
45
|
+
for (const p of candidates) {
|
|
46
|
+
try { if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) {}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
MODEL_NAME, MODEL_ID, MODEL_DIR, MODEL_PATH, MIN_BYTES, CHUNK_MAX_BYTES,
|
|
53
|
+
DISCLAIMER, isReady, sizeGB, loadManifest,
|
|
54
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// ── ADB performance report (self-contained HTML) ─────────────────────────────
|
|
2
|
+
// Produces a single HTML file with Chart.js line/bar charts, a score /100, AI
|
|
3
|
+
// suggestions and a metrics table. Opened directly in any browser — no debug
|
|
4
|
+
// mode, no dashboard required. Styled to the ParityFix magenta theme.
|
|
5
|
+
|
|
6
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => (
|
|
7
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
|
8
|
+
));
|
|
9
|
+
|
|
10
|
+
const GRADE_COLOR = { excellent: '#46d6a0', good: '#46d6a0', fair: '#f2c14e', poor: '#ff4d6d' };
|
|
11
|
+
|
|
12
|
+
function buildReport({ app, module, deviceInfo, iterations, metrics, score, issues, suggestions, generatedAt }) {
|
|
13
|
+
const labels = iterations.map((_, i) => `Run ${i + 1}`);
|
|
14
|
+
const series = (k) => JSON.stringify(iterations.map((it) => it[k] ?? null));
|
|
15
|
+
const gradeColor = GRADE_COLOR[score.grade] || '#ff00e9';
|
|
16
|
+
|
|
17
|
+
const suggestionItems = suggestions.map((s) => `
|
|
18
|
+
<li>
|
|
19
|
+
<strong>${esc(s.title || 'Suggestion')}</strong>
|
|
20
|
+
${s.detail ? `<p>${esc(s.detail)}</p>` : ''}
|
|
21
|
+
<span class="tag ${s.source === 'local' ? 'tag-ai' : 'tag-code'}">${s.source === 'local' ? '✦ AI' : '🧮 code'}</span>
|
|
22
|
+
</li>`).join('');
|
|
23
|
+
|
|
24
|
+
const issueItems = issues.length
|
|
25
|
+
? issues.map((i) => `<li><span class="pill pill-${i.area}">${esc(i.area)}</span> ${esc(i.text)}</li>`).join('')
|
|
26
|
+
: '<li class="ok">No issues detected — every parameter within budget. 🎉</li>';
|
|
27
|
+
|
|
28
|
+
// Render null metrics as "n/a" (neutral) so a parameter the platform didn't
|
|
29
|
+
// capture isn't shown as a passing 0.
|
|
30
|
+
const na = (v, unit) => (v == null ? { text: 'n/a', cls: 'na' } : { text: `${v}${unit}`, cls: null });
|
|
31
|
+
const cell = (v, unit, bad) => { const c = na(v, unit); return { text: c.text, cls: c.cls || (bad ? 'bad' : 'good') }; };
|
|
32
|
+
const metricRows = [
|
|
33
|
+
['Frame jank', cell(metrics.jankPct, '%', metrics.jankPct > 5)],
|
|
34
|
+
['p50 frame time', cell(metrics.p50, ' ms', false)],
|
|
35
|
+
['p90 frame time', cell(metrics.p90, ' ms', metrics.p90 > 16.6)],
|
|
36
|
+
['p99 frame time', cell(metrics.p99, ' ms', metrics.p99 > 40)],
|
|
37
|
+
['Peak memory (PSS)', cell(metrics.peakMemMb, ' MB', metrics.peakMemMb > 250)],
|
|
38
|
+
['Memory growth', cell(metrics.memGrowthMb, ' MB', metrics.memGrowthMb > 10)],
|
|
39
|
+
['Avg CPU', cell(metrics.cpuPct, '%', metrics.cpuPct > 40)],
|
|
40
|
+
...(metrics.gpuMemMb != null ? [['Avg GPU memory', cell(metrics.gpuMemMb, ' MB', false)]] : []),
|
|
41
|
+
].map(([k, c]) => `<tr><td>${k}</td><td class="${c.cls}">${c.text}</td></tr>`).join('');
|
|
42
|
+
|
|
43
|
+
return `<!DOCTYPE html>
|
|
44
|
+
<html lang="en">
|
|
45
|
+
<head>
|
|
46
|
+
<meta charset="UTF-8" />
|
|
47
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
48
|
+
<title>ParityFix Report — ${esc(app)} · ${esc(module)}</title>
|
|
49
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
50
|
+
<style>
|
|
51
|
+
:root{--bg:#18171f;--surface:#201f2a;--surface2:#2a2836;--border:#3a3848;
|
|
52
|
+
--text:#aca7cb;--hi:#fff;--muted:#8b86a6;--accent:#ff00e9;--sage:#46d6a0;--warn:#f2c14e;--danger:#ff4d6d;--high:#ff9457}
|
|
53
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
54
|
+
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif;padding:28px;line-height:1.5}
|
|
55
|
+
.wrap{max-width:1000px;margin:0 auto}
|
|
56
|
+
header{display:flex;align-items:center;gap:16px;margin-bottom:6px}
|
|
57
|
+
.logo{width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#ff00e9,#ff00d5);display:flex;align-items:center;justify-content:center;font-weight:800;color:#fff}
|
|
58
|
+
h1{font-size:20px;color:var(--hi);font-weight:800}
|
|
59
|
+
h1 .fx{color:var(--accent)}
|
|
60
|
+
.sub{color:var(--muted);font-size:13px;margin-bottom:22px}
|
|
61
|
+
.grid{display:grid;grid-template-columns:220px 1fr;gap:18px;margin-bottom:24px}
|
|
62
|
+
.score-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:22px;text-align:center}
|
|
63
|
+
.score-num{font-size:56px;font-weight:800;line-height:1;color:${gradeColor}}
|
|
64
|
+
.score-grade{text-transform:uppercase;letter-spacing:.08em;font-size:12px;font-weight:700;color:${gradeColor};margin-top:6px}
|
|
65
|
+
.subscores{display:flex;flex-direction:column;gap:8px;margin-top:16px;text-align:left}
|
|
66
|
+
.subscore{display:flex;justify-content:space-between;font-size:12px}
|
|
67
|
+
.bar{height:6px;border-radius:3px;background:var(--surface2);margin-top:3px;overflow:hidden}
|
|
68
|
+
.bar > span{display:block;height:100%;border-radius:3px}
|
|
69
|
+
.panel{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px}
|
|
70
|
+
.panel h2{font-size:12px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:14px;font-weight:700}
|
|
71
|
+
canvas{max-height:230px}
|
|
72
|
+
.charts{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:24px}
|
|
73
|
+
ul{list-style:none}
|
|
74
|
+
.issues li,.suggest li{padding:10px 0;border-bottom:1px solid var(--border);font-size:13px}
|
|
75
|
+
.suggest li strong{color:var(--hi);display:block;margin-bottom:2px}
|
|
76
|
+
.suggest li p{color:var(--text);font-size:12.5px;margin-top:2px}
|
|
77
|
+
.tag{display:inline-block;font-size:10px;font-weight:700;padding:2px 8px;border-radius:999px;margin-top:6px}
|
|
78
|
+
.tag-ai{background:rgba(255,0,233,.16);color:#ff66f1}
|
|
79
|
+
.tag-code{background:var(--surface2);color:var(--muted)}
|
|
80
|
+
.pill{display:inline-block;font-size:10px;font-weight:700;padding:2px 8px;border-radius:999px;text-transform:uppercase;margin-right:6px}
|
|
81
|
+
.pill-render{background:rgba(255,148,87,.16);color:var(--high)}
|
|
82
|
+
.pill-memory{background:rgba(255,77,109,.16);color:var(--danger)}
|
|
83
|
+
.pill-cpu{background:rgba(242,193,78,.16);color:var(--warn)}
|
|
84
|
+
.ok{color:var(--sage)}
|
|
85
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
86
|
+
td{padding:8px 10px;border-bottom:1px solid var(--border)}
|
|
87
|
+
td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
|
|
88
|
+
.good{color:var(--sage)} .bad{color:var(--danger)} .na{color:var(--muted)}
|
|
89
|
+
.two{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:24px}
|
|
90
|
+
footer{color:var(--muted);font-size:11px;margin-top:26px;padding-top:16px;border-top:1px solid var(--border)}
|
|
91
|
+
@media(max-width:760px){.grid,.charts,.two{grid-template-columns:1fr}}
|
|
92
|
+
</style>
|
|
93
|
+
</head>
|
|
94
|
+
<body>
|
|
95
|
+
<div class="wrap">
|
|
96
|
+
<header>
|
|
97
|
+
<div class="logo">P</div>
|
|
98
|
+
<div>
|
|
99
|
+
<h1>Parity<span class="fx">Fix</span> — Performance Report</h1>
|
|
100
|
+
</div>
|
|
101
|
+
</header>
|
|
102
|
+
<div class="sub">
|
|
103
|
+
App <strong style="color:var(--hi)">${esc(app)}</strong> · Module <strong style="color:var(--hi)">${esc(module)}</strong>
|
|
104
|
+
· ${esc(deviceInfo.brand)} ${esc(deviceInfo.model)}, ${esc(deviceInfo.os)} ${esc(deviceInfo.osVersion)} (${esc(deviceInfo.abi)})
|
|
105
|
+
· ${iterations.length} iterations
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div class="grid">
|
|
109
|
+
<div class="score-card">
|
|
110
|
+
<div class="score-num">${score.overall}</div>
|
|
111
|
+
<div style="color:var(--muted);font-size:12px">out of 100</div>
|
|
112
|
+
<div class="score-grade">${esc(score.grade)}</div>
|
|
113
|
+
<div class="subscores">
|
|
114
|
+
${[['Render', score.render, 'var(--high)'], ['Memory', score.memory, 'var(--danger)'], ['CPU', score.cpu, 'var(--warn)']]
|
|
115
|
+
.map(([k, v, c]) => v == null
|
|
116
|
+
? `<div><div class="subscore"><span>${k}</span><span class="na">n/a</span></div><div class="bar"></div></div>`
|
|
117
|
+
: `<div><div class="subscore"><span>${k}</span><span>${v}</span></div><div class="bar"><span style="width:${v}%;background:${c}"></span></div></div>`).join('')}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="panel issues">
|
|
121
|
+
<h2>Detected issues</h2>
|
|
122
|
+
<ul>${issueItems}</ul>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div class="charts">
|
|
127
|
+
<div class="panel"><h2>Frame time per iteration (ms)</h2><canvas id="frameChart"></canvas></div>
|
|
128
|
+
<div class="panel"><h2>Jank % per iteration</h2><canvas id="jankChart"></canvas></div>
|
|
129
|
+
<div class="panel"><h2>Memory PSS per iteration (MB)</h2><canvas id="memChart"></canvas></div>
|
|
130
|
+
<div class="panel"><h2>CPU % per iteration</h2><canvas id="cpuChart"></canvas></div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div class="two">
|
|
134
|
+
<div class="panel suggest">
|
|
135
|
+
<h2>Suggested improvements</h2>
|
|
136
|
+
<ul>${suggestionItems}</ul>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="panel">
|
|
139
|
+
<h2>Aggregated metrics</h2>
|
|
140
|
+
<table><tbody>${metricRows}</tbody></table>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<footer>Generated by ParityFix — Local AI-Powered Performance Analyzer · ${esc(generatedAt)}</footer>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<script>
|
|
148
|
+
const labels = ${JSON.stringify(labels)};
|
|
149
|
+
const grid = 'rgba(172,167,203,.12)', tick = '#8b86a6';
|
|
150
|
+
const base = (extra={}) => ({responsive:true,plugins:{legend:{labels:{color:'#aca7cb',boxWidth:12}}},
|
|
151
|
+
scales:{x:{grid:{color:grid},ticks:{color:tick}},y:{grid:{color:grid},ticks:{color:tick},beginAtZero:true}},...extra});
|
|
152
|
+
const line = (id,datasets)=>new Chart(document.getElementById(id),{type:'line',data:{labels,datasets},options:base()});
|
|
153
|
+
const bar = (id,label,data,color)=>new Chart(document.getElementById(id),{type:'bar',
|
|
154
|
+
data:{labels,datasets:[{label,data,backgroundColor:color}]},options:base({plugins:{legend:{display:false}}})});
|
|
155
|
+
|
|
156
|
+
line('frameChart',[
|
|
157
|
+
{label:'p50',data:${series('p50')},borderColor:'#46d6a0',backgroundColor:'#46d6a0',tension:.3},
|
|
158
|
+
{label:'p90',data:${series('p90')},borderColor:'#ff9457',backgroundColor:'#ff9457',tension:.3},
|
|
159
|
+
{label:'p99',data:${series('p99')},borderColor:'#ff4d6d',backgroundColor:'#ff4d6d',tension:.3},
|
|
160
|
+
]);
|
|
161
|
+
bar('jankChart','Jank %',${series('jankPct')},'#ff00e9');
|
|
162
|
+
line('memChart',[{label:'PSS (MB)',data:${series('pssMb')},borderColor:'#ff00e9',backgroundColor:'rgba(255,0,233,.15)',fill:true,tension:.3}]);
|
|
163
|
+
bar('cpuChart','CPU %',${series('cpuPct')},'#f2c14e');
|
|
164
|
+
</script>
|
|
165
|
+
</body>
|
|
166
|
+
</html>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { buildReport };
|