Skip to content

Commit 2181bc0

Browse files
mcollinaclauderozzilla
authored
fix: recording mode improvements (#135)
* feat: record API creates new file instead of modifying index.html The record endpoint now creates a standalone HTML file with embedded metrics data instead of modifying the frontend's index.html. This ensures the original bundle is never altered and allows multiple recordings. Changes: - Add optional outputPath parameter to specify output directory or file - Default output is cwd with auto-generated filename (watt-admin-DATE-TIME.html) - Never overwrites existing files (appends -1, -2, etc. if needed) - Extract output utilities to web/backend/utils/output.ts - Add comprehensive tests for new functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: add integration test for record file creation Adds a test that verifies the record endpoint actually creates an HTML file with embedded JSON at the specified outputPath. This test currently fails and needs investigation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fixup Signed-off-by: Matteo Collina <hello@matteocollina.com> * working record in folder Signed-off-by: Matteo Collina <hello@matteocollina.com> * fixup Signed-off-by: Matteo Collina <hello@matteocollina.com> * moar fixes Signed-off-by: Matteo Collina <hello@matteocollina.com> * fixup Signed-off-by: Matteo Collina <hello@matteocollina.com> * Hide flamegraph tab in live mode The flamegraph feature is only relevant when viewing recorded/offline data, so we now conditionally hide it when the app is in live mode. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix test Signed-off-by: Matteo Collina <hello@matteocollina.com> * Remove flamegraph test from E2E (only available offline) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: disable flamegraph button when no profile data instead of hiding 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: hide OpenAPI service click in offline mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: improve recording mode UX and fix errors - Fix circular JSON error when stringifying window object in api.ts - Add modal popup showing file path when recording stops - Auto-open recording in browser when stop recording (skip during CI/tests) - Remove Load button from UI (no longer needed) - Update E2E tests to expect Recording Saved modal instead of Load button 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update web/frontend/src/components/application/ServicesBox.tsx Co-authored-by: Roberto Bianchi <roberto.bianchi@spendesk.com> * Update web/frontend/src/utilities/getters.ts Co-authored-by: Roberto Bianchi <roberto.bianchi@spendesk.com> --------- Signed-off-by: Matteo Collina <hello@matteocollina.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Roberto Bianchi <roberto.bianchi@spendesk.com>
1 parent b83d921 commit 2181bc0

File tree

22 files changed

+501
-78
lines changed

22 files changed

+501
-78
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,6 @@ playwright-report
137137

138138
# Backend
139139
web/backend/openapi.json
140+
141+
# profiles
142+
watt-admin-*.html

lib/start.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { join } from 'path'
55
import { parseArgs, promisify } from 'util'
66
import { request } from 'undici'
77
import { exec } from 'child_process'
8-
import closeWithGrace from 'close-with-grace'
98

109
const __dirname = import.meta.dirname
1110

@@ -39,9 +38,9 @@ export async function start (client, selectedRuntime) {
3938
try {
4039
const { statusCode, body } = await requestRecord('stop')
4140
if (statusCode === 200) {
42-
await body.dump()
43-
const bundlePath = join(__dirname, '..', 'web', 'frontend', 'dist', 'index.html')
44-
await execAsync(`${process.platform === 'win32' ? 'start' : 'open'} ${bundlePath}`)
41+
const { path } = await body.json()
42+
await execAsync(`${process.platform === 'win32' ? 'start' : 'open'} ${path}`)
43+
return path
4544
} else {
4645
console.error(`Failure triggering the stop command: ${await body.text()}`)
4746
}
@@ -69,9 +68,11 @@ export async function start (client, selectedRuntime) {
6968
async function recordAndShutdown () {
7069
console.log('SIGINT received, recording metrics and shutting down...')
7170
clearTimeout(recordTimeout)
72-
await recordMetrics(entrypointUrl)
71+
const path = await recordMetrics(entrypointUrl)
7372
await shutdown()
7473
process.removeAllListeners('SIGINT')
74+
75+
console.log(`Profile recorded at ${path}`)
7576
}
7677

7778
process.once('SIGINT', () => {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/start.test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { RuntimeApiClient } from '@platformatic/control'
22
import { describe, it, beforeEach, afterEach, mock } from 'node:test'
33
import assert from 'node:assert'
44
import * as util from 'util'
5+
import { setImmediate as immediate } from 'node:timers/promises'
56

67
interface MockServer {
78
started: boolean
@@ -86,7 +87,8 @@ describe('start', () => {
8687
statusCode: 200,
8788
body: {
8889
dump: async () => {},
89-
text: async () => 'success'
90+
text: async () => 'success',
91+
json: async () => ({ path: 'profile-data/index.html' })
9092
}
9193
}
9294
}
@@ -211,8 +213,8 @@ describe('start', () => {
211213
process.emit('SIGINT')
212214

213215
// Wait for async operations to complete
214-
await new Promise(resolve => setImmediate(resolve))
215-
await new Promise(resolve => setImmediate(resolve))
216+
await immediate()
217+
await immediate()
216218

217219
// 5. Check if the 'stop' record request was made
218220
assert.strictEqual(requestMock.mock.calls.length, 2, 'Should have made a second request to stop recording')
@@ -274,19 +276,19 @@ describe('start', () => {
274276

275277
// First SIGINT - should trigger shutdown
276278
process.emit('SIGINT')
277-
await new Promise(resolve => setImmediate(resolve))
279+
await immediate()
278280

279281
// During shutdown, there should be an ignoring handler
280282
const listenerCountDuringShutdown = process.listenerCount('SIGINT')
281283

282284
// Send second SIGINT during shutdown
283285
process.emit('SIGINT')
284-
await new Promise(resolve => setImmediate(resolve))
286+
await immediate()
285287

286288
// Send third SIGINT to be sure
287289
process.emit('SIGINT')
288-
await new Promise(resolve => setImmediate(resolve))
289-
await new Promise(resolve => setImmediate(resolve))
290+
await immediate()
291+
await immediate()
290292

291293
// Verify shutdown was only called once
292294
const closeMock = mockServer.close as MockFunction<() => Promise<void>>

web/backend/routes/root.ts

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import type { FastifyInstance } from 'fastify'
22
import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'
33
import { RuntimeApiClient } from '@platformatic/control'
44
import { getPidToLoad, getSelectableRuntimes } from '../utils/runtimes.ts'
5-
import { writeFile, readFile } from 'fs/promises'
5+
import { writeFile, readFile } from 'node:fs/promises'
6+
import { exec } from 'node:child_process'
7+
import { promisify } from 'node:util'
8+
9+
const execAsync = promisify(exec)
610
import { checkRecordState } from '../utils/states.ts'
711
import { join } from 'path'
812
import { pidParamSchema, selectableRuntimeSchema, modeSchema, profileSchema } from '../schemas/index.ts'
13+
import { generateDefaultFilename, getUniqueFilePath } from '../utils/output.ts'
914

1015
const __dirname = import.meta.dirname
1116

@@ -80,7 +85,7 @@ export default async function (fastify: FastifyInstance) {
8085
items: {
8186
anyOf: [
8287
{
83-
additionalProperties: false,
88+
additionalProperties: true,
8489
type: 'object',
8590
required: ['id', 'type', 'status', 'version', 'localUrl', 'entrypoint', 'dependencies'],
8691
properties: {
@@ -158,11 +163,24 @@ export default async function (fastify: FastifyInstance) {
158163
body: {
159164
type: 'object',
160165
additionalProperties: false,
161-
properties: { mode: modeSchema, profile: profileSchema },
166+
properties: {
167+
mode: modeSchema,
168+
profile: profileSchema,
169+
outputPath: { type: 'string', description: 'Directory or file path for the output HTML. Defaults to cwd with auto-generated filename.' }
170+
},
162171
required: ['mode', 'profile']
172+
},
173+
response: {
174+
200: {
175+
type: 'object',
176+
additionalProperties: false,
177+
properties: {
178+
path: { type: 'string', description: 'Path to the saved recording HTML file' }
179+
}
180+
}
163181
}
164182
}
165-
}, async ({ body: { mode, profile: type }, params: { pid } }) => {
183+
}, async ({ body: { mode, profile: type, outputPath }, params: { pid } }, reply) => {
166184
const from = fastify.loaded.mode
167185
const to = mode
168186
if (!checkRecordState({ from, to })) {
@@ -177,27 +195,85 @@ export default async function (fastify: FastifyInstance) {
177195
}
178196
fastify.loaded.type = type
179197
fastify.loaded.metrics = {}
198+
return {}
180199
}
181200

182201
if (mode === 'stop') {
183202
try {
203+
reply.log.trace({ pid, type, outputPath }, 'Stopping recording and generating output')
184204
const runtimes = getSelectableRuntimes(await api.getRuntimes(), false)
185-
const services = await api.getRuntimeApplications(getPidToLoad(runtimes))
205+
reply.log.trace({ pid }, 'Fetching services from runtime')
206+
const pidToLoad = pid || getPidToLoad(runtimes)
207+
reply.log.trace({ pidToLoad }, 'Determined PID to load for recording')
208+
const services = await api.getRuntimeApplications(pidToLoad)
186209

187210
const profile: Record<string, Uint8Array> = {}
188211
for (const { id } of applications) {
189212
const profileData = Buffer.from(await api.stopApplicationProfiling(pid, id, { type }))
190-
await writeFile(join(__dirname, '..', '..', 'frontend', 'dist', `${fastify.loaded.type}-profile-${id}.pb`), profileData)
191213
profile[id] = new Uint8Array(profileData)
192214
}
193215

216+
reply.log.trace({ pid, type }, 'Profiling data collected from runtime')
217+
194218
const loadedJson = JSON.stringify({ runtimes, services, metrics: fastify.loaded.metrics[getPidToLoad(runtimes)], profile, type })
195219

196220
const scriptToAppend = ` <script>window.LOADED_JSON=${loadedJson}</script>\n</body>`
197-
const bundlePath = join(__dirname, '..', '..', 'frontend', 'dist', 'index.html')
198-
await writeFile(bundlePath, (await readFile(bundlePath, 'utf8')).replace('</body>', scriptToAppend), 'utf8')
221+
const templatePath = join(__dirname, '..', '..', 'frontend', 'dist', 'index.html')
222+
const fontsDir = join(__dirname, '..', '..', 'frontend', 'dist', 'fonts')
223+
let templateHtml = await readFile(templatePath, 'utf8')
224+
225+
// Inline fonts as base64 data URIs for offline usage
226+
const fontFiles = [
227+
{ path: 'Inter/Inter-VariableFont_wght.ttf', url: './fonts/Inter/Inter-VariableFont_wght.ttf' },
228+
{ path: 'Roboto_Mono/RobotoMono-VariableFont_wght.ttf', url: './fonts/Roboto_Mono/RobotoMono-VariableFont_wght.ttf' }
229+
]
230+
for (const font of fontFiles) {
231+
try {
232+
const fontData = await readFile(join(fontsDir, font.path))
233+
const base64Font = fontData.toString('base64')
234+
const dataUri = `data:font/ttf;base64,${base64Font}`
235+
templateHtml = templateHtml.replaceAll(font.url, dataUri)
236+
} catch (err) {
237+
reply.log.warn({ err, font: font.path }, 'Failed to inline font')
238+
}
239+
}
240+
241+
// Remove font preload links (not needed with inlined fonts)
242+
templateHtml = templateHtml.replace(/<link rel="preload"[^>]*\.ttf"[^>]*>/g, '')
243+
244+
const outputHtml = templateHtml.replace('</body>', scriptToAppend)
245+
246+
// Determine output file path
247+
let targetPath: string
248+
if (outputPath) {
249+
// If outputPath ends with .html, use it as-is; otherwise treat as directory
250+
if (outputPath.endsWith('.html')) {
251+
targetPath = outputPath
252+
} else {
253+
targetPath = join(outputPath, generateDefaultFilename())
254+
}
255+
} else {
256+
// Default to current working directory
257+
targetPath = join(process.cwd(), generateDefaultFilename())
258+
}
259+
260+
// Ensure we never overwrite an existing file
261+
reply.log.trace({ targetPath }, 'Saving recording to output path')
262+
const uniquePath = await getUniqueFilePath(targetPath)
263+
await writeFile(uniquePath, outputHtml, 'utf8')
264+
reply.log.info({ path: uniquePath }, 'Recording saved')
265+
266+
// Open the file in the default browser (skip during CI/tests)
267+
if (!process.env.CI && !process.env.NODE_TEST_CONTEXT) {
268+
const openCommand = process.platform === 'win32' ? 'start' : 'open'
269+
execAsync(`${openCommand} "${uniquePath}"`)
270+
.then(() => reply.log.info({ path: uniquePath }, 'Recording opened in browser'))
271+
.catch((err) => reply.log.warn({ err, path: uniquePath }, 'Failed to open recording in browser'))
272+
}
273+
274+
return { path: uniquePath }
199275
} catch (err) {
200-
fastify.log.error({ err }, 'Unable to save the loaded JSON')
276+
reply.log.error({ err }, 'Unable to save the loaded JSON')
201277
}
202278
}
203279
})

web/backend/test/routes/root.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import test from 'node:test'
22
import assert from 'node:assert'
33
import { getServer, startWatt } from '../helper.ts'
4+
import { readFile, rm, readdir, mkdir } from 'node:fs/promises'
5+
import { join } from 'node:path'
6+
import { tmpdir } from 'node:os'
47

58
test('no runtime running', async (t) => {
69
const server = await getServer(t)
@@ -107,3 +110,89 @@ test('runtime is running', async (t) => {
107110
})
108111
assert.strictEqual(restart.statusCode, 200, 'check for restart endpoint')
109112
})
113+
114+
test('record endpoint accepts outputPath parameter', async (t) => {
115+
await startWatt(t)
116+
const server = await getServer(t)
117+
const res = await server.inject({ url: '/runtimes?includeAdmin=true' })
118+
const [runtime] = res.json()
119+
const runtimePid = runtime.pid
120+
121+
// Start recording
122+
await server.inject({ url: `/record/${runtimePid}`, method: 'POST', body: { mode: 'start', profile: 'cpu' } })
123+
assert.strictEqual(server.loaded.mode, 'start', 'start mode')
124+
125+
// Stop recording with outputPath parameter - should be accepted without validation error
126+
const stopRes = await server.inject({
127+
url: `/record/${runtimePid}`,
128+
method: 'POST',
129+
body: { mode: 'stop', profile: 'cpu', outputPath: '/tmp/test-output.html' }
130+
})
131+
assert.strictEqual(stopRes.statusCode, 200, 'outputPath parameter should be accepted')
132+
assert.strictEqual(server.loaded.mode, 'stop', 'stop mode')
133+
})
134+
135+
test('record endpoint accepts outputPath as directory path', async (t) => {
136+
await startWatt(t)
137+
const server = await getServer(t)
138+
const res = await server.inject({ url: '/runtimes?includeAdmin=true' })
139+
const [runtime] = res.json()
140+
const runtimePid = runtime.pid
141+
142+
// Start recording first
143+
await server.inject({ url: `/record/${runtimePid}`, method: 'POST', body: { mode: 'start', profile: 'cpu' } })
144+
145+
// Stop with outputPath as directory (no .html extension)
146+
const stopRes = await server.inject({
147+
url: `/record/${runtimePid}`,
148+
method: 'POST',
149+
body: { mode: 'stop', profile: 'cpu', outputPath: '/tmp/recordings' }
150+
})
151+
assert.strictEqual(stopRes.statusCode, 200, 'should accept directory path')
152+
assert.strictEqual(server.loaded.mode, 'stop', 'stop mode')
153+
})
154+
155+
test('record creates file with embedded JSON at custom outputPath', async (t) => {
156+
await startWatt(t)
157+
const server = await getServer(t)
158+
const res = await server.inject({ url: '/runtimes?includeAdmin=true' })
159+
const [runtime] = res.json()
160+
const runtimePid = runtime.pid
161+
162+
// Create a temp directory for output
163+
const tempDir = join(tmpdir(), `watt-admin-test-${Date.now()}`)
164+
await mkdir(tempDir, { recursive: true })
165+
t.after(async () => {
166+
await rm(tempDir, { recursive: true, force: true })
167+
})
168+
169+
// Start recording
170+
const startRes = await server.inject({
171+
url: `/record/${runtimePid}`,
172+
method: 'POST',
173+
body: { mode: 'start', profile: 'cpu' }
174+
})
175+
assert.strictEqual(startRes.statusCode, 200, 'start recording should succeed')
176+
assert.strictEqual(server.loaded.mode, 'start', 'mode should be start')
177+
178+
// Stop recording with custom outputPath (directory)
179+
const stopRes = await server.inject({
180+
url: `/record/${runtimePid}`,
181+
method: 'POST',
182+
body: { mode: 'stop', profile: 'cpu', outputPath: tempDir }
183+
})
184+
assert.strictEqual(stopRes.statusCode, 200, 'stop recording should succeed')
185+
assert.strictEqual(server.loaded.mode, 'stop', 'mode should be stop')
186+
187+
// Find the generated file in custom directory
188+
const files = await readdir(tempDir)
189+
const generatedFile = files.find(f => f.startsWith('watt-admin-') && f.endsWith('.html'))
190+
191+
assert.ok(generatedFile, 'Generated file should exist in custom directory')
192+
193+
// Verify file content contains the embedded JSON
194+
const content = await readFile(join(tempDir, generatedFile), 'utf8')
195+
assert.ok(content.includes('window.LOADED_JSON='), 'File should contain embedded JSON')
196+
assert.ok(content.includes('</html>'), 'File should be valid HTML')
197+
})
198+

0 commit comments

Comments
 (0)