From 2565e649263da49b0cee53bd10f973f97523b475 Mon Sep 17 00:00:00 2001 From: Quentin Goinaud Date: Tue, 30 Sep 2025 16:07:47 +0200 Subject: [PATCH 1/4] ci(workflows): remove windows workaround steps from push workflow --- .github/workflows/push.yml | 16 ---------------- 1 file changed, 16 deletions(-) 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 From 3dcfe0ed1ea9ede6391a2f6517c9f37595dcc877 Mon Sep 17 00:00:00 2001 From: Quentin Goinaud Date: Wed, 1 Oct 2025 12:08:05 +0200 Subject: [PATCH 2/4] - --- components.d.ts | 8 + src/components/BuildHistoryView.vue | 474 ++++++++++ src/main.ts | 56 +- src/main/auth.ts | 138 +++ src/main/config.ts | 21 +- src/main/handler-func.ts | 13 +- src/main/handlers.ts | 516 ++++++++++- src/main/handlers/build-history.ts | 277 ++++++ src/main/temp-tracker.ts | 45 - src/main/utils.ts | 167 +++- src/renderer/App.vue | 65 +- src/renderer/components/BuildDetailsModal.vue | 634 +++++++++++++ .../components/BuildHistoryFilters.vue | 388 ++++++++ src/renderer/components/BuildHistoryItem.vue | 454 +++++++++ src/renderer/components/BuildHistoryList.vue | 475 ++++++++++ src/renderer/components/BuildStatusBadge.vue | 167 ++++ src/renderer/components/Layout.vue | 106 ++- src/renderer/components/Settings.vue | 144 +-- src/renderer/pages/BuildHistoryPage.vue | 679 ++++++++++++++ src/renderer/pages/editor.vue | 165 ++-- src/renderer/router/router.ts | 8 + src/renderer/store/auth.ts | 3 +- src/renderer/store/build-history.ts | 482 ++++++++++ src/renderer/store/editor.ts | 2 - src/shared/apis.ts | 47 + src/shared/build-history.ts | 147 +++ src/shared/i18n/en_US.json | 3 + src/shared/subscription-errors.ts | 87 ++ src/tests/build-history-auth.test.ts | 157 ++++ src/tests/build-history-integration.test.ts | 754 +++++++++++++++ src/tests/build-history-storage.test.ts | 865 ++++++++++++++++++ src/tests/build-history-store.test.ts | 802 ++++++++++++++++ src/tests/components/BuildStatusBadge.test.ts | 323 +++++++ tests/e2e/build-history.e2e.test.ts | 495 ++++++++++ 34 files changed, 8756 insertions(+), 411 deletions(-) create mode 100644 src/components/BuildHistoryView.vue create mode 100644 src/main/auth.ts create mode 100644 src/main/handlers/build-history.ts delete mode 100644 src/main/temp-tracker.ts create mode 100644 src/renderer/components/BuildDetailsModal.vue create mode 100644 src/renderer/components/BuildHistoryFilters.vue create mode 100644 src/renderer/components/BuildHistoryItem.vue create mode 100644 src/renderer/components/BuildHistoryList.vue create mode 100644 src/renderer/components/BuildStatusBadge.vue create mode 100644 src/renderer/pages/BuildHistoryPage.vue create mode 100644 src/renderer/store/build-history.ts create mode 100644 src/shared/build-history.ts create mode 100644 src/shared/subscription-errors.ts create mode 100644 src/tests/build-history-auth.test.ts create mode 100644 src/tests/build-history-integration.test.ts create mode 100644 src/tests/build-history-storage.test.ts create mode 100644 src/tests/build-history-store.test.ts create mode 100644 src/tests/components/BuildStatusBadge.test.ts create mode 100644 tests/e2e/build-history.e2e.test.ts 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/src/components/BuildHistoryView.vue b/src/components/BuildHistoryView.vue new file mode 100644 index 00000000..d09694c1 --- /dev/null +++ b/src/components/BuildHistoryView.vue @@ -0,0 +1,474 @@ + + + + + diff --git a/src/main.ts b/src/main.ts index a03ec34b..2855005b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,11 +5,9 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { registerIPCHandlers } from './main/handlers' 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 +15,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 @@ -298,62 +295,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 +386,6 @@ app.whenReady().then(async () => { // explicitly with Cmd + Q. app.on('window-all-closed', async () => { if (process.platform !== 'darwin') { - await client.shutdown() app.quit() } }) diff --git a/src/main/auth.ts b/src/main/auth.ts new file mode 100644 index 00000000..2e4b358d --- /dev/null +++ b/src/main/auth.ts @@ -0,0 +1,138 @@ +import { app } from 'electron' +import { join } from 'node:path' +import { writeFile, readFile } from 'node:fs/promises' +import { useLogger } from '@@/logger' + +interface UserSubscription { + userId: string + subscriptions: Array<{ + id: string + product: { + benefits: Array<{ + id: string + name: string + }> + } + }> + lastChecked: number +} + +const AUTH_CACHE_FILE = join(app.getPath('userData'), 'auth-cache.json') +const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes + +export class MainProcessAuth { + private logger = useLogger() + private subscriptionCache: Map = new Map() + + private async loadCache(): Promise { + try { + const data = await readFile(AUTH_CACHE_FILE, 'utf-8') + const cache = JSON.parse(data) + + // Only use cache if it's not too old + if (Date.now() - cache.timestamp < CACHE_DURATION) { + this.subscriptionCache = new Map(Object.entries(cache.subscriptions)) + } + } catch (error) { + // Cache doesn't exist or is corrupted, start fresh + this.subscriptionCache.clear() + } + } + + private async saveCache(): Promise { + try { + const cache = { + timestamp: Date.now(), + subscriptions: Object.fromEntries(this.subscriptionCache) + } + await writeFile(AUTH_CACHE_FILE, JSON.stringify(cache, null, 2), 'utf-8') + } catch (error) { + this.logger.logger().error('Failed to save auth cache:', error) + } + } + + private async fetchUserSubscription(userId: string): Promise { + try { + // In a real implementation, this would call your subscription service + // For now, we'll simulate checking with Supabase + const { createClient } = await import('@supabase/supabase-js') + + console.log('process.env.SUPABASE_URL', process.env.SUPABASE_URL) + + // You'll need to configure these with your actual Supabase credentials + const supabaseUrl = process.env.SUPABASE_URL || 'your-supabase-url' + const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || 'your-anon-key' + + const supabase = createClient(supabaseUrl, supabaseAnonKey) + + // Get user from Supabase Auth + const { data: userData } = await supabase.auth.admin.getUserById(userId) + + if (!userData.user?.email) { + return null + } + + // Call the polar-user-plan function (same as in renderer) + const { data: subscriptionData, error } = await supabase.functions.invoke('polar-user-plan') + + if (error || !subscriptionData) { + this.logger.logger().error('Failed to fetch subscription data:', error) + return null + } + + const subscription: UserSubscription = { + userId, + subscriptions: subscriptionData.subscriptions || [], + lastChecked: Date.now() + } + + this.subscriptionCache.set(userId, subscription) + await this.saveCache() + + return subscription + } catch (error) { + this.logger.logger().error('Error fetching user subscription:', error) + return null + } + } + + async hasBenefit(userId: string, benefitId: string): Promise { + if (!userId) { + return false + } + + await this.loadCache() + + let userSubscription = this.subscriptionCache.get(userId) + + // Fetch fresh data if cache is missing or too old + if (!userSubscription || Date.now() - userSubscription.lastChecked > CACHE_DURATION) { + userSubscription = await this.fetchUserSubscription(userId) + } + + if (!userSubscription) { + return false + } + + return userSubscription.subscriptions.some((sub) => + sub.product.benefits.some((benefit) => benefit.id === benefitId) + ) + } + + async isPaidUser(userId: string): Promise { + // Check for the cloud-save benefit which indicates a paid user + return this.hasBenefit(userId, '16955d3e-3e0f-4574-9093-87a32edf237c') + } + + async clearCache(userId?: string): Promise { + if (userId) { + this.subscriptionCache.delete(userId) + } else { + this.subscriptionCache.clear() + } + await this.saveCache() + } +} + +// Export singleton instance +export const mainProcessAuth = new MainProcessAuth() 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..651583a3 100644 --- a/src/main/handlers.ts +++ b/src/main/handlers.ts @@ -1,12 +1,14 @@ 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 { 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' export type HandleListenerSendFn = (events: Events) => void @@ -47,6 +49,28 @@ export const registerIPCHandlers = () => { logger().info('registering ipc handlers') + // Helper function to check build history authorization + const checkBuildHistoryAuthorization = async ( + event: Electron.IpcMainInvokeEvent + ): Promise => { + // In a real implementation, you'd extract the user ID from the session/token + // For now, we'll use a placeholder - this needs to be implemented based on your auth system + const userId = event.sender.getTitle() || 'anonymous' // This is a placeholder + + // TEMPORARILY DISABLED: Auth verification bypassed for debugging + // Original code: const isAuthorized = await mainProcessAuth.isPaidUser(userId) + 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 userId + } + handle('dialog:showOpenDialog', async (event, { value, send }) => { const slash = (await import('slash')).default @@ -125,39 +149,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() @@ -294,7 +285,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 +384,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 +432,455 @@ 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() + const filteredEntries = value.query?.pipelineId + ? allEntries.filter((entry) => entry.projectId === 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:delete-by-project', async (event, { send, value }) => { + const { logger } = useLogger() + + try { + // Check authorization before allowing deletion + await checkBuildHistoryAuthorization(event) + + await buildHistoryStorage.deleteByProject(value.projectId) + send({ + type: 'end', + data: { + type: 'success', + result: { result: 'ok' } + } + }) + } catch (error) { + logger().error('Failed to delete build history entries for project:', 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 entries for project' + } + }) + } + }) + + 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 } = value + + const mainWindow = BrowserWindow.fromWebContents(event.sender) + abortControllerGraph = new AbortController() + + try { + const { result, buildId } = await executeGraphWithHistory({ + graph, + variables, + projectName, + projectPath, + 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) => { + // Send log data to frontend + if (data.type === 'log') { + // Sanitize data for IPC serialization + const sanitizedData = { + type: data.type, + level: data.level, + message: data.message, + timestamp: data.timestamp, + nodeId: data.nodeId, + pluginId: 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..a21eef11 --- /dev/null +++ b/src/main/handlers/build-history.ts @@ -0,0 +1,277 @@ +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.projectId) + const existingIndex = entries.findIndex((e) => e.id === entry.id) + + if (existingIndex >= 0) { + entries[existingIndex] = entry + } else { + entries.push(entry) + } + + await this.savePipelineHistory(entry.projectId, entries) + this.logger + .logger() + .info(`Saved build history entry: ${entry.id} for pipeline: ${entry.projectId}`) + } 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 deleteByProject(projectId: string): Promise { + try { + const entries = await this.loadPipelineHistory(projectId) + const deletedCount = entries.length + + // Save empty array to clear the pipeline history + await this.savePipelineHistory(projectId, []) + + this.logger + .logger() + .info(`Deleted ${deletedCount} build history entries for pipeline: ${projectId}`) + } catch (error) { + this.logger + .logger() + .error(`Failed to delete build history entries for pipeline ${projectId}:`, error) + throw new Error(`Failed to delete build history entries for pipeline: ${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..a4bb5402 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,156 @@ 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 +} + +/** + * 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 + } = 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, + projectId: projectPath || 'unknown', + 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/renderer/App.vue b/src/renderer/App.vue index d0fa9353..78d8bd02 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -47,23 +47,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({ diff --git a/src/renderer/components/BuildDetailsModal.vue b/src/renderer/components/BuildDetailsModal.vue new file mode 100644 index 00000000..564d6df0 --- /dev/null +++ b/src/renderer/components/BuildDetailsModal.vue @@ -0,0 +1,634 @@ + + + + + diff --git a/src/renderer/components/BuildHistoryFilters.vue b/src/renderer/components/BuildHistoryFilters.vue new file mode 100644 index 00000000..cee8b0b0 --- /dev/null +++ b/src/renderer/components/BuildHistoryFilters.vue @@ -0,0 +1,388 @@ + + + + + diff --git a/src/renderer/components/BuildHistoryItem.vue b/src/renderer/components/BuildHistoryItem.vue new file mode 100644 index 00000000..43d82f38 --- /dev/null +++ b/src/renderer/components/BuildHistoryItem.vue @@ -0,0 +1,454 @@ + + + + + diff --git a/src/renderer/components/BuildHistoryList.vue b/src/renderer/components/BuildHistoryList.vue new file mode 100644 index 00000000..46ad0933 --- /dev/null +++ b/src/renderer/components/BuildHistoryList.vue @@ -0,0 +1,475 @@ + + + + + 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..304d73c1 100644 --- a/src/renderer/components/Layout.vue +++ b/src/renderer/components/Layout.vue @@ -2,6 +2,20 @@
{{ headerSentence }}
+