From ef5e758a430f44f614f88b0b4b1e5926aa886da4 Mon Sep 17 00:00:00 2001 From: Liat Berkovich Date: Tue, 11 Nov 2025 15:55:26 +0200 Subject: [PATCH 1/2] feat(ui): support appType (Compose/Quadlet) for inline applications (EDM-2451)\n\n- Add appType to application form model\n- Preserve appType from API and emit on save\n- Add Compose/Quadlet selector in Applications step --- .../EditDeviceWizard/deviceSpecUtils.ts | 4 +++- .../steps/ApplicationTemplates.tsx | 23 +++++++++++++++++-- libs/ui-components/src/types/deviceSpec.ts | 3 ++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index 0a79e6b62..bce8ff81a 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -228,7 +228,7 @@ export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { return { name: app.name, - appType: AppType.AppTypeCompose, + appType: app.appType || AppType.AppTypeCompose, inline: app.files.map( (file): InlineApplicationFileFixed => ({ path: file.path, @@ -396,6 +396,7 @@ export const getApplicationValues = (deviceSpec?: DeviceSpec): AppForm[] => { if (isImageAppProvider(app)) { return { specType: AppSpecType.OCI_IMAGE, + appType: app.appType, name: app.name || '', image: app.image, variables: getAppFormVariables(app), @@ -405,6 +406,7 @@ export const getApplicationValues = (deviceSpec?: DeviceSpec): AppForm[] => { // TODO EDM-2451: Add support for artifact applications return { specType: AppSpecType.INLINE, + appType: app.appType, name: app.name || '', files: (app as InlineApplicationProviderSpec).inline.map((file) => ({ path: file.path || '', diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx index 871a119b3..498dcfdad 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationTemplates.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; -import { Button, FormGroup, FormSection, Grid, Split, SplitItem } from '@patternfly/react-core'; +import { Button } from '@patternfly/react-core/dist/esm/components/Button'; +import { FormGroup, FormSection } from '@patternfly/react-core/dist/esm/components/Form'; +import { Grid } from '@patternfly/react-core/dist/esm/layouts/Grid'; +import { Split, SplitItem } from '@patternfly/react-core/dist/esm/layouts/Split'; import { FieldArray, useField, useFormikContext } from 'formik'; import { MinusCircleIcon } from '@patternfly/react-icons/dist/js/icons/minus-circle-icon'; import { PlusCircleIcon } from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; @@ -45,6 +48,7 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: setValue( { specType: AppSpecType.INLINE, + appType: (app.appType || ('compose' as unknown as AppForm['appType'])), name: app.name || '', files: [{ path: '', content: '' }], variables: [], @@ -55,6 +59,7 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: setValue( { specType: AppSpecType.OCI_IMAGE, + appType: app.appType, name: app.name || '', image: '', variables: [], @@ -62,7 +67,7 @@ const ApplicationSection = ({ index, isReadOnly }: { index: number; isReadOnly?: false, ); } - }, [isImageIncomplete, isInlineIncomplete, app.name, setValue]); + }, [isImageIncomplete, isInlineIncomplete, app.name, app.appType, setValue]); return ( + {isInlineAppForm(app) && ( + + + + )} + {isImageAppForm(app) && } {isInlineAppForm(app) && } diff --git a/libs/ui-components/src/types/deviceSpec.ts b/libs/ui-components/src/types/deviceSpec.ts index ad5fba64a..59048152c 100644 --- a/libs/ui-components/src/types/deviceSpec.ts +++ b/libs/ui-components/src/types/deviceSpec.ts @@ -1,4 +1,5 @@ import { + AppType, ArtifactApplicationProviderSpec, ConfigProviderSpec, DisruptionBudget, @@ -46,7 +47,7 @@ type InlineContent = { type AppBase = { specType: AppSpecType; - // appType: AppType - commented out for now, since it only accepts one value ("compose") + appType?: AppType; name?: string; variables: { name: string; value: string }[]; volumes?: { From 14b2f5d47a6f223cae5ba56d9f33b40ae13ace69 Mon Sep 17 00:00:00 2001 From: Liat Berkovich Date: Tue, 18 Nov 2025 17:44:48 +0200 Subject: [PATCH 2/2] Adding container application file --- README.md | 12 + apps/standalone/package.json | 9 +- apps/standalone/public/mockServiceWorker.js | 349 ++++++++++++++ apps/standalone/src/index.tsx | 14 +- apps/standalone/src/mocks/browser.ts | 6 + apps/standalone/src/mocks/handlers.ts | 264 +++++++++++ apps/standalone/webpack.config.ts | 39 +- .../EditDeviceWizard/deviceSpecUtils.ts | 50 ++- .../steps/ApplicationContainerForm.tsx | 193 ++++++++ .../steps/ApplicationTemplates.tsx | 13 +- .../Fleet/CreateFleet/CreateFleetWizard.tsx | 2 +- .../src/components/form/validations.ts | 45 +- libs/ui-components/src/types/deviceSpec.ts | 11 + package-lock.json | 424 +++++++++++++++++- package.json | 1 + 15 files changed, 1412 insertions(+), 20 deletions(-) create mode 100644 apps/standalone/public/mockServiceWorker.js create mode 100644 apps/standalone/src/mocks/browser.ts create mode 100644 apps/standalone/src/mocks/handlers.ts create mode 100644 libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx diff --git a/README.md b/README.md index 736e752dd..5a9ba474a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,18 @@ or provide the CA certs: See [CONFIGURATION.md](CONFIGURATION.md) for complete configuration options. +### Running Standalone UI with mock data (no backend) + +The standalone app can run fully offline using MSW and the existing Cypress fixtures. + +```shell +npm run dev:mock +``` + +Notes: +- Mocked endpoints cover core `/api/flightctl/api/v1` routes (fleets, repositories) and can be extended in `apps/standalone/src/mocks/handlers.ts`. +- To disable mocks in the same session, stop the dev server and run `npm run dev` (without the `USE_MSW` flag). + ### Running UI as OCP plugin With this option, the Flight Control UI will run as a Plugin in the OCP console. diff --git a/apps/standalone/package.json b/apps/standalone/package.json index bbfe02fb0..477e7d256 100644 --- a/apps/standalone/package.json +++ b/apps/standalone/package.json @@ -11,6 +11,7 @@ "build": "NODE_ENV=production npm run ts-node ../../node_modules/.bin/webpack --mode=production", "start-prod": "npm run ts-node ../../node_modules/.bin/webpack serve --mode=production --color --progress", "dev": "concurrently \"npm run dev:proxy\" \"npm run dev:ui\"", + "dev:mock": "USE_MSW=true npm run dev", "dev:kind": ". ./scripts/setup_env.sh && npm run dev", "dev:proxy": "cd ../../proxy && nodemon --watch 'proxy/**/*' --exec 'go run' app.go --signal SIGTERM", "dev:ui": "npm run ts-node ../../node_modules/.bin/webpack serve --mode=development --color --progress", @@ -20,6 +21,7 @@ }, "devDependencies": { "@types/react-dom": "^18.2.17", + "msw": "^2.4.1", "copy-webpack-plugin": "^11.0.0", "css-loader": "^6.8.1", "css-minimizer-webpack-plugin": "^5.0.1", @@ -58,5 +60,10 @@ "react-i18next": "^11.7.3", "react-router-dom": "^6.22.0", "yup": "^1.3.3" + }, + "msw": { + "workerDirectory": [ + "public" + ] } -} +} \ No newline at end of file diff --git a/apps/standalone/public/mockServiceWorker.js b/apps/standalone/public/mockServiceWorker.js new file mode 100644 index 000000000..02115fb4d --- /dev/null +++ b/apps/standalone/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.12.1' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/standalone/src/index.tsx b/apps/standalone/src/index.tsx index 651e6e07c..e8ed0ca9f 100644 --- a/apps/standalone/src/index.tsx +++ b/apps/standalone/src/index.tsx @@ -36,4 +36,16 @@ const rootApp = ( ); -render(rootApp, root); +const start = async () => { + if (process.env.USE_MSW === 'true') { + const { worker } = await import('./mocks/browser'); + await worker.start({ + onUnhandledRequest: 'bypass', + serviceWorker: { url: '/mockServiceWorker.js' }, + }); + } + render(rootApp, root); +}; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +start(); diff --git a/apps/standalone/src/mocks/browser.ts b/apps/standalone/src/mocks/browser.ts new file mode 100644 index 000000000..cd3f753f3 --- /dev/null +++ b/apps/standalone/src/mocks/browser.ts @@ -0,0 +1,6 @@ +import { setupWorker } from 'msw/browser'; +import { handlers } from './handlers'; + +export const worker = setupWorker(...handlers); + + diff --git a/apps/standalone/src/mocks/handlers.ts b/apps/standalone/src/mocks/handlers.ts new file mode 100644 index 000000000..a8593e04e --- /dev/null +++ b/apps/standalone/src/mocks/handlers.ts @@ -0,0 +1,264 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any, sort-imports */ +import { http, HttpResponse } from 'msw'; +import type { Fleet, Repository } from '@flightctl/types'; +import type { Device } from '@flightctl/types'; +// Reuse existing Cypress fixtures +import { basicFleets } from '../../../../libs/cypress/fixtures/fleets/initialFleets'; +import { repoList } from '../../../../libs/cypress/fixtures/repositories/initialRepositories'; + +function buildListResponse(items: T[], kind: string) { + return { + apiVersion: 'v1alpha1', + kind, + metadata: {}, + items, + }; +} + +const apiBase = '/api/flightctl/api/v1'; +const loginBase = '/api/login'; + +function getMockDevices(): Device[] { + return [ + { + apiVersion: 'v1alpha1', + kind: 'Device', + metadata: { + name: 'device-001', + labels: { alias: 'edge-gw-01' } as Record, + owner: 'Fleet/eu-east-prod-001', + } as unknown as Device['metadata'], + spec: { + os: { image: 'quay.io/example/os:1.0.0' }, + config: [ + { + name: 'example-server', + gitRef: { + repository: 'defaultRepo', + targetRevision: 'main', + path: '/demos/basic-nginx-fleet/configuration/', + }, + }, + ] as any, + applications: [ + { + name: 'nginx', + image: 'docker.io/library/nginx:1.25', + }, + { + name: 'prometheus', + image: 'quay.io/prometheus/prometheus:v2.55.0', + }, + ] as any, + systemd: { + matchPatterns: ['nginx.service', 'prometheus.service'], + }, + } as any, + status: { + summary: { status: 'Online' } as any, + updated: { status: 'UpToDate' } as any, + applicationsSummary: { status: 'Healthy' } as any, + integrity: { status: 'Verified' } as any, + applications: [ + { + name: 'nginx', + ready: '1/1', + restarts: 0, + status: 'Running', + }, + { + name: 'prometheus', + ready: '1/1', + restarts: 1, + status: 'Running', + }, + ], + resources: {} as any, + config: {} as any, + os: { image: 'quay.io/example/os:1.0.0' } as any, + lifecycle: { status: 'Enrolled' } as any, + systemInfo: { + architecture: 'x86_64', + operatingSystem: 'Linux', + distroName: 'Fedora', + distroVersion: '40', + kernel: '6.10.5', + hostname: 'edge-gw-01', + } as any, + } as any, + }, + { + apiVersion: 'v1alpha1', + kind: 'Device', + metadata: { + name: 'device-002', + labels: { alias: 'edge-gw-02' } as Record, + owner: 'Fleet/eu-west-prod-001', + } as unknown as Device['metadata'], + spec: { + os: { image: 'quay.io/example/os:1.0.0' }, + config: [], + applications: [ + { + name: 'influxdb', + image: 'docker.io/library/influxdb:2.7', + }, + ] as any, + systemd: { + matchPatterns: ['influxdb.service'], + }, + } as any, + status: { + summary: { status: 'Online' } as any, + updated: { status: 'UpdateAvailable' } as any, + applicationsSummary: { status: 'Degraded' } as any, + integrity: { status: 'Unknown' } as any, + applications: [ + { + name: 'influxdb', + ready: '0/1', + restarts: 3, + status: 'Error', + }, + ], + resources: {} as any, + config: {} as any, + os: { image: 'quay.io/example/os:1.0.0' } as any, + lifecycle: { status: 'Enrolled' } as any, + systemInfo: { + architecture: 'x86_64', + operatingSystem: 'Linux', + distroName: 'Fedora', + distroVersion: '40', + kernel: '6.10.5', + hostname: 'edge-gw-02', + } as any, + } as any, + }, + ]; +} + +export const handlers = [ + // --- Auth/login endpoints --- + // Return a redirect URL for login flow (used by redirectToLogin) + http.get(loginBase, () => { + return HttpResponse.json({ url: '/callback?code=mock-code' }); + }), + // Exchange code for session; return token expiry + http.post(loginBase, () => { + return HttpResponse.json({ expiresIn: 3600 }); + }), + // Logged-in user info + http.get(`${loginBase}/info`, () => { + return HttpResponse.json({ username: 'mock-user' }); + }), + // Token refresh + http.get(`${loginBase}/refresh`, () => { + return HttpResponse.json({ expiresIn: 3600 }); + }), + // Logout via UI proxy + http.get('/api/logout', () => { + return HttpResponse.json({ url: '/' }); + }), + + // Devices list (+ optional query params) + http.get(new RegExp(`${apiBase}/devices(\\?.*)?$`), ({ request }) => { + const devices = getMockDevices(); + const url = new URL(request.url); + const summaryOnly = url.searchParams.get('summaryOnly') === 'true'; + if (summaryOnly) { + return HttpResponse.json({ + apiVersion: 'v1alpha1', + kind: 'DeviceList', + metadata: {}, + items: [], + summary: { + total: devices.length, + applicationStatus: { Healthy: 1, Degraded: 1 }, + summaryStatus: { Online: 2 }, + updateStatus: { UpToDate: 1, UpdateAvailable: 1 }, + }, + }); + } + return HttpResponse.json({ + apiVersion: 'v1alpha1', + kind: 'DeviceList', + metadata: {}, + items: devices, + }); + }), + + // Device details + http.get(new RegExp(`${apiBase}/devices/([\\w-]+)$`), ({ request }) => { + const url = new URL(request.url); + const nameMatch = url.pathname.match(/\/devices\/([\w-]+)$/); + const name = nameMatch ? nameMatch[1] : ''; + const dev = getMockDevices().find((d) => d.metadata.name === name); + if (dev) return HttpResponse.json(dev as Required); + return HttpResponse.json({ message: 'Not Found' }, { status: 404 }); + }), + + // Device last seen + http.get(new RegExp(`${apiBase}/devices/([\\w-]+)/lastseen$`), () => { + return HttpResponse.json({ lastSeen: new Date().toISOString() }); + }), + + // Patch device + http.patch(new RegExp(`${apiBase}/devices/([\\w-]+)$`), () => { + return HttpResponse.json({}); + }), + + // Decommission device + http.put(new RegExp(`${apiBase}/devices/([\\w-]+)/decommission$`), () => { + return HttpResponse.json({}); + }), + + // Delete device + http.delete(new RegExp(`${apiBase}/devices/([\\w-]+)$`), () => { + return HttpResponse.json({}); + }), + + // Fleets list + http.get(new RegExp(`${apiBase}/fleets(\\?.*)?$`), () => { + return HttpResponse.json(buildListResponse(basicFleets, 'FleetList')); + }), + + // Fleet by name + http.get(new RegExp(`${apiBase}/fleets/([\\w-]+)$`), ({ request }) => { + // params from RegExp route aren't parsed automatically; extract from URL + const url = new URL(request.url); + const nameMatch = url.pathname.match(/\/fleets\/([\w-]+)$/); + const name = nameMatch ? nameMatch[1] : ''; + const fleet = basicFleets.find((f) => f.metadata.name === name); + if (fleet) return HttpResponse.json(fleet); + return HttpResponse.json({ message: 'Not Found' }, { status: 404 }); + }), + + // Create fleet + http.post(`${apiBase}/fleets`, async ({ request }) => { + const body = (await request.json()) as Fleet; + const base = basicFleets[0]; + const created: Fleet = { + ...base, + metadata: { ...base.metadata, name: body?.metadata?.name ?? base.metadata.name }, + }; + return HttpResponse.json(created, { status: 201 }); + }), + + // Repositories list + http.get(`${apiBase}/repositories`, () => { + return HttpResponse.json(buildListResponse(repoList, 'RepositoryList')); + }), + + // Repository by name + http.get(new RegExp(`${apiBase}/repositories/([\\w-]+)$`), ({ request }) => { + const url = new URL(request.url); + const nameMatch = url.pathname.match(/\/repositories\/([\w-]+)$/); + const name = nameMatch ? nameMatch[1] : ''; + const repo = repoList.find((r) => r.metadata.name === name); + if (repo) return HttpResponse.json(repo); + return HttpResponse.json({ message: 'Not Found' }, { status: 404 }); + }), +]; + + diff --git a/apps/standalone/webpack.config.ts b/apps/standalone/webpack.config.ts index 7124abb7b..299724427 100644 --- a/apps/standalone/webpack.config.ts +++ b/apps/standalone/webpack.config.ts @@ -26,9 +26,15 @@ const config: Configuration & { port: 9000, historyApiFallback: true, open: true, - static: { - directory: path.resolve(__dirname, 'dist'), - }, + static: [ + { + directory: path.resolve(__dirname, 'dist'), + }, + { + directory: path.resolve(__dirname, 'public'), + watch: true, + }, + ], client: { overlay: true, }, @@ -159,6 +165,16 @@ const config: Configuration & { new CopyPlugin({ patterns: [{ from: '../../libs/i18n/locales', to: 'locales' }], }), + // Provide MSW worker script in dev server output so worker.start() can register it + new CopyPlugin({ + patterns: [ + { + from: path.resolve(__dirname, '../../node_modules/msw/browser.js'), + to: 'mockServiceWorker.js', + noErrorOnMissing: true, + }, + ], + }), ], resolve: { extensions: ['.js', '.ts', '.tsx', '.jsx'], @@ -201,11 +217,18 @@ if (NODE_ENV === 'production') { ); config.devtool = 'source-map'; } else { - config.plugins?.push( - new DefinePlugin({ - 'window.API_PORT': JSON.stringify(process.env.API_PORT) || '3001', - }), - ); + const useMsw = (process.env.USE_MSW || '').toLowerCase() === 'true'; + const defines: Record = { + 'process.env.USE_MSW': JSON.stringify(process.env.USE_MSW || ''), + }; + // In mock mode, force same-origin requests so the Service Worker can intercept them. + // Otherwise, default to proxy port or provided API_PORT. + if (useMsw) { + defines['window.API_PORT'] = 'undefined'; + } else { + defines['window.API_PORT'] = JSON.stringify(process.env.API_PORT) || '3001'; + } + config.plugins?.push(new DefinePlugin(defines)); } export default config; diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts index bce8ff81a..d3d9c4622 100644 --- a/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/deviceSpecUtils.ts @@ -226,10 +226,54 @@ export const toAPIApplication = (app: AppForm): ApplicationProviderSpec => { return app.name ? { ...data, name: app.name } : data; } + // Inline applications: support UI-only 'container' format which is encoded as quadlet + if ((app as InlineAppForm).inlineFormat === 'container' && (app as InlineAppForm).container) { + const name = (app as InlineAppForm).name; + const container = (app as InlineAppForm).container!; + const ports = (container.ports || []).map((p) => { + const proto = (p.protocol || 'tcp').toLowerCase(); + return `PublishPort=${p.hostPort}:${p.containerPort}/${proto}`; + }); + const mounts = (container.mounts || []).map((m) => `Volume=${m.name}:${m.mountPath}`); + const resources: string[] = []; + if (container.memory) resources.push(`Memory=${container.memory}`); + if (container.cpuQuota) resources.push(`CPUQuota=${container.cpuQuota}`); + if (container.cpuWeight !== undefined) resources.push(`CPUWeight=${container.cpuWeight}`); + const lines = [ + '[Unit]', + `Description=${name}`, + '', + '[Container]', + `Image=${container.image}`, + 'EnvironmentFile=.env', + ...ports, + ...mounts, + ...resources, + '', + '[Install]', + 'WantedBy=multi-user.target', + '', + ]; + const quadlet = lines.join('\n'); + return { + name, + appType: AppType.AppTypeQuadlet, + inline: [ + { + path: `${name}.container`, + content: quadlet, + contentEncoding: EncodingType.EncodingPlain, + }, + ], + envVars, + volumes, + }; + } + return { - name: app.name, - appType: app.appType || AppType.AppTypeCompose, - inline: app.files.map( + name: (app as InlineAppForm).name, + appType: (app.appType || AppType.AppTypeCompose), + inline: (app as InlineAppForm).files.map( (file): InlineApplicationFileFixed => ({ path: file.path, content: file.content || '', diff --git a/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx new file mode 100644 index 000000000..be8dc6d86 --- /dev/null +++ b/libs/ui-components/src/components/Device/EditDeviceWizard/steps/ApplicationContainerForm.tsx @@ -0,0 +1,193 @@ +import * as React from 'react'; +import { FieldArray, useField } from 'formik'; +import { Button, FormGroup, Grid, Split, SplitItem } from '@patternfly/react-core'; +import MinusCircleIcon from '@patternfly/react-icons/dist/js/icons/minus-circle-icon'; +import PlusCircleIcon from '@patternfly/react-icons/dist/js/icons/plus-circle-icon'; +import { useTranslation } from '../../../../hooks/useTranslation'; +import TextField from '../../../form/TextField'; +import ExpandableFormSection from '../../../form/ExpandableFormSection'; +import { InlineAppForm } from '../../../../types/deviceSpec'; + +interface Props { + app: InlineAppForm; + index: number; + isReadOnly?: boolean; +} + +const ApplicationContainerForm = ({ index, isReadOnly }: Props) => { + const { t } = useTranslation(); + const appFieldName = `applications[${index}]`; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [{ value: app }, , { setValue }] = useField(appFieldName); + + React.useEffect(() => { + if (!app.container) { + setValue( + { + ...app, + container: { + image: '', + ports: [], + mounts: [], + }, + }, + false, + ); + } + // ensure appType stays 'quadlet' for container format + if (app.inlineFormat === 'container' && app.appType !== ('quadlet' as unknown as InlineAppForm['appType'])) { + setValue( + { + ...app, + appType: 'quadlet' as unknown as InlineAppForm['appType'], + }, + false, + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [app.inlineFormat]); + + const containerField = `${appFieldName}.container`; + + return ( + + + + + + + + {({ push, remove }) => ( + <> + + + {t('Ports')} + + {!isReadOnly && ( + + + + )} + + {/* Use FieldArray's context to render list; values accessed by formik in TextField */} + {/* Renders simple triplet: hostPort, containerPort, protocol */} + {/* Index-based rendering handled by Formik/values */} + {/* Consumers validate in shared schema */} + {app.container?.ports?.map((_p, pIndex) => { + const pName = `${containerField}.ports[${pIndex}]`; + return ( + + + + + + + + + + + + + + + + + {!isReadOnly && ( + + + + )} + + {app.container?.mounts?.map((_m, mIndex) => { + const mName = `${containerField}.mounts[${mIndex}]`; + return ( + + + + + + + + + + + + {!isReadOnly && ( + +