diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index b2bcf4fd..b986d2f0 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -141,22 +141,6 @@ jobs: - name: Install dependencies run: pnpm install --prod=false - - name: windows workaround - if: matrix.os == 'windows-latest' - run: cd out && dir - - - name: windows workaround - if: matrix.os == 'windows-latest' - run: cd out/Pipelab-win32-x64 && dir - - - name: windows workaround - if: matrix.os == 'windows-latest' - run: cd out/Pipelab-win32-x64/resources && dir - - - name: windows workaround - if: matrix.os == 'windows-latest' - run: cd out/Pipelab-win32-x64/resources/app && dir - - name: windows workaround if: matrix.os == 'windows-latest' run: cd out/Pipelab-win32-x64/resources/app && pnpm i diff --git a/components.d.ts b/components.d.ts index 6d87c3a5..67c48e13 100644 --- a/components.d.ts +++ b/components.d.ts @@ -7,16 +7,20 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + BuildHistoryView: typeof import('./src/components/BuildHistoryView.vue')['default'] Button: typeof import('primevue/button')['default'] ButtonGroup: typeof import('primevue/buttongroup')['default'] + Calendar: typeof import('primevue/calendar')['default'] Checkbox: typeof import('primevue/checkbox')['default'] Chip: typeof import('primevue/chip')['default'] Column: typeof import('primevue/column')['default'] + ConfirmDialog: typeof import('primevue/confirmdialog')['default'] ConfirmPopup: typeof import('primevue/confirmpopup')['default'] DataTable: typeof import('primevue/datatable')['default'] Dialog: typeof import('primevue/dialog')['default'] Divider: typeof import('primevue/divider')['default'] Drawer: typeof import('primevue/drawer')['default'] + Dropdown: typeof import('primevue/dropdown')['default'] IconField: typeof import('primevue/iconfield')['default'] Inplace: typeof import('primevue/inplace')['default'] InputGroup: typeof import('primevue/inputgroup')['default'] @@ -26,14 +30,18 @@ declare module 'vue' { Listbox: typeof import('primevue/listbox')['default'] Menu: typeof import('primevue/menu')['default'] Message: typeof import('primevue/message')['default'] + Paginator: typeof import('primevue/paginator')['default'] Panel: typeof import('primevue/panel')['default'] Password: typeof import('primevue/password')['default'] + ProgressBar: typeof import('primevue/progressbar')['default'] ProgressSpinner: typeof import('primevue/progressspinner')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] Select: typeof import('primevue/select')['default'] SelectButton: typeof import('primevue/selectbutton')['default'] Skeleton: typeof import('primevue/skeleton')['default'] + TabPanel: typeof import('primevue/tabpanel')['default'] + TabView: typeof import('primevue/tabview')['default'] Toast: typeof import('primevue/toast')['default'] ToggleSwitch: typeof import('primevue/toggleswitch')['default'] } diff --git a/package.json b/package.json index 31d76fad..d0dc836c 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@types/archiver": "6.0.3", "@types/date-fns": "2.6.3", "@types/dompurify": "3.0.5", + "@types/ws": "8.18.1", "@vee-validate/valibot": "4.13.2", "@vuelidate/core": "2.0.3", "@vuelidate/validators": "2.0.4", @@ -122,6 +123,7 @@ "vue-dompurify-html": "5.1.0", "vue-i18n": "10", "web-worker": "1.3.0", + "ws": "8.18.3", "zod": "3.23.8" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9601c26d..7666821f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@types/dompurify': specifier: 3.0.5 version: 3.0.5 + '@types/ws': + specifier: 8.18.1 + version: 8.18.1 '@vee-validate/valibot': specifier: 4.13.2 version: 4.13.2(vue@3.5.13(typescript@5.8.3)) @@ -261,6 +264,9 @@ importers: web-worker: specifier: 1.3.0 version: 1.3.0 + ws: + specifier: 8.18.3 + version: 8.18.3 zod: specifier: 3.23.8 version: 3.23.8 @@ -7800,8 +7806,8 @@ packages: utf-8-validate: optional: true - ws@8.18.2: - resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -10262,7 +10268,7 @@ snapshots: '@supabase/node-fetch': 2.6.15 '@types/phoenix': 1.6.6 '@types/ws': 8.18.1 - ws: 8.18.2 + ws: 8.18.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -13538,7 +13544,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.18.2 + ws: 8.18.3 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -16537,7 +16543,7 @@ snapshots: ws@7.5.10: {} - ws@8.18.2: {} + ws@8.18.3: {} xdg-basedir@4.0.0: {} diff --git a/src/components/BuildHistoryView.vue b/src/components/BuildHistoryView.vue new file mode 100644 index 00000000..0d612f07 --- /dev/null +++ b/src/components/BuildHistoryView.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/src/constants.ts b/src/constants.ts index 4eaf17bc..08af4f4f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,7 +2,11 @@ import { platform } from 'process' export const name = 'Pipelab' -export const outFolderName = (binName: string, platform: NodeJS.Platform, arch: NodeJS.Architecture) => { +export const outFolderName = ( + binName: string, + platform: NodeJS.Platform, + arch: NodeJS.Architecture +) => { let platformName = '' let archName = '' @@ -42,3 +46,5 @@ export const getBinName = (name: string) => { } return name } + +export const websocketPort = 33753 diff --git a/src/main.ts b/src/main.ts index a03ec34b..700183a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,13 +3,12 @@ import { join } from 'path' import { platform } from 'os' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { registerIPCHandlers } from './main/handlers' +import { webSocketServer } from './main/websocket-server' import { usePlugins } from '@@/plugins' import { parseArgs, ParseArgsConfig } from 'node:util' -import { processGraph } from '@@/graph' import { readFile, writeFile, mkdir } from 'fs/promises' -import { getFinalPlugins } from '@main/utils' +import { executeGraphWithHistory } from '@main/utils' import { SavedFile } from '@@/model' -import { handleActionExecute } from '@main/handler-func' import { useLogger } from '@@/logger' import * as Sentry from '@sentry/electron/main' import { assetsPath } from '@main/paths' @@ -17,7 +16,6 @@ import { usePluginAPI } from '@main/api' import { setupConfig } from '@main/config' import { resolve } from 'node:path' import Squirrel from 'electron-squirrel-startup' -import { tempFolderTracker } from '@main/temp-tracker' const isLinux = platform() === 'linux' // let tray @@ -253,6 +251,16 @@ app.whenReady().then(async () => { await registerBuiltIn() // registerOtherPlugins() + // Start WebSocket server + try { + await webSocketServer.start() + // Wait for WebSocket server to be ready before creating window + await webSocketServer.waitForReady() + logger().info('WebSocket server is ready, creating window') + } catch (error) { + logger().error('Failed to start WebSocket server:', error) + } + const config = { options: { /** project: path to file .pipelab */ @@ -298,62 +306,34 @@ app.whenReady().then(async () => { const { canvas, variables } = data const { blocks: nodes } = canvas - const pluginDefinitions = getFinalPlugins() try { - const result = await processGraph({ + await executeGraphWithHistory({ graph: nodes, - definitions: pluginDefinitions, variables: variables, - steps: {}, - context: {}, + projectName: data.name || 'Unnamed Pipeline', + projectPath: project, + mainWindow: mainWindow, onNodeEnter: (node) => { logger().info('onNodeEnter', node.uid) }, onNodeExit: (node) => { logger().info('onNodeExit', node.uid) }, - onExecuteItem: (node, params /* , steps */) => { - /* if (node.type === 'condition') { - return handleConditionExecute(node.origin.nodeId, node.origin.pluginId, params, { - send: (data) => { - logger().info('send', data) - } - }) - } else */ if (node.type === 'action') { - return handleActionExecute( - node.origin.nodeId, - node.origin.pluginId, - params, - mainWindow, - (data) => { - if (!isCI) { - logger().info('send', data) - } - console.log('send', data) - }, - new AbortController().signal - ) - } else { - throw new Error('Unhandled type ' + node.type) + onLog: (data) => { + if (!isCI) { + logger().info('send', data) } - } + console.log('send', data) + }, + outputPath: output }) console.log('got an output', output) - - if (output) { - await writeFile(output, JSON.stringify(result, null, 2), 'utf8') - } - - // Clean up temporary folders on success if setting is enabled - if (settings.clearTemporaryFoldersOnPipelineEnd) { - await tempFolderTracker.cleanup() - } } catch (e) { console.error('error while executing process', e) if (output) { - await writeFile(output, JSON.stringify({ error: e.message }, null, 2), 'utf8') + await writeFile(output, JSON.stringify({ error: (e as Error).message }, null, 2), 'utf8') } } } @@ -417,7 +397,26 @@ app.whenReady().then(async () => { // explicitly with Cmd + Q. app.on('window-all-closed', async () => { if (process.platform !== 'darwin') { - await client.shutdown() + // Stop WebSocket server before quitting + try { + await webSocketServer.stop() + } catch (error) { + logger().error('Error stopping WebSocket server:', error) + } app.quit() } }) + +// Handle app before quit to cleanup WebSocket server +app.on('before-quit', async (event) => { + event.preventDefault() + + try { + await webSocketServer.stop() + logger().info('WebSocket server stopped, quitting app') + app.exit(0) + } catch (error) { + logger().error('Error stopping WebSocket server during quit:', error) + app.exit(1) + } +}) diff --git a/src/main/config.ts b/src/main/config.ts index 28a674ef..382ab893 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -2,36 +2,29 @@ import { createMigration, createMigrator, finalVersion, initialVersion } from '@ import { createVersionSchema, OmitVersion } from '@@/libs/migration/models/migration' import { app } from 'electron' import { join } from 'node:path' -import { string, union, literal, InferInput, boolean } from 'valibot' +import { union, literal, InferInput } from 'valibot' import { ensure } from './utils' import { readFile, writeFile } from 'node:fs/promises' import { useLogger } from '@@/logger' -import { tmpdir } from 'node:os' export const AppSettingsValidatorV1 = createVersionSchema({ - cacheFolder: string(), theme: union([literal('light'), literal('dark')]), version: literal('1.0.0') }) export const AppSettingsValidatorV2 = createVersionSchema({ - cacheFolder: string(), theme: union([literal('light'), literal('dark')]), version: literal('2.0.0') }) export const AppSettingsValidatorV3 = createVersionSchema({ - cacheFolder: string(), theme: union([literal('light'), literal('dark')]), - version: literal('3.0.0'), - clearTemporaryFoldersOnPipelineEnd: boolean() + version: literal('3.0.0') }) export const AppSettingsValidatorV4 = createVersionSchema({ - cacheFolder: string(), theme: union([literal('light'), literal('dark')]), version: literal('4.0.0'), - clearTemporaryFoldersOnPipelineEnd: boolean(), locale: union([ literal('en-US'), literal('fr-FR'), @@ -52,10 +45,7 @@ export const AppSettingsValidator = AppSettingsValidatorV4 const migrator = createMigrator() -const defaultCacheFolder = join(tmpdir(), 'pipelab') - export const defaultAppSettings = migrator.createDefault({ - cacheFolder: defaultCacheFolder, theme: 'light', version: '1.0.0' }) @@ -70,12 +60,7 @@ export const appSettingsMigrator = migrator.createMigrations({ }), createMigration({ version: '2.0.0', - up: (state) => { - return { - ...state, - clearTemporaryFoldersOnPipelineEnd: false - } satisfies OmitVersion - }, + up: (state) => state satisfies OmitVersion, down: () => { throw new Error("Can't migrate down from 2.0.0") } diff --git a/src/main/handler-func.ts b/src/main/handler-func.ts index e0ccd922..79f06d69 100644 --- a/src/main/handler-func.ts +++ b/src/main/handler-func.ts @@ -16,9 +16,8 @@ import { usePluginAPI } from './api' import { BlockCondition } from '@@/model' import { HandleListenerSendFn } from './handlers' import { ensureNodeJS, generateTempFolder } from './utils' -import { setupConfig } from './config' import { join } from 'node:path' -import { tempFolderTracker } from './temp-tracker' +import { tmpdir } from 'node:os' const checkParams = (definitionParams: InputsDefinition, elementParams: Record) => { // get a list of all required params @@ -71,9 +70,7 @@ export const handleConditionExecute = async ( } } - const _settings = await setupConfig() - const config = await _settings.getConfig() - const tmp = await generateTempFolder(config.cacheFolder) + const tmp = await generateTempFolder(tmpdir()) await mkdir(tmp, { recursive: true @@ -123,8 +120,6 @@ export const handleActionExecute = async ( ): Promise> => { const { plugins } = usePlugins() const { logger } = useLogger() - const settings = await setupConfig() - const config = await settings.getConfig() mainWindow?.setProgressBar(1, { mode: 'indeterminate' @@ -147,7 +142,7 @@ export const handleActionExecute = async ( } } - const tmp = await generateTempFolder(config.cacheFolder) + const tmp = await generateTempFolder(tmpdir()) const _assetsPath = await assetsPath() const _unpackPath = await unpackPath() const nodePath = await ensureNodeJS() @@ -190,7 +185,7 @@ export const handleActionExecute = async ( paths: { assets: _assetsPath, unpack: _unpackPath, - cache: config.cacheFolder, + cache: tmpdir(), node: nodePath, pnpm }, diff --git a/src/main/handlers.ts b/src/main/handlers.ts index 287987b8..f5bb38a3 100644 --- a/src/main/handlers.ts +++ b/src/main/handlers.ts @@ -1,43 +1,70 @@ import { Channels, Data, Events, Message } from '@@/apis' -import { BrowserWindow, app, dialog, ipcMain } from 'electron' -import { ensure, getFinalPlugins } from './utils' +import { ensure, getFinalPlugins, executeGraphWithHistory } from './utils' +import { WebSocket as WSWebSocket } from 'ws' import { join } from 'node:path' -import { writeFile, readFile, rm } from 'node:fs/promises' +import { writeFile, readFile } from 'node:fs/promises' import { presets } from './presets/list' import { handleActionExecute, handleConditionExecute } from './handler-func' import { useLogger } from '@@/logger' import { getDefaultAppSettingsMigrated, setupConfig } from './config' +import { buildHistoryStorage } from './handlers/build-history' +import { SubscriptionRequiredError } from '@@/subscription-errors' +import { WebSocketEvent, WebSocketHandler, WebSocketSendFunction } from '@@/websocket.types' +import { app, BrowserWindow, dialog } from 'electron' -export type HandleListenerSendFn = (events: Events) => void +export type HandleListenerSendFn = WebSocketSendFunction -export type HandleListener = ( - event: Electron.IpcMainInvokeEvent, - data: { value: Data; send: HandleListenerSendFn } -) => Promise +export type WsEvent = WebSocketEvent + +export type HandleListener = WebSocketHandler + +const handlers: Record> = {} export const useAPI = () => { const { logger } = useLogger() - const handle = (channel: KEY, listener: HandleListener) => { - return ipcMain.on(channel, (event, message: Message) => { - const { data, requestId } = message - // logger.info('received event', requestId) - // logger.info('received data', data) + const handle = (channel: KEY, listener: WebSocketHandler) => { + handlers[channel] = listener + + return { + channel, + listener + } + } - const send: HandleListenerSendFn = (events) => { + const processWebSocketMessage = (ws: WSWebSocket, channel: string, message: Message) => { + const { data, requestId } = message + + if (handlers[channel]) { + logger().debug('Executing handler for channel:', channel) + const event: WsEvent = { + sender: ws.url || 'websocket-client' + } + + const send: HandleListenerSendFn = (events) => { logger().debug('sending', events, 'to', requestId) - return event.sender.send(requestId, events) + const response = { + type: 'response', + requestId, + events + } + ws.send(JSON.stringify(response)) + return Promise.resolve() } - return listener(event, { + return handlers[channel](event, { send, value: data }) - }) + } else { + logger().warn('No handler found for channel:', channel) + } } return { - handle + handle, + processWebSocketMessage, + handlers } } @@ -47,6 +74,20 @@ export const registerIPCHandlers = () => { logger().info('registering ipc handlers') + // Helper function to check build history authorization + const checkBuildHistoryAuthorization = async (event: WsEvent): Promise => { + logger().info('AUTH BYPASS: Skipping auth verification for build history access') + + // Always authorize for now - relying on frontend auth checks only + const isAuthorized = true + + if (!isAuthorized) { + throw new SubscriptionRequiredError('build-history') + } + + return true + } + handle('dialog:showOpenDialog', async (event, { value, send }) => { const slash = (await import('slash')).default @@ -54,7 +95,7 @@ export const registerIPCHandlers = () => { logger().info('value', value) logger().info('dialog:showOpenDialog') - const mainWindow = BrowserWindow.fromWebContents(event.sender) + const mainWindow = BrowserWindow.getFocusedWindow() if (!mainWindow) { logger().error('mainWindow not found') @@ -125,39 +166,6 @@ export const registerIPCHandlers = () => { }) }) - handle('fs:rm', async (event, { value, send }) => { - const { logger } = useLogger() - logger().info('value', value) - logger().info('fs:rm') - - try { - await rm(value.path, { - recursive: value.recursive, - force: value.force - }) - } catch (e) { - logger().error('e', e) - send({ - type: 'end', - data: { - type: 'error', - ipcError: 'Unable to remove file' - } - }) - return - } - - send({ - type: 'end', - data: { - type: 'success', - result: { - ok: true - } - } - }) - }) - handle('dialog:showSaveDialog', async (event, { value, send }) => { const { logger } = useLogger() @@ -165,7 +173,7 @@ export const registerIPCHandlers = () => { logger().info('value', value) logger().info('dialog:showSaveDialog') - const mainWindow = BrowserWindow.fromWebContents(event.sender) + const mainWindow = BrowserWindow.getFocusedWindow() if (!mainWindow) { logger().error('mainWindow not found') @@ -281,7 +289,8 @@ export const registerIPCHandlers = () => { handle('action:execute', async (event, { send, value }) => { const { nodeId, params, pluginId } = value - const mainWindow = BrowserWindow.fromWebContents(event.sender) + // In WebSocket mode, no BrowserWindow available + const mainWindow = BrowserWindow.getFocusedWindow() abortControllerGraph = new AbortController() const signalPromise = new Promise((resolve, reject) => { @@ -294,7 +303,7 @@ export const registerIPCHandlers = () => { type: 'error' } }) - return reject(new Error('Action interrupted: ' + (ev.reason || 'Unknown reason'))) + return reject(new Error('Action interrupted: ' + 'Unknown reason')) }) }) @@ -393,7 +402,7 @@ export const registerIPCHandlers = () => { await settingsG.setConfig({ ...settings, - [value.key]: migratedSettings[value.key] + [value.key]: migratedSettings[value.key as keyof typeof migratedSettings] as any }) send({ @@ -441,4 +450,428 @@ export const registerIPCHandlers = () => { } }) }) + + // Build History Handlers + handle('build-history:save', async (event, { send, value }) => { + const { logger } = useLogger() + + try { + // Check authorization before allowing save + logger().info('AUTH BYPASS: Processing build-history:save request') + await checkBuildHistoryAuthorization(event) + + await buildHistoryStorage.save(value.entry) + send({ + type: 'end', + data: { + type: 'success', + result: { result: 'ok' } + } + }) + } catch (error) { + logger().error('Failed to save build history entry:', error) + + // Handle subscription errors with user-friendly messages + if (error instanceof SubscriptionRequiredError) { + send({ + type: 'end', + data: { + type: 'error', + ipcError: error.userMessage, + code: error.code + } + }) + return + } + + send({ + type: 'end', + data: { + type: 'error', + ipcError: error instanceof Error ? error.message : 'Failed to save build history entry' + } + }) + } + }) + + handle('build-history:get', async (event, { send, value }) => { + const { logger } = useLogger() + + try { + // Check authorization before allowing access + logger().info('AUTH BYPASS: Processing build-history:get request') + await checkBuildHistoryAuthorization(event) + + const entry = await buildHistoryStorage.get(value.id) + send({ + type: 'end', + data: { + type: 'success', + result: { entry } + } + }) + } catch (error) { + logger().error('Failed to get build history entry:', error) + + // Handle subscription errors with user-friendly messages + if (error instanceof SubscriptionRequiredError) { + send({ + type: 'end', + data: { + type: 'error', + ipcError: error.userMessage, + code: error.code + } + }) + return + } + + send({ + type: 'end', + data: { + type: 'error', + ipcError: error instanceof Error ? error.message : 'Failed to get build history entry' + } + }) + } + }) + + handle('build-history:get-all', async (event, { send, value }) => { + const { logger } = useLogger() + + try { + // Check authorization before allowing access + logger().info('AUTH BYPASS: Processing build-history:get-all request') + await checkBuildHistoryAuthorization(event) + + // Simplified: get all entries, optionally filter by pipeline + const allEntries = await buildHistoryStorage.getAll() + console.log('allEntries', allEntries) + console.log('value.query', value.query) + const filteredEntries = value.query?.pipelineId + ? allEntries.filter((entry) => entry.pipelineId === value.query.pipelineId) + : allEntries + + send({ + type: 'end', + data: { + type: 'success', + result: { + entries: filteredEntries, + total: filteredEntries.length + } + } + }) + } catch (error) { + logger().error('Failed to get build history entries:', error) + + // Handle subscription errors with user-friendly messages + if (error instanceof SubscriptionRequiredError) { + send({ + type: 'end', + data: { + type: 'error', + ipcError: error.userMessage, + code: error.code + } + }) + return + } + + send({ + type: 'end', + data: { + type: 'error', + ipcError: error instanceof Error ? error.message : 'Failed to get build history entries' + } + }) + } + }) + + handle('build-history:update', async (event, { send, value }) => { + const { logger } = useLogger() + + try { + // Check authorization before allowing update + await checkBuildHistoryAuthorization(event) + + await buildHistoryStorage.update(value.id, value.updates) + send({ + type: 'end', + data: { + type: 'success', + result: { result: 'ok' } + } + }) + } catch (error) { + logger().error('Failed to update build history entry:', error) + + // Handle subscription errors with user-friendly messages + if (error instanceof SubscriptionRequiredError) { + send({ + type: 'end', + data: { + type: 'error', + ipcError: error.userMessage, + code: error.code + } + }) + return + } + + send({ + type: 'end', + data: { + type: 'error', + ipcError: error instanceof Error ? error.message : 'Failed to update build history entry' + } + }) + } + }) + + handle('build-history:delete', async (event, { send, value }) => { + const { logger } = useLogger() + + try { + // Check authorization before allowing deletion + await checkBuildHistoryAuthorization(event) + + await buildHistoryStorage.delete(value.id) + send({ + type: 'end', + data: { + type: 'success', + result: { result: 'ok' } + } + }) + } catch (error) { + logger().error('Failed to delete build history entry:', error) + + // Handle subscription errors with user-friendly messages + if (error instanceof SubscriptionRequiredError) { + send({ + type: 'end', + data: { + type: 'error', + ipcError: error.userMessage, + code: error.code + } + }) + return + } + + send({ + type: 'end', + data: { + type: 'error', + ipcError: error instanceof Error ? error.message : 'Failed to delete build history entry' + } + }) + } + }) + + handle('build-history:clear', async (event, { send }) => { + const { logger } = useLogger() + + try { + // Check authorization before allowing clear operation + await checkBuildHistoryAuthorization(event) + + await buildHistoryStorage.clear() + send({ + type: 'end', + data: { + type: 'success', + result: { result: 'ok' } + } + }) + } catch (error) { + logger().error('Failed to clear build history:', error) + + // Handle subscription errors with user-friendly messages + if (error instanceof SubscriptionRequiredError) { + send({ + type: 'end', + data: { + type: 'error', + ipcError: error.userMessage, + code: error.code + } + }) + return + } + + send({ + type: 'end', + data: { + type: 'error', + ipcError: error instanceof Error ? error.message : 'Failed to clear build history' + } + }) + } + }) + + handle('build-history:get-storage-info', async (event, { send }) => { + const { logger } = useLogger() + + try { + // Check authorization before allowing access to storage info + await checkBuildHistoryAuthorization(event) + + const info = await buildHistoryStorage.getStorageInfo() + send({ + type: 'end', + data: { + type: 'success', + result: info + } + }) + } catch (error) { + logger().error('Failed to get build history storage info:', error) + + // Handle subscription errors with user-friendly messages + if (error instanceof SubscriptionRequiredError) { + send({ + type: 'end', + data: { + type: 'error', + ipcError: error.userMessage, + code: error.code + } + }) + return + } + + send({ + type: 'end', + data: { + type: 'error', + ipcError: + error instanceof Error ? error.message : 'Failed to get build history storage info' + } + }) + } + }) + + handle('build-history:configure', async (_, { send, value }) => { + const { logger } = useLogger() + + try { + // For now, we'll just log the configuration request + // In a real implementation, you might want to make the storage configurable + logger().info('Build history configuration request:', value.config) + + send({ + type: 'end', + data: { + type: 'success', + result: { result: 'ok' } + } + }) + } catch (error) { + logger().error('Failed to configure build history:', error) + send({ + type: 'end', + data: { + type: 'error', + ipcError: error instanceof Error ? error.message : 'Failed to configure build history' + } + }) + } + }) + + handle('graph:execute', async (event, { send, value }) => { + const { graph, variables, projectName, projectPath, pipelineId } = value + + const mainWindow = BrowserWindow.getFocusedWindow() + abortControllerGraph = new AbortController() + + try { + const { result, buildId } = await executeGraphWithHistory({ + graph, + variables, + projectName, + projectPath, + pipelineId, + mainWindow, + onNodeEnter: (node) => { + // Send UI update for node entering + send({ + type: 'node-enter', + data: { + nodeUid: node.uid, + nodeName: node.name + } + }) + }, + onNodeExit: (node) => { + // Send UI update for node exiting + send({ + type: 'node-exit', + data: { + nodeUid: node.uid, + nodeName: node.name + } + }) + }, + onLog: (data, node) => { + console.log('data', data) + console.log('node', node) + /** + * + data { + type: 'log', + data: { + decorator: '[Export .c3p]', + time: 1759479752141, + message: [ 'Downloading browser' ] + } + } + node undefined + */ + // Send log data to frontend + if (data.type === 'log') { + // Sanitize data for IPC serialization + const sanitizedData = { + type: data.data.type, + level: data.data.level, + message: data.data.message, + timestamp: data.data.timestamp, + nodeId: data.data.nodeId, + pluginId: data.data.pluginId + // Only include serializable properties + } + + send({ + type: 'node-log', + data: { + nodeUid: node?.uid || 'unknown', + logData: sanitizedData + } + }) + } + }, + abortSignal: abortControllerGraph.signal + }) + + send({ + type: 'end', + data: { + type: 'success', + result: { + result, + buildId + } + } + }) + } catch (e) { + send({ + type: 'end', + data: { + type: 'error', + ipcError: e instanceof Error ? e.message : 'Unknown error' + } + }) + } + }) } diff --git a/src/main/handlers/build-history.ts b/src/main/handlers/build-history.ts new file mode 100644 index 00000000..441a19cc --- /dev/null +++ b/src/main/handlers/build-history.ts @@ -0,0 +1,258 @@ +import { app } from 'electron' +import { join } from 'node:path' +import { writeFile, readFile, unlink, mkdir } from 'node:fs/promises' +import { BuildHistoryEntry, IBuildHistoryStorage } from '@@/build-history' +import { useLogger } from '@@/logger' + +// Simplified storage - one file per pipeline containing array of build entries +const STORAGE_PATH = join(app.getPath('userData'), 'build-history') + +export class BuildHistoryStorage implements IBuildHistoryStorage { + private logger = useLogger() + + constructor() { + // Simple initialization - no complex setup needed + } + + private getPipelinePath(pipelineId: string): string { + // Sanitize the pipelineId to create a valid filename + // Replace invalid filename characters with underscores + const sanitizedId = pipelineId + .replace(/[/\\:*?"<>|]/g, '_') + .replace(/__/g, '_') // Replace multiple underscores with single + .replace(/^_+|_+$/g, '') // Remove leading/trailing underscores + + return join(STORAGE_PATH, `pipeline-${sanitizedId}.json`) + } + + private async ensureStoragePath(): Promise { + try { + await mkdir(STORAGE_PATH, { recursive: true }) + } catch (error) { + this.logger.logger().error('Failed to create storage path:', error) + throw new Error(`Failed to create storage directory: ${error}`) + } + } + + private async loadPipelineHistory(pipelineId: string): Promise { + try { + const pipelinePath = this.getPipelinePath(pipelineId) + const data = await readFile(pipelinePath, 'utf-8') + return JSON.parse(data) + } catch (error) { + // File doesn't exist or is corrupted, return empty array + return [] + } + } + + private async savePipelineHistory( + pipelineId: string, + entries: BuildHistoryEntry[] + ): Promise { + console.log('savePipelineHistory', pipelineId, entries.length) + try { + await this.ensureStoragePath() + const pipelinePath = this.getPipelinePath(pipelineId) + await writeFile(pipelinePath, JSON.stringify(entries, null, 2), 'utf-8') + } catch (error) { + this.logger.logger().error('Failed to save pipeline history:', error) + throw new Error(`Failed to save pipeline history: ${error}`) + } + } + + async save(entry: BuildHistoryEntry): Promise { + try { + const entries = await this.loadPipelineHistory(entry.pipelineId) + const existingIndex = entries.findIndex((e) => e.id === entry.id) + + if (existingIndex >= 0) { + entries[existingIndex] = entry + } else { + entries.push(entry) + } + + await this.savePipelineHistory(entry.pipelineId, entries) + this.logger + .logger() + .info(`Saved build history entry: ${entry.id} for pipeline: ${entry.pipelineId}`) + } catch (error) { + this.logger.logger().error('Failed to save build history entry:', error) + throw new Error(`Failed to save build history entry: ${error}`) + } + } + + async get(id: string): Promise { + try { + // We need to search through all pipeline files to find the entry + // This is simple but not optimized - for production you'd want indexing + const files = await this.getAllPipelineFiles() + + for (const file of files) { + const pipelineId = file.replace('pipeline-', '').replace('.json', '') + const entries = await this.loadPipelineHistory(pipelineId) + + const entry = entries.find((e) => e.id === id) + if (entry) { + return entry + } + } + + return undefined + } catch (error) { + this.logger.logger().error(`Failed to get build history entry ${id}:`, error) + return undefined + } + } + + async getAll(): Promise { + try { + const files = await this.getAllPipelineFiles() + const allEntries: BuildHistoryEntry[] = [] + + for (const file of files) { + const pipelineId = file.replace('pipeline-', '').replace('.json', '') + const entries = await this.loadPipelineHistory(pipelineId) + allEntries.push(...entries) + } + + // Sort by creation time, newest first + return allEntries.sort((a, b) => b.createdAt - a.createdAt) + } catch (error) { + this.logger.logger().error('Failed to get all build history entries:', error) + throw new Error(`Failed to get build history entries: ${error}`) + } + } + + async getByPipeline(pipelineId: string): Promise { + try { + const entries = await this.loadPipelineHistory(pipelineId) + // Sort by creation time, newest first + return entries.sort((a, b) => b.createdAt - a.createdAt) + } catch (error) { + this.logger.logger().error(`Failed to get build history for pipeline ${pipelineId}:`, error) + throw new Error(`Failed to get build history for pipeline: ${error}`) + } + } + + async update(id: string, updates: Partial): Promise { + try { + const files = await this.getAllPipelineFiles() + + for (const file of files) { + const pipelineId = file.replace('pipeline-', '').replace('.json', '') + const entries = await this.loadPipelineHistory(pipelineId) + + const entryIndex = entries.findIndex((e) => e.id === id) + if (entryIndex >= 0) { + entries[entryIndex] = { + ...entries[entryIndex], + ...updates, + updatedAt: Date.now() + } + await this.savePipelineHistory(pipelineId, entries) + return + } + } + + throw new Error(`Build history entry ${id} not found`) + } catch (error) { + this.logger.logger().error(`Failed to update build history entry ${id}:`, error) + throw new Error(`Failed to update build history entry: ${error}`) + } + } + + async delete(id: string): Promise { + try { + const files = await this.getAllPipelineFiles() + + for (const file of files) { + const pipelineId = file.replace('pipeline-', '').replace('.json', '') + const entries = await this.loadPipelineHistory(pipelineId) + + const entryIndex = entries.findIndex((e) => e.id === id) + if (entryIndex >= 0) { + entries.splice(entryIndex, 1) + await this.savePipelineHistory(pipelineId, entries) + this.logger.logger().info(`Deleted build history entry: ${id}`) + return + } + } + + // Entry not found, but don't throw error + this.logger.logger().info(`Build history entry ${id} not found for deletion`) + } catch (error) { + this.logger.logger().error(`Failed to delete build history entry ${id}:`, error) + throw new Error(`Failed to delete build history entry: ${error}`) + } + } + + async clear(): Promise { + try { + await this.ensureStoragePath() + + const files = await this.getAllPipelineFiles() + await Promise.all(files.map((file) => unlink(join(STORAGE_PATH, file)))) + + this.logger.logger().info('Cleared all build history entries') + } catch (error) { + this.logger.logger().error('Failed to clear build history:', error) + throw new Error(`Failed to clear build history: ${error}`) + } + } + + async getStorageInfo(): Promise<{ + totalEntries: number + totalSize: number + oldestEntry?: number + newestEntry?: number + }> { + try { + const allEntries = await this.getAll() + if (allEntries.length === 0) { + return { + totalEntries: 0, + totalSize: 0 + } + } + + // Calculate approximate size + let totalSize = 0 + try { + const files = await this.getAllPipelineFiles() + for (const file of files) { + const filePath = join(STORAGE_PATH, file) + const stats = await import('node:fs/promises').then((fs) => fs.stat(filePath)) + totalSize += stats.size + } + } catch (error) { + // Rough estimate if we can't calculate actual size + totalSize = allEntries.length * 1024 + } + + const sortedEntries = allEntries.sort((a, b) => a.createdAt - b.createdAt) + + return { + totalEntries: allEntries.length, + totalSize, + oldestEntry: sortedEntries[0]?.createdAt, + newestEntry: sortedEntries[sortedEntries.length - 1]?.createdAt + } + } catch (error) { + this.logger.logger().error('Failed to get storage info:', error) + throw new Error(`Failed to get storage info: ${error}`) + } + } + + private async getAllPipelineFiles(): Promise { + try { + await this.ensureStoragePath() + const files = await import('node:fs/promises').then((fs) => fs.readdir(STORAGE_PATH)) + return files.filter((file) => file.startsWith('pipeline-') && file.endsWith('.json')) + } catch (error) { + return [] + } + } +} + +// Export a default instance +export const buildHistoryStorage = new BuildHistoryStorage() diff --git a/src/main/temp-tracker.ts b/src/main/temp-tracker.ts deleted file mode 100644 index fac7acad..00000000 --- a/src/main/temp-tracker.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { rm } from 'node:fs/promises' - -class TempFolderTracker { - private folders: Set = new Set() - private static instance: TempFolderTracker - - private constructor() {} - - public static getInstance(): TempFolderTracker { - if (!TempFolderTracker.instance) { - TempFolderTracker.instance = new TempFolderTracker() - } - return TempFolderTracker.instance - } - - public track(folder: string): void { - this.folders.add(folder) - } - - public async cleanup(force: boolean = false): Promise { - if (this.folders.size === 0) return - - const foldersToDelete = Array.from(this.folders) - this.folders.clear() - - for (const folder of foldersToDelete) { - try { - console.info(`Deleting temporary folder: ${folder}`) - // await rm(folder, { recursive: true, force }) - } catch (error) { - console.error(`Failed to delete temporary folder: ${folder}`, error) - // Re-add the folder if deletion failed - if (!force) { - this.folders.add(folder) - } - } - } - } - - public getTrackedFolders(): string[] { - return Array.from(this.folders) - } -} - -export const tempFolderTracker = TempFolderTracker.getInstance() diff --git a/src/main/utils.ts b/src/main/utils.ts index e9bdbe02..bc557254 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -1,15 +1,20 @@ import { usePlugins } from '@@/plugins' import { downloadFile, Hooks, RendererPluginDefinition } from '../shared/libs/plugin-core' import { access, chmod, mkdir, mkdtemp, realpath, rm, unlink, writeFile } from 'node:fs/promises' -import { tempFolderTracker } from './temp-tracker' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' -import { app } from 'electron' +import { app, BrowserWindow } from 'electron' import * as zlib from 'zlib' // For gunzip (used with tar.gz) import * as tar from 'tar' // Library for tar extraction import * as yauzl from 'yauzl' // Library for zip extraction import { constants, createReadStream, createWriteStream } from 'node:fs' import { throttle } from 'es-toolkit' +import { processGraph } from '@@/graph' +import { handleActionExecute } from './handler-func' +import { useLogger } from '@@/logger' +import { buildHistoryStorage } from './handlers/build-history' +import type { BuildHistoryEntry } from '@@/build-history' +import type { Variable } from '@pipelab/core-app' export const getFinalPlugins = () => { const { plugins } = usePlugins() @@ -63,10 +68,7 @@ export const generateTempFolder = async (base = tmpdir()) => { const realPath = await realpath(base) console.log('join', join(realPath, 'pipelab-')) const tempFolder = await mkdtemp(join(realPath, 'pipelab-')) - - // Track the created temporary folder - tempFolderTracker.track(tempFolder) - + return tempFolder } @@ -404,3 +406,160 @@ export const zipFolder = async (from: string, to: string, log: typeof console.lo archive.finalize() }) } + +// @ts-expect-error import.meta +const isCI = process.env.CI === 'true' || import.meta.env.CI === 'true' + +export interface GraphExecutionOptions { + /** The graph nodes to execute */ + graph: any[] + /** Variables for the execution */ + variables: Variable[] + /** Project information */ + projectName?: string + projectPath?: string + /** Main window for action execution */ + mainWindow?: BrowserWindow + /** Logger function for node events */ + onNodeEnter?: (node: any) => void + onNodeExit?: (node: any) => void + /** Log handler function */ + onLog?: (data: any, node?: any) => void + /** Abort signal for cancellation */ + abortSignal?: AbortSignal + /** Optional output file path for CLI usage */ + outputPath?: string + pipelineId?: string +} + +/** + * Unified function for executing processGraph with build history tracking + * Used by both CLI (main.ts) and IPC handler (handlers.ts) implementations + */ +export const executeGraphWithHistory = async (options: GraphExecutionOptions) => { + const { logger } = useLogger() + const { + graph, + variables, + projectName, + projectPath, + mainWindow, + onNodeEnter, + onNodeExit, + onLog, + abortSignal, + outputPath, + pipelineId + } = options + + console.log('options', options) + + // Create build history entry for this pipeline execution + const buildId = `build-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const buildEntry: BuildHistoryEntry = { + id: buildId, + pipelineId: pipelineId, + projectName: projectName || 'Unnamed Pipeline', + projectPath: projectPath || 'unknown', + status: 'running', + startTime: Date.now(), + steps: [], + totalSteps: graph.length, + completedSteps: 0, + failedSteps: 0, + cancelledSteps: 0, + logs: [], + createdAt: Date.now(), + updatedAt: Date.now() + } + + // Save initial build history entry + try { + await buildHistoryStorage.save(buildEntry) + logger().info(`Build history entry created: ${buildId}`) + } catch (error) { + logger().error('Failed to save initial build history entry:', error) + } + + try { + const pluginDefinitions = getFinalPlugins() + + const result = await processGraph({ + graph, + definitions: pluginDefinitions, + variables, + steps: {}, + context: {}, + onNodeEnter: (node) => { + logger().info('onNodeEnter', node.uid) + onNodeEnter?.(node) + }, + onNodeExit: (node) => { + logger().info('onNodeExit', node.uid) + onNodeExit?.(node) + }, + onExecuteItem: (node, params) => { + if (node.type === 'action') { + return handleActionExecute( + node.origin.nodeId, + node.origin.pluginId, + params, + mainWindow, + (data) => { + if (!isCI) { + logger().info('send', data) + } + onLog?.(data) + }, + abortSignal || new AbortController().signal + ) + } else { + throw new Error('Unhandled type ' + node.type) + } + } + }) + + // Update build history entry as completed + buildEntry.status = 'completed' + buildEntry.endTime = Date.now() + buildEntry.duration = buildEntry.endTime - buildEntry.startTime + buildEntry.completedSteps = graph.length + buildEntry.updatedAt = Date.now() + + await buildHistoryStorage.save(buildEntry) + logger().info(`Build history entry updated as completed: ${buildId}`) + + // Handle output file for CLI usage + if (outputPath) { + const fs = await import('fs/promises') + await fs.writeFile(outputPath, JSON.stringify(result, null, 2), 'utf8') + } + + return { result, buildId } + } catch (e) { + // Update build history entry as failed + buildEntry.status = 'failed' + buildEntry.endTime = Date.now() + buildEntry.duration = buildEntry.endTime - buildEntry.startTime + buildEntry.error = { + message: e instanceof Error ? e.message : 'Unknown error', + timestamp: Date.now() + } + buildEntry.updatedAt = Date.now() + + await buildHistoryStorage.save(buildEntry) + logger().info(`Build history entry updated as failed: ${buildId}`) + + // Handle error output file for CLI usage + if (outputPath) { + const fs = await import('fs/promises') + await fs.writeFile( + outputPath, + JSON.stringify({ error: (e as Error).message }, null, 2), + 'utf8' + ) + } + + throw e + } +} diff --git a/src/main/websocket-server.ts b/src/main/websocket-server.ts new file mode 100644 index 00000000..38249be8 --- /dev/null +++ b/src/main/websocket-server.ts @@ -0,0 +1,184 @@ +import { WebSocketServer as WSWebSocketServer, WebSocket as WSWebSocket } from 'ws' +import { IncomingMessage } from 'http' +import { useAPI } from './handlers' +import { useLogger } from '@@/logger' +import { + WebSocketServerConfig, + WebSocketServerEvents, + WebSocketConnectionState, + WebSocketError, + WebSocketMessage, + isWebSocketRequestMessage +} from '@@/websocket.types' +import { websocketPort } from 'src/constants' + +export class WebSocketServer { + private wss: WSWebSocketServer | null = null + private server: import('http').Server | null = null + private isReady = false + private readyResolve: (() => void) | null = null + private connectionState: WebSocketConnectionState = 'disconnected' + + async start(port: number = websocketPort): Promise { + const { logger } = useLogger() + + return new Promise((resolve, reject) => { + try { + logger().info('Starting WebSocket server on port', port) + + // Create HTTP server for WebSocket + const http = require('http') + this.server = http.createServer() + + // Create WebSocket server + this.wss = new WSWebSocketServer({ server: this.server }) + + this.wss.on('connection', (ws: WSWebSocket, request: IncomingMessage) => { + logger().info('WebSocket client connected', { url: request.url }) + + ws.on('message', (data: Buffer) => { + try { + const message = JSON.parse(data.toString()) + logger().debug('Received WebSocket message:', message) + this.handleWebSocketMessage(ws, message) + } catch (error) { + logger().error('Failed to parse WebSocket message:', error) + this.sendError(ws, undefined, 'Invalid message format') + } + }) + + ws.on('close', (code: number, reason: Buffer) => { + logger().info('WebSocket client disconnected', { code, reason: reason.toString() }) + }) + + ws.on('error', (error: Error) => { + logger().error('WebSocket error:', error) + }) + }) + + this.server.listen(port, () => { + this.connectionState = 'connected' + logger().info(`WebSocket server listening on port ${port}`) + this.isReady = true + if (this.readyResolve) { + this.readyResolve() + } + resolve() + }) + + this.server.on('error', (error: Error) => { + this.connectionState = 'error' + logger().error('WebSocket server error:', error) + reject(new WebSocketError(`Server error: ${error.message}`)) + }) + } catch (error) { + this.connectionState = 'error' + logger().error('Failed to start WebSocket server:', error) + reject( + new WebSocketError( + `Failed to start server: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + ) + } + }) + } + + private async handleWebSocketMessage(ws: WSWebSocket, message: WebSocketMessage) { + const { logger } = useLogger() + const { processWebSocketMessage } = useAPI() + + try { + if (isWebSocketRequestMessage(message)) { + logger().debug('Processing WebSocket request message:', { + channel: message.channel, + requestId: message.requestId, + hasData: !!message.data + }) + + logger().debug('Routing message to handler:', message.channel) + await processWebSocketMessage(ws, message.channel, message) + logger().debug('Handler processed message successfully') + } else { + logger().warn('Invalid message format received:', { + type: message.type, + requestId: message.requestId, + hasError: 'error' in message + }) + this.sendError( + ws, + message.requestId, + 'Invalid message format: missing channel or requestId' + ) + } + } catch (error) { + logger().error('Error processing WebSocket message:', error) + this.sendError( + ws, + message.requestId, + error instanceof Error ? error.message : 'Unknown error' + ) + } + } + + private sendError( + ws: WSWebSocket, + requestId: string | undefined, + errorMessage: string, + code?: string + ) { + try { + const errorResponse: any = { + type: 'error', + requestId, + error: errorMessage, + ...(code && { code }) + } + ws.send(JSON.stringify(errorResponse)) + } catch (error) { + console.error('Failed to send error response:', error) + } + } + + async stop(): Promise { + const { logger } = useLogger() + + return new Promise((resolve) => { + this.isReady = false + this.connectionState = 'disconnected' + + if (this.wss) { + this.wss.close(() => { + logger().info('WebSocket server closed') + resolve() + }) + } else { + resolve() + } + + if (this.server) { + this.server.close() + } + }) + } + + waitForReady(): Promise { + if (this.isReady) { + return Promise.resolve() + } + + return new Promise((resolve) => { + this.readyResolve = resolve + }) + } + + isServerReady(): boolean { + return this.isReady + } + + getConnectionState(): WebSocketConnectionState { + return this.connectionState + } +} + +// Export singleton instance +export const webSocketServer = new WebSocketServer() diff --git a/src/renderer/App.vue b/src/renderer/App.vue index d0fa9353..c01157d2 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -1,10 +1,8 @@ @@ -40,6 +33,7 @@ import { useLogger } from '@@/logger' import { useAuth } from '@renderer/store/auth' import { storeToRefs } from 'pinia' import { useAppSettings } from './store/settings' +import { websocketManager } from './composables/websocket-manager' const appStore = useAppStore() const filesStore = useFiles() @@ -47,23 +41,66 @@ const settingsStore = useAppSettings() const { logger } = useLogger() const authStore = useAuth() const { init: authInit } = authStore -const { settings, } = storeToRefs(settingsStore) +const { settings } = storeToRefs(settingsStore) const { init: initSettings } = settingsStore const { init } = appStore const isLoading = ref(false) handle('log:message', async (event, { value, send }) => { - // console.log('value._meta', value._meta) - if (value._meta) { - const values = Object.entries(value) - .filter(([key]) => key !== '_meta') - .map(([, v]) => v) - logger() - .getSubLogger({ - name: 'Main' - }) - .log(value._meta.logLevelId, ...[value._meta.logLevelName, ...values]) + // console.log('log:message: Received value:', { + // value, + // type: typeof value, + // hasValue: !!value, + // isObject: typeof value === 'object', + // hasMeta: value?._meta, + // metaType: typeof value?._meta + // }) + + // Validate that value exists and is an object before accessing properties + if (!value || typeof value !== 'object') { + console.warn('log:message: Invalid value received:', { + value, + type: typeof value, + hasValue: !!value + }) + send({ + type: 'end', + data: undefined + }) + return + } + + // Check if the value has _meta property before accessing it + if ( + value && + value._meta && + typeof value._meta === 'object' && + value._meta.logLevelId !== undefined + ) { + try { + const values = Object.entries(value) + .filter(([key]) => key !== '_meta') + .map(([, v]) => v) + + // Filter out undefined values to prevent tslog errors + const filteredValues = values.filter((v) => v !== undefined) + const logLevelName = value._meta.logLevelName || 'LOG' + + logger() + .getSubLogger({ + name: 'Main' + }) + .log(value._meta.logLevelId, ...[logLevelName, ...filteredValues]) + } catch (error) { + console.error('log:message: Error processing log message:', error) + } + } else { + console.warn('log:message: Value missing _meta property or _meta is not an object:', { + hasMeta: !!value._meta, + metaType: typeof value._meta, + valueKeys: Object.keys(value || {}) + }) } send({ @@ -148,6 +185,9 @@ onMounted(async () => { .main { flex: 1; + display: flex; + width: 100%; + height: 100%; } } diff --git a/src/renderer/Root.vue b/src/renderer/Root.vue index ebd1fc2a..014536e6 100644 --- a/src/renderer/Root.vue +++ b/src/renderer/Root.vue @@ -1,18 +1,9 @@ - + diff --git a/src/renderer/components/BuildDetailsModal.vue b/src/renderer/components/BuildDetailsModal.vue new file mode 100644 index 00000000..3338c0da --- /dev/null +++ b/src/renderer/components/BuildDetailsModal.vue @@ -0,0 +1,634 @@ + + + + + diff --git a/src/renderer/components/BuildHistoryItem.vue b/src/renderer/components/BuildHistoryItem.vue new file mode 100644 index 00000000..9becd051 --- /dev/null +++ b/src/renderer/components/BuildHistoryItem.vue @@ -0,0 +1,455 @@ + + + + + diff --git a/src/renderer/components/BuildHistoryList.vue b/src/renderer/components/BuildHistoryList.vue new file mode 100644 index 00000000..bae015d6 --- /dev/null +++ b/src/renderer/components/BuildHistoryList.vue @@ -0,0 +1,433 @@ + + + + + diff --git a/src/renderer/components/BuildStatusBadge.vue b/src/renderer/components/BuildStatusBadge.vue new file mode 100644 index 00000000..8d19de94 --- /dev/null +++ b/src/renderer/components/BuildStatusBadge.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/src/renderer/components/Layout.vue b/src/renderer/components/Layout.vue index 6dc08b63..2ffb576f 100644 --- a/src/renderer/components/Layout.vue +++ b/src/renderer/components/Layout.vue @@ -237,18 +237,75 @@ import posthog from 'posthog-js' import { storeToRefs } from 'pinia' import { handle } from '@renderer/composables/handlers' import { useForm } from 'vee-validate' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' +import { useI18n } from 'vue-i18n' const { logger } = useLogger() +const { t } = useI18n() +const route = useRoute() +const router = useRouter() const $menu = ref() -const route = useRoute() - const headerSentence = computed(() => { return route.meta?.title as string }) +const hasBuildHistoryAccess = computed(() => { + return auth.hasBenefit('cloud-save') +}) + +const isBuildHistoryRoute = computed(() => { + return route.name === 'BuildHistory' || route.name === 'BuildHistory' +}) + +const isScenarioFilteredRoute = computed(() => { + return route.name === 'BuildHistory' +}) + +const getBuildHistoryText = () => { + if (isScenarioFilteredRoute.value) { + return t('navigation.build-history-scenario') + } + return t('navigation.build-history') +} + +const getBuildHistoryLabel = () => { + if (isScenarioFilteredRoute.value) { + return t('navigation.build-history-scenario-description') + } + return t('navigation.build-history-description') +} + +const navigateToBuildHistory = () => { + console.log('route', route) + + // If we're already on a build history route, toggle between general and scenario-filtered + if (isBuildHistoryRoute.value) { + if (isScenarioFilteredRoute.value) { + // Switch to general build history + router.push({ + name: 'BuildHistory', + params: { pipelineId: route.params.pipelineId } + }) + } else { + // Try to switch to scenario-filtered if we have scenario context + const currentPipelineId = route.params.pipelineId + if (currentPipelineId) { + router.push({ + name: 'BuildHistory', + params: { + pipelineId: currentPipelineId + } + }) + } + } + } else { + // Navigate to general build history + router.push({ name: 'BuildHistory' }) + } +} + const updateStatus = ref('update-not-available') const appVersion = ref(window.version) @@ -495,11 +552,96 @@ const onSubmit = handleSubmit(onSuccess, onInvalidSubmit) margin-left: 12px; } + .navigation { + display: flex; + align-items: center; + + .nav-button { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + font-size: 1rem; + color: var(--text-color); + text-decoration: none; + border-radius: 6px; + transition: all 0.2s ease; + cursor: pointer; + + .icon { + opacity: 0.8; + } + + .nav-text { + font-weight: 500; + } + + &:hover { + background-color: var(--surface-hover); + color: var(--primary-color); + + .icon { + opacity: 1; + } + } + + &.active { + background-color: var(--primary-color); + color: var(--primary-color-text); + + .icon { + opacity: 1; + } + } + + &.scenario-filtered { + .nav-text { + font-weight: 600; + } + + .scenario-indicator { + opacity: 0.8; + margin-left: 4px; + } + } + + @media (max-width: 768px) { + padding: 6px 12px; + + .nav-text { + display: none; + } + + .icon { + margin-right: 0; + } + } + } + } + .button { display: flex; gap: 8px; align-items: center; } + + @media (max-width: 768px) { + .navigation { + order: 2; + } + + .button { + order: 3; + } + + .title { + order: 1; + flex: 1; + margin-left: 0; + margin-right: 0; + text-align: center; + } + } } .footer { diff --git a/src/renderer/components/Settings.vue b/src/renderer/components/Settings.vue index c1d388ed..73990136 100644 --- a/src/renderer/components/Settings.vue +++ b/src/renderer/components/Settings.vue @@ -3,10 +3,9 @@ {{ t('settings.tabs.general') }} - {{ t('settings.tabs.storage') }} - {{ t('settings.tabs.integrations') }} - {{ t('settings.tabs.advanced') }} - {{ t('settings.tabs.billing') }} + {{ t('settings.tabs.integrations') }} + {{ t('settings.tabs.advanced') }} + {{ t('settings.tabs.billing') }} @@ -28,8 +27,8 @@