@platformatic/watt-admin 0.6.0-alpha.1 → 0.6.0-alpha.3

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/README.md CHANGED
@@ -14,7 +14,7 @@ After installing the project dependencies, you can run the tool directly:
14
14
 
15
15
  To run the CLI on a custom port, you can just pass it as an argument with `./cli.js --port 4321`.
16
16
 
17
- To record a flamegraph session, and choose to profile either the `cpu` or the `heap`, you can run the CLI with `./cli.js --record --flamegraph heap`. Once you will stop the process, an HTML one-file bundle will be auto-generated, and you will be able to navigate (even offline) the `watt-admin` app, looking at the flamegraph and at the metrics stored for the whole time you have run the CLI.
17
+ To record a flamegraph session, and choose to profile either the `cpu` or the `heap`, you can run the CLI with `./cli.js --record --profile heap`. Once you will stop the process, an HTML one-file bundle will be auto-generated, and you will be able to navigate (even offline) the `watt-admin` app, looking at the flamegraph and at the metrics stored for the whole time you have run the CLI.
18
18
 
19
19
  The tool is also available as a binary when installed globally or linked:
20
20
 
package/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { Runtime } from '@platformatic/control'
2
+
3
+ export default function main (): Promise<Runtime | null>
package/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  'use strict'
4
4
 
5
- import { pathToFileURL } from 'url'
5
+ import esmain from 'es-main'
6
6
  import { RuntimeApiClient } from '@platformatic/control'
7
7
  import { select } from '@inquirer/prompts'
8
8
  import { start } from './lib/start.js'
@@ -137,7 +137,7 @@ export default async function main () {
137
137
  }
138
138
 
139
139
  // Execute the main function if this script is run directly
140
- if (import.meta.url === pathToFileURL(process.argv[1]).href) {
140
+ if (esmain(import.meta)) {
141
141
  main().then((selectedRuntime) => {
142
142
  if (!selectedRuntime) {
143
143
  return
package/lib/start.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { RuntimeApiClient } from '@platformatic/control'
2
+
3
+ export declare function start (client: RuntimeApiClient, selectedRuntime: string): Promise<void>
package/lib/start.js CHANGED
@@ -52,7 +52,9 @@ export async function start (client, selectedRuntime) {
52
52
  }
53
53
 
54
54
  const configFile = join(__dirname, '..', 'watt.json')
55
- const server = await create(configFile)
55
+ const server = await create(configFile, undefined, {
56
+ setupSignals: false
57
+ })
56
58
  entrypointUrl = await server.start()
57
59
 
58
60
  if (record) {
@@ -64,6 +66,9 @@ export async function start (client, selectedRuntime) {
64
66
 
65
67
  // Always clean up the client
66
68
  await client.close()
69
+
70
+ // Then stop the server
71
+ await server.close()
67
72
  })
68
73
 
69
74
  const { statusCode, body } = await requestRecord('start')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@platformatic/watt-admin",
4
- "version": "0.6.0-alpha.1",
4
+ "version": "0.6.0-alpha.3",
5
5
  "scripts": {
6
6
  "prepublishOnly": "npm run clean && npm run build",
7
7
  "dev": "wattpm dev",
@@ -28,46 +28,48 @@
28
28
  "@platformatic/gateway": "^3.11.0",
29
29
  "@platformatic/runtime": "^3.11.0",
30
30
  "@platformatic/service": "^3.11.0",
31
+ "@platformatic/ui-components": "^0.19.1",
31
32
  "@platformatic/vite": "^3.11.0",
33
+ "@platformatic/wattpm-pprof-capture": "^3.13.0",
32
34
  "@scalar/api-reference-react": "^0.7.55",
35
+ "@vitejs/plugin-react": "^5.0.4",
36
+ "amaro": "^1.1.4",
37
+ "autoprefixer": "^10.4.21",
33
38
  "close-with-grace": "^2.3.0",
39
+ "d3": "^7.9.0",
40
+ "dayjs": "^1.11.18",
41
+ "es-main": "^1.4.0",
34
42
  "fastify": "^5.6.1",
35
43
  "pprof-format": "^2.2.1",
44
+ "react": "^19.2.0",
45
+ "react-dom": "^19.2.0",
36
46
  "react-pprof": "^1.1.0",
47
+ "react-router-dom": "^7.9.3",
37
48
  "react-use-websocket": "^4.13.0",
38
49
  "split2": "^4.2.0",
50
+ "tailwindcss": "^3.4.18",
39
51
  "undici": "^7.16.0",
40
- "wattpm": "^3.11.0"
52
+ "use-error-boundary": "^2.0.6",
53
+ "vite": "^7.1.9",
54
+ "vite-plugin-singlefile": "^2.3.0",
55
+ "wattpm": "^3.11.0",
56
+ "zustand": "^5.0.8"
41
57
  },
42
58
  "devDependencies": {
43
59
  "@fastify/type-provider-json-schema-to-ts": "^5.0.0",
44
- "@platformatic/ui-components": "^0.19.1",
45
- "@platformatic/wattpm-pprof-capture": "^3.13.0",
46
60
  "@playwright/test": "^1.56.0",
47
61
  "@types/d3": "^7.4.3",
48
62
  "@types/node": "^22",
49
63
  "@types/react-dom": "^19.2.1",
50
64
  "@types/split2": "^4.2.3",
51
65
  "@types/ws": "^8.18.1",
52
- "@vitejs/plugin-react": "^5.0.4",
53
- "autoprefixer": "^10.4.21",
54
- "d3": "^7.9.0",
55
- "dayjs": "^1.11.18",
56
66
  "eslint": "^9.37.0",
57
67
  "fastify-tsconfig": "^3.0.0",
58
68
  "massimo-cli": "^1.0.1",
59
69
  "neostandard": "^0.12.2",
60
70
  "playwright": "^1.56.0",
61
- "react": "^19.2.0",
62
- "react-dom": "^19.2.0",
63
- "react-router-dom": "^7.9.3",
64
- "tailwindcss": "^3.4.18",
65
71
  "typescript": "^5.9.3",
66
- "use-error-boundary": "^2.0.6",
67
- "vite": "^7.1.9",
68
- "vite-plugin-singlefile": "^2.3.0",
69
- "vitest": "^3.2.4",
70
- "zustand": "^5.0.8"
72
+ "vitest": "^3.2.4"
71
73
  },
72
74
  "bin": {
73
75
  "watt-admin": "./cli.js"
package/renovate.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": [
4
+ "config:base"
5
+ ],
6
+ "packageRules": [
7
+ {
8
+ "groupName": "Safe automerge",
9
+ "matchUpdateTypes": ["minor", "patch", "pin", "digest"],
10
+ "automerge": true
11
+ }
12
+ ],
13
+ "lockFileMaintenance": {
14
+ "enabled": true,
15
+ "automerge": true
16
+ }
17
+ }
package/watt.json CHANGED
@@ -7,13 +7,21 @@
7
7
  "logger": {
8
8
  "level": "info"
9
9
  },
10
- "autoload": {
11
- "path": "web",
12
- "mappings": {
13
- "backend": {
14
- "id": "backend",
15
- "useHttp": true
16
- }
10
+ "entrypoint": "composer",
11
+ "applications": [
12
+ {
13
+ "id": "backend",
14
+ "path": "./web/backend",
15
+ "useHttp": true,
16
+ "nodeOptions": "--import=amaro/strip"
17
+ },
18
+ {
19
+ "id": "composer",
20
+ "path": "./web/composer"
21
+ },
22
+ {
23
+ "id": "frontend",
24
+ "path": "./web/frontend"
17
25
  }
18
- }
26
+ ]
19
27
  }
@@ -0,0 +1,17 @@
1
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
2
+ import type { FastifyInstance } from 'fastify'
3
+ import type { PlatformaticApp, PlatformaticServiceConfig } from '@platformatic/service'
4
+ import type { Mode, Profile } from './schemas/index.ts'
5
+ import type { MappedMetrics } from './utils/metrics-helpers.ts'
6
+
7
+ declare module 'fastify' {
8
+ interface FastifyInstance {
9
+ platformatic: PlatformaticApp<PlatformaticServiceConfig>
10
+ metricsInterval: NodeJS.Timeout
11
+ loaded: { mode?: Mode, metrics: MappedMetrics, type?: Profile }
12
+ }
13
+
14
+ interface FastifySchema {
15
+ hide?: boolean
16
+ }
17
+ }
@@ -0,0 +1,15 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import { getMetrics } from '../utils/metrics.ts'
3
+ import { MS_WAITING } from '../utils/constants.ts'
4
+
5
+ export default async function (fastify: FastifyInstance) {
6
+ fastify.decorate('loaded', { metrics: {} })
7
+
8
+ fastify.decorate('metricsInterval', setInterval(() => getMetrics(fastify), MS_WAITING))
9
+
10
+ fastify.addHook('onClose', async () => {
11
+ // If the following log is not called, please run the project directly through the `wattpm` binary (ref. https://github.com/platformatic/platformatic/issues/3751)
12
+ fastify.log.info('Closing the backend...')
13
+ clearInterval(fastify.metricsInterval)
14
+ })
15
+ }
@@ -0,0 +1,6 @@
1
+ import websocket from '@fastify/websocket'
2
+ import type { FastifyInstance } from 'fastify'
3
+
4
+ export default async function (fastify: FastifyInstance) {
5
+ await fastify.register(websocket)
6
+ }
@@ -0,0 +1,46 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'
3
+ import { metricResponseSchema, pidParamSchema } from '../schemas/index.ts'
4
+ import type { MetricsResponse } from '../schemas/index.ts'
5
+
6
+ export default async function (fastify: FastifyInstance) {
7
+ const typedFastify = fastify.withTypeProvider<JsonSchemaToTsProvider>()
8
+ const emptyMetrics: MetricsResponse = { dataCpu: [], dataLatency: [], dataMem: [], dataReq: [], dataKafka: [], dataUndici: [], dataWebsocket: [], dataNodejs: [] }
9
+
10
+ typedFastify.get('/runtimes/:pid/metrics', {
11
+ schema: { params: pidParamSchema, response: { 200: metricResponseSchema } },
12
+ }, async ({ params: { pid } }) => fastify.loaded.metrics[pid]?.aggregated || emptyMetrics)
13
+
14
+ typedFastify.get('/runtimes/:pid/metrics/:serviceId', {
15
+ schema: {
16
+ params: {
17
+ type: 'object',
18
+ properties: {
19
+ pid: { type: 'number' },
20
+ serviceId: { type: 'string' }
21
+ },
22
+ required: ['pid', 'serviceId']
23
+ },
24
+ response: { 200: metricResponseSchema }
25
+ }
26
+ }, async ({ params: { pid, serviceId } }) => {
27
+ return fastify.loaded.metrics[pid]?.services[serviceId]?.all || emptyMetrics
28
+ })
29
+
30
+ typedFastify.get('/runtimes/:pid/metrics/:serviceId/:workerId', {
31
+ schema: {
32
+ params: {
33
+ type: 'object',
34
+ properties: {
35
+ pid: { type: 'number' },
36
+ serviceId: { type: 'string' },
37
+ workerId: { type: 'number' },
38
+ },
39
+ required: ['pid', 'serviceId', 'workerId']
40
+ },
41
+ response: { 200: metricResponseSchema }
42
+ }
43
+ }, async ({ params: { pid, serviceId, workerId } }) => {
44
+ return fastify.loaded.metrics[pid]?.services[serviceId]?.[workerId] || emptyMetrics
45
+ })
46
+ }
@@ -0,0 +1,41 @@
1
+ import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'
2
+ import { RuntimeApiClient } from '@platformatic/control'
3
+ import type { FastifyInstance } from 'fastify'
4
+
5
+ export default async function (fastify: FastifyInstance) {
6
+ const typedFastify = fastify.withTypeProvider<JsonSchemaToTsProvider>()
7
+ const api = new RuntimeApiClient()
8
+
9
+ typedFastify.removeContentTypeParser(['application/json', 'text/*'])
10
+ typedFastify.addContentTypeParser('*', { parseAs: 'buffer' }, async (_request: unknown, body: unknown) => body)
11
+
12
+ typedFastify.all('/proxy/:pid/services/:serviceId/*', {
13
+ schema: {
14
+ hide: true, // needed since the client generation fails to properly handle the '*' wildcard
15
+ }
16
+ }, async (request, reply) => {
17
+ const { pid, serviceId, '*': requestUrl } = request.params as { pid: number, serviceId: string, '*': string } // cast needed because we can't define a valid json schema with the '*' wildcard
18
+
19
+ delete request.headers.connection
20
+ delete request.headers['content-length']
21
+ delete request.headers['content-encoding']
22
+ delete request.headers['transfer-encoding']
23
+
24
+ const injectParams = {
25
+ method: request.method,
26
+ url: '/' + requestUrl,
27
+ headers: request.headers,
28
+ query: request.query,
29
+ body: request.body
30
+ } as Parameters<typeof api.injectRuntime>[2]
31
+
32
+ fastify.log.info({ pid, serviceId, injectParams }, 'runtime request proxy')
33
+
34
+ const res = await api.injectRuntime(pid, serviceId, injectParams)
35
+
36
+ delete res.headers['content-length']
37
+ delete res.headers['transfer-encoding']
38
+
39
+ return reply.code(res.statusCode).headers(res.headers).send(res.body)
40
+ })
41
+ }
@@ -0,0 +1,204 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'
3
+ import { RuntimeApiClient } from '@platformatic/control'
4
+ import { getPidToLoad, getSelectableRuntimes } from '../utils/runtimes.ts'
5
+ import { writeFile, readFile } from 'fs/promises'
6
+ import { checkRecordState } from '../utils/states.ts'
7
+ import { join } from 'path'
8
+ import { pidParamSchema, selectableRuntimeSchema, modeSchema, profileSchema } from '../schemas/index.ts'
9
+
10
+ const __dirname = import.meta.dirname
11
+
12
+ export default async function (fastify: FastifyInstance) {
13
+ const typedFastify = fastify.withTypeProvider<JsonSchemaToTsProvider>()
14
+
15
+ // FIXME: types have not been properly implemented in `@platformatic/control` and they should be updated as form the cast in the following line
16
+ const api = new RuntimeApiClient() as RuntimeApiClient & { startApplicationProfiling: (...args: unknown[]) => Promise<unknown>, stopApplicationProfiling: (...args: unknown[]) => Promise<string> }
17
+
18
+ typedFastify.get('/runtimes', {
19
+ schema: {
20
+ querystring: {
21
+ type: 'object',
22
+ properties: {
23
+ includeAdmin: {
24
+ type: 'boolean',
25
+ default: false,
26
+ },
27
+ },
28
+ },
29
+ response: { 200: { type: 'array', items: selectableRuntimeSchema } }
30
+ }
31
+ }, async (request) => getSelectableRuntimes(await api.getRuntimes(), request.query.includeAdmin))
32
+
33
+ typedFastify.get('/runtimes/:pid/health', {
34
+ schema: {
35
+ params: pidParamSchema,
36
+ response: {
37
+ 200: {
38
+ type: 'object',
39
+ additionalProperties: false,
40
+ properties: {
41
+ status: {
42
+ type: 'string',
43
+ enum: ['OK', 'KO'],
44
+ description: "Status can only be 'OK' or 'KO'"
45
+ }
46
+ },
47
+ required: ['status']
48
+ }
49
+ }
50
+ }
51
+ }, async ({ params: { pid } }) => {
52
+ const ok = { status: 'OK' as const }
53
+ const ko = { status: 'KO' as const }
54
+
55
+ try {
56
+ const result = await api.getMatchingRuntime({ pid: pid.toString() })
57
+ return (result.pid === pid) ? ok : ko
58
+ } catch {
59
+ return ko
60
+ }
61
+ })
62
+
63
+ typedFastify.get('/runtimes/:pid/services', {
64
+ schema: {
65
+ params: pidParamSchema,
66
+ response: {
67
+ 200: {
68
+ type: 'object',
69
+ additionalProperties: false,
70
+ required: ['entrypoint', 'production', 'applications'],
71
+ properties: {
72
+ entrypoint: {
73
+ type: 'string'
74
+ },
75
+ production: {
76
+ type: 'boolean'
77
+ },
78
+ applications: {
79
+ type: 'array',
80
+ items: {
81
+ anyOf: [
82
+ {
83
+ additionalProperties: false,
84
+ type: 'object',
85
+ required: ['id', 'type', 'status', 'version', 'localUrl', 'entrypoint', 'dependencies'],
86
+ properties: {
87
+ id: {
88
+ type: 'string'
89
+ },
90
+ type: {
91
+ type: 'string'
92
+ },
93
+ status: {
94
+ type: 'string'
95
+ },
96
+ version: {
97
+ type: 'string'
98
+ },
99
+ localUrl: {
100
+ type: 'string'
101
+ },
102
+ entrypoint: {
103
+ type: 'boolean'
104
+ },
105
+ workers: {
106
+ type: 'number'
107
+ },
108
+ url: {
109
+ type: 'string'
110
+ },
111
+ dependencies: {
112
+ type: 'array',
113
+ items: { type: 'string' }
114
+ }
115
+ }
116
+ },
117
+ {
118
+ additionalProperties: false,
119
+ type: 'object',
120
+ required: ['id', 'status'],
121
+ properties: {
122
+ id: {
123
+ type: 'string'
124
+ },
125
+ status: {
126
+ type: 'string'
127
+ }
128
+ }
129
+ }
130
+ ]
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }, async (request) => api.getRuntimeApplications(request.params.pid))
138
+
139
+ typedFastify.get('/runtimes/:pid/openapi/:serviceId', {
140
+ schema: {
141
+ params: { type: 'object', properties: { pid: { type: 'number' }, serviceId: { type: 'string' } }, required: ['pid', 'serviceId'] }
142
+ }
143
+ }, async ({ params: { pid, serviceId } }) => api.getRuntimeOpenapi(pid, serviceId))
144
+
145
+ typedFastify.post('/runtimes/:pid/restart', {
146
+ schema: { params: pidParamSchema, body: { type: 'object' } }
147
+ }, async (request) => {
148
+ try {
149
+ await api.restartRuntime(request.params.pid)
150
+ } catch (err) {
151
+ fastify.log.warn({ err }, 'Issue restarting the runtime')
152
+ }
153
+ })
154
+
155
+ typedFastify.post('/record/:pid', {
156
+ schema: {
157
+ params: pidParamSchema,
158
+ body: {
159
+ type: 'object',
160
+ additionalProperties: false,
161
+ properties: { mode: modeSchema, profile: profileSchema },
162
+ required: ['mode', 'profile']
163
+ }
164
+ }
165
+ }, async ({ body: { mode, profile: type }, params: { pid } }) => {
166
+ const from = fastify.loaded.mode
167
+ const to = mode
168
+ if (!checkRecordState({ from, to })) {
169
+ return fastify.log.error({ from, to }, 'Invalid record state machine transition')
170
+ }
171
+
172
+ const { applications } = await api.getRuntimeApplications(pid)
173
+ fastify.loaded.mode = mode
174
+ if (mode === 'start') {
175
+ for (const { id } of applications) {
176
+ await api.startApplicationProfiling(pid, id, { type })
177
+ }
178
+ fastify.loaded.type = type
179
+ fastify.loaded.metrics = {}
180
+ }
181
+
182
+ if (mode === 'stop') {
183
+ try {
184
+ const runtimes = getSelectableRuntimes(await api.getRuntimes(), false)
185
+ const services = await api.getRuntimeApplications(getPidToLoad(runtimes))
186
+
187
+ const profile: Record<string, Uint8Array> = {}
188
+ for (const { id } of applications) {
189
+ const profileData = Buffer.from(await api.stopApplicationProfiling(pid, id, { type }))
190
+ await writeFile(join(__dirname, '..', '..', 'frontend', 'dist', `${fastify.loaded.type}-profile-${id}.pb`), profileData)
191
+ profile[id] = new Uint8Array(profileData)
192
+ }
193
+
194
+ const loadedJson = JSON.stringify({ runtimes, services, metrics: fastify.loaded.metrics[getPidToLoad(runtimes)], profile, type })
195
+
196
+ 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')
199
+ } catch (err) {
200
+ fastify.log.error({ err }, 'Unable to save the loaded JSON')
201
+ }
202
+ }
203
+ })
204
+ }
@@ -0,0 +1,45 @@
1
+ import type { WebSocket } from 'ws'
2
+ import split2 from 'split2'
3
+ import type { FastifyInstance } from 'fastify'
4
+ import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'
5
+ import { RuntimeApiClient } from '@platformatic/control'
6
+ import { pidParamSchema } from '../schemas/index.ts'
7
+ import type { PidParam } from '../schemas/index.ts'
8
+ import { pipeline } from 'node:stream/promises'
9
+
10
+ export default async function (fastify: FastifyInstance) {
11
+ const typedFastify = fastify.withTypeProvider<JsonSchemaToTsProvider>()
12
+ const api = new RuntimeApiClient()
13
+
14
+ const wsSendAsync = (socket: WebSocket, data: string): Promise<void> => new Promise((resolve, reject) => setTimeout(() => socket.send(data, (err) => (err)
15
+ ? reject(err)
16
+ : resolve()
17
+ ), 100)
18
+ )
19
+
20
+ typedFastify.get<{ Params: PidParam }>('/runtimes/:pid/logs/ws', {
21
+ schema: { params: pidParamSchema },
22
+ websocket: true
23
+ }, async (socket, { params: { pid } }) => {
24
+ try {
25
+ const clientStream = api.getRuntimeLiveLogsStream(pid)
26
+
27
+ socket.on('close', () => {
28
+ clientStream.destroy()
29
+ })
30
+
31
+ await pipeline(
32
+ clientStream,
33
+ split2(),
34
+ async function * (source: AsyncIterable<string>) {
35
+ for await (const line of source) {
36
+ await wsSendAsync(socket, line)
37
+ }
38
+ }
39
+ )
40
+ } catch (error) {
41
+ fastify.log.error({ error }, 'fatal error on runtime logs ws')
42
+ socket.close()
43
+ }
44
+ })
45
+ }