From 36e2718d7abb598a5e1a3d246460bd271ecd1c4e Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:13:16 -0600 Subject: [PATCH 1/2] feat: add demo mode with mock data for GitHub Pages deployment - Add REACT_APP_DEMO_MODE env var to switch between real and mock APIs - Create comprehensive mock data: 5 repos, 17 workflows, ~150 runs with realistic labels (ubuntu-latest, windows-latest, macos-latest, self-hosted), events (push, pull_request, workflow_dispatch, schedule), branches, durations, and mixed success/failure/cancelled conclusions - Mock API service mirrors all apiService methods with simulated latency - Mock socket service (no-op) for demo mode - Proxy pattern: apiService.js and socketService.js auto-switch - HashRouter for GitHub Pages compatibility (BrowserRouter in production) - DemoBanner component with link to install RunWatch - GitHub Actions workflow: deploy-demo.yml builds and deploys to Pages --- .github/workflows/deploy-demo.yml | 60 +++++ client/src/App.js | 7 +- client/src/api/apiService.js | 264 +------------------- client/src/api/mockApiService.js | 151 +++++++++++ client/src/api/mockData.js | 248 ++++++++++++++++++ client/src/api/mockSocketService.js | 27 ++ client/src/api/realApiService.js | 258 +++++++++++++++++++ client/src/api/realSocketService.js | 220 ++++++++++++++++ client/src/api/socketService.js | 225 +---------------- client/src/common/components/DemoBanner.jsx | 48 ++++ 10 files changed, 1036 insertions(+), 472 deletions(-) create mode 100644 .github/workflows/deploy-demo.yml create mode 100644 client/src/api/mockApiService.js create mode 100644 client/src/api/mockData.js create mode 100644 client/src/api/mockSocketService.js create mode 100644 client/src/api/realApiService.js create mode 100644 client/src/api/realSocketService.js create mode 100644 client/src/common/components/DemoBanner.jsx diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 0000000..a7a62d3 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,60 @@ +name: Deploy Demo to GitHub Pages + +on: + push: + branches: [main] + paths: + - 'client/**' + - '.github/workflows/deploy-demo.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + name: 🎭 Build Demo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: client/package-lock.json + + - name: Install dependencies + working-directory: client + run: npm ci --legacy-peer-deps + + - name: Build demo + working-directory: client + env: + REACT_APP_DEMO_MODE: 'true' + PUBLIC_URL: '/RunWatch' + run: npm run build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: client/build + + deploy: + name: 🚀 Deploy to Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/client/src/App.js b/client/src/App.js index 0633dfd..2c30731 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -1,5 +1,5 @@ import React from 'react'; -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, HashRouter, Routes, Route } from 'react-router-dom'; import { ThemeProvider, CssBaseline } from '@mui/material'; import { theme } from './common/theme/theme'; import Layout from './common/components/Layout'; @@ -10,14 +10,19 @@ import WorkflowHistory from './features/workflows/WorkflowHistory'; import RepositoryView from './features/repository/RepositoryView'; import Settings from './features/settings/Settings'; import { AdminTokenProvider } from './common/context/AdminTokenContext'; +import DemoBanner from './common/components/DemoBanner'; import './App.css'; +const isDemoMode = process.env.REACT_APP_DEMO_MODE === 'true'; +const Router = isDemoMode ? HashRouter : BrowserRouter; + function App() { return ( + {isDemoMode && } } /> diff --git a/client/src/api/apiService.js b/client/src/api/apiService.js index e015c01..721bfcd 100644 --- a/client/src/api/apiService.js +++ b/client/src/api/apiService.js @@ -1,258 +1,12 @@ -import axios from 'axios'; -import io from 'socket.io-client'; +// API Service proxy — switches between real and mock based on REACT_APP_DEMO_MODE +import realApiService from './realApiService'; +import mockApiService from './mockApiService'; -// Use the environment variables, falling back to development defaults if not set -const API_URL = process.env.REACT_APP_API_URL || 'http://localhost/api'; -const WS_URL = process.env.REACT_APP_WEBSOCKET_URL || 'ws://localhost'; +const isDemoMode = process.env.REACT_APP_DEMO_MODE === 'true'; -console.log('Using API URL:', API_URL); -console.log('Using WebSocket URL:', WS_URL); +if (isDemoMode) { + console.log('🎭 RunWatch Demo Mode — using mock data'); +} -// Axios instance with 429 retry logic (exponential backoff) -const api = axios.create(); -api.interceptors.response.use(undefined, async (error) => { - const config = error.config; - if (error.response?.status === 429 && (!config._retryCount || config._retryCount < 3)) { - config._retryCount = (config._retryCount || 0) + 1; - const retryAfter = error.response.headers['retry-after']; - const delay = retryAfter ? Number(retryAfter) * 1000 : Math.min(1000 * 2 ** config._retryCount, 15000); - await new Promise(resolve => setTimeout(resolve, delay)); - return api(config); - } - return Promise.reject(error); -}); - -// Create socket connection with environment-aware configuration -export const socket = io(WS_URL, { - transports: ['websocket'], - path: '/socket.io' -}); - -// API Services -const apiService = { - // Get all workflow runs - getWorkflowRuns: async (page = 1, pageSize = 30, search = '', status = 'all') => { - try { - const response = await api.get(`${API_URL}/workflow-runs`, { - params: { page, pageSize, search, status } - }); - return response.data.data || { data: [], pagination: { total: 0, page: 1, pageSize: 30, totalPages: 1 } }; - } catch (error) { - console.error('Error fetching workflow runs:', error); - throw error; - } - }, - - // Get workflow runs for a specific repository - getRepoWorkflowRuns: async (repoName, workflowName = null, page = 1, pageSize = 30) => { - try { - const params = { page, pageSize }; - if (workflowName) { - params.workflowName = workflowName; - } - const response = await api.get(`${API_URL}/workflow-runs/repo/${repoName}`, { params }); - return response.data.data || { data: [], pagination: { total: 0, page: 1, pageSize: 0, totalPages: 1 } }; - } catch (error) { - console.error(`Error fetching workflow runs for repo ${repoName}:`, error); - throw error; - } - }, - - // Get workflow run by ID - getWorkflowRunById: async (id) => { - try { - const response = await api.get(`${API_URL}/workflow-runs/${id}`); - return response.data.data; - } catch (error) { - console.error(`Error fetching workflow run ${id}:`, error); - throw error; - } - }, - - // Sync workflow run - syncWorkflowRun: async (id) => { - try { - const response = await api.post(`${API_URL}/workflow-runs/${id}/sync`); - return response.data.data; - } catch (error) { - console.error(`Error syncing workflow run ${id}:`, error); - throw error; - } - }, - - deleteWorkflowRun: async (id) => { - try { - const response = await api.delete(`${API_URL}/workflow-runs/${id}`); - return response.data.data; - } catch (error) { - console.error(`Error deleting workflow run ${id}:`, error); - throw error; - } - }, - - // Sync all workflow runs for a repository - syncWorkflowRuns: async (repoName) => { - try { - const response = await api.post(`${API_URL}/workflow-runs/repo/${repoName}/sync`); - return response.data.data; - } catch (error) { - console.error(`Error syncing workflow runs for repo ${repoName}:`, error); - throw error; - } - }, - - // Get workflow statistics - getWorkflowStats: async () => { - try { - const response = await api.get(`${API_URL}/stats`); - return response.data.data || {}; - } catch (error) { - console.error('Error fetching workflow stats:', error); - throw error; - } - }, - - // Get available organizations - getOrganizations: async () => { - try { - const response = await api.get(`${API_URL}/organizations`); - return response.data; - } catch (error) { - console.error('Error fetching organizations:', error); - throw error; - } - }, - - // Sync GitHub data using installation ID - syncGitHubData: async (installationId, options = { maxWorkflowRuns: 100 }) => { - try { - const response = await fetch(`${API_URL}/sync/${encodeURIComponent(String(installationId))}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(options) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to sync GitHub data'); - } - - return await response.json(); - } catch (error) { - console.error('Error syncing GitHub data:', error); - throw error; - } - }, - - // Get database status - getDatabaseStatus: async () => { - try { - const response = await api.get(`${API_URL}/db/status`); - return response.data.data || {}; - } catch (error) { - console.error('Error fetching database status:', error); - throw error; - } - }, - - // Get sync history - getSyncHistory: async () => { - try { - const response = await api.get(`${API_URL}/sync/history`); - return response.data; - } catch (error) { - console.error('Error fetching sync history:', error); - throw error; - } - }, - - // Get active sync status - getActiveSync: async () => { - try { - const response = await api.get(`${API_URL}/sync/active`); - return response.data; - } catch (error) { - console.error('Error fetching active sync:', error); - throw error; - } - }, - - // Get active metrics - getActiveMetrics: async () => { - try { - const response = await api.get(`${API_URL}/workflow-runs/metrics`); - return response.data.data; - } catch (error) { - console.error('Error fetching active metrics:', error); - throw error; - } - }, - - // Get queued workflows - getQueuedWorkflows: async () => { - try { - const response = await api.get(`${API_URL}/workflow-runs/queued`); - return response.data.data; - } catch (error) { - console.error('Error fetching queued workflows:', error); - throw error; - } - }, - - // Create database backup (requires admin token) - async createDatabaseBackup(adminToken) { - const headers = {}; - if (adminToken) { - headers['Authorization'] = `Bearer ${adminToken}`; - } - const response = await api.get(`${API_URL}/database/backup`, { headers }); - return response.data; - }, - - // Restore database backup (requires admin token) - async restoreDatabaseBackup(backupData, adminToken) { - const headers = {}; - if (adminToken) { - headers['Authorization'] = `Bearer ${adminToken}`; - } - const response = await api.post(`${API_URL}/database/restore`, backupData, { headers }); - return response.data; - }, - - // Get workflow metrics - getWorkflowMetrics: async () => { - try { - const response = await api.get(`${API_URL}/workflow-runs/metrics`); - return response.data.data; - } catch (error) { - console.error('Error fetching workflow metrics:', error); - throw error; - } - }, - - // Get job metrics - getJobMetrics: async () => { - try { - const response = await api.get(`${API_URL}/workflow-runs/jobs/metrics`); - return response.data.data; - } catch (error) { - console.error('Error fetching job metrics:', error); - throw error; - } - }, - - // Get the status of the database - getDatabaseStatus: async () => { - try { - const response = await api.get(`${API_URL}/db/status`); - return response.data.data || {}; - } catch (error) { - console.error('Error fetching database status:', error); - throw error; - } - } -}; - -export default apiService; \ No newline at end of file +const apiService = isDemoMode ? mockApiService : realApiService; +export default apiService; diff --git a/client/src/api/mockApiService.js b/client/src/api/mockApiService.js new file mode 100644 index 0000000..ccbf7b4 --- /dev/null +++ b/client/src/api/mockApiService.js @@ -0,0 +1,151 @@ +// Mock API Service for RunWatch Demo Mode +// Drop-in replacement for apiService — returns mock data with simulated latency + +import { allRuns, repos, workflows, computeWorkflowMetrics, computeJobMetrics, computeStats } from './mockData'; + +const delay = (ms = 300) => new Promise(r => setTimeout(r, ms + Math.random() * 200)); + +const mockApiService = { + getWorkflowRuns: async (page = 1, pageSize = 30, search = '', status = 'all') => { + await delay(); + let filtered = [...allRuns]; + if (search) { + const q = search.toLowerCase(); + filtered = filtered.filter(r => + r.repository.fullName.toLowerCase().includes(q) || + r.workflow.name.toLowerCase().includes(q) || + r.run.head_branch?.toLowerCase().includes(q) + ); + } + if (status !== 'all') { + filtered = filtered.filter(r => r.run.status === status || r.run.conclusion === status); + } + const total = filtered.length; + const start = (page - 1) * pageSize; + const data = filtered.slice(start, start + pageSize); + return { + data, + pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) }, + }; + }, + + getRepoWorkflowRuns: async (repoName, workflowName = null, page = 1, pageSize = 30) => { + await delay(); + let filtered = allRuns.filter(r => r.repository.fullName === repoName); + if (workflowName) { + filtered = filtered.filter(r => r.workflow.name === workflowName); + } + const total = filtered.length; + const start = (page - 1) * pageSize; + const data = filtered.slice(start, start + pageSize); + return { + data, + pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) }, + }; + }, + + getWorkflowRunById: async (id) => { + await delay(); + return allRuns.find(r => r.run.id === parseInt(id)) || null; + }, + + syncWorkflowRun: async (id) => { + await delay(800); + // Simulate a sync — just return the same run + return allRuns.find(r => r.run.id === parseInt(id)) || null; + }, + + deleteWorkflowRun: async (id) => { + await delay(400); + return { deleted: true, runId: id }; + }, + + syncWorkflowRuns: async (repoName) => { + await delay(1500); + return allRuns.filter(r => r.repository.fullName === repoName).slice(0, 10); + }, + + getWorkflowStats: async () => { + await delay(); + return computeStats(); + }, + + getOrganizations: async () => { + await delay(); + return { + success: true, + data: [ + { + id: 1, + account: { login: 'acme-corp', avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' }, + app_slug: 'runwatch-demo', + }, + ], + }; + }, + + syncGitHubData: async (installationId, options = {}) => { + await delay(2000); + return { success: true, message: 'Demo mode — sync simulated', synced: 0 }; + }, + + getDatabaseStatus: async () => { + await delay(); + return { + status: 'connected', + collections: { + workflowRuns: allRuns.length, + repositories: repos.length, + }, + dbSize: '24.5 MB', + }; + }, + + getSyncHistory: async () => { + await delay(); + return { + success: true, + data: [ + { _id: 'sync_1', startedAt: new Date(Date.now() - 3600000).toISOString(), completedAt: new Date(Date.now() - 3500000).toISOString(), status: 'completed', runsProcessed: 45 }, + { _id: 'sync_2', startedAt: new Date(Date.now() - 86400000).toISOString(), completedAt: new Date(Date.now() - 86300000).toISOString(), status: 'completed', runsProcessed: 120 }, + ], + }; + }, + + getActiveSync: async () => { + await delay(); + return { success: true, data: null }; + }, + + getActiveMetrics: async () => { + await delay(); + return computeWorkflowMetrics(); + }, + + getQueuedWorkflows: async () => { + await delay(); + return allRuns.filter(r => r.run.status === 'queued'); + }, + + async createDatabaseBackup(adminToken) { + await delay(500); + return { success: true, data: { backup: 'demo-backup-data', timestamp: new Date().toISOString() } }; + }, + + async restoreDatabaseBackup(backupData, adminToken) { + await delay(1000); + return { success: true, message: 'Demo mode — restore simulated' }; + }, + + getWorkflowMetrics: async () => { + await delay(); + return computeWorkflowMetrics(); + }, + + getJobMetrics: async () => { + await delay(); + return computeJobMetrics(); + }, +}; + +export default mockApiService; diff --git a/client/src/api/mockData.js b/client/src/api/mockData.js new file mode 100644 index 0000000..d050ac6 --- /dev/null +++ b/client/src/api/mockData.js @@ -0,0 +1,248 @@ +// Mock data for RunWatch Demo Mode +// Realistic GitHub Actions workflow data across multiple repos + +const now = new Date(); +const hour = (h) => new Date(now.getTime() - h * 3600000).toISOString(); +const min = (m) => new Date(now.getTime() - m * 60000).toISOString(); + +// ── Repositories ────────────────────────────────────────── +const repos = [ + { name: 'acme-corp/web-platform', fullName: 'acme-corp/web-platform' }, + { name: 'acme-corp/api-gateway', fullName: 'acme-corp/api-gateway' }, + { name: 'acme-corp/mobile-app', fullName: 'acme-corp/mobile-app' }, + { name: 'acme-corp/infra-terraform', fullName: 'acme-corp/infra-terraform' }, + { name: 'acme-corp/data-pipeline', fullName: 'acme-corp/data-pipeline' }, +]; + +const workflows = { + 'acme-corp/web-platform': [ + { name: 'CI', id: 1001 }, + { name: 'Deploy Production', id: 1002 }, + { name: 'E2E Tests', id: 1003 }, + { name: 'CodeQL Analysis', id: 1004 }, + ], + 'acme-corp/api-gateway': [ + { name: 'Build & Test', id: 2001 }, + { name: 'Deploy Staging', id: 2002 }, + { name: 'Deploy Production', id: 2003 }, + { name: 'Security Scan', id: 2004 }, + ], + 'acme-corp/mobile-app': [ + { name: 'iOS Build', id: 3001 }, + { name: 'Android Build', id: 3002 }, + { name: 'Unit Tests', id: 3003 }, + { name: 'Release', id: 3004 }, + ], + 'acme-corp/infra-terraform': [ + { name: 'Plan', id: 4001 }, + { name: 'Apply', id: 4002 }, + { name: 'Drift Detection', id: 4003 }, + ], + 'acme-corp/data-pipeline': [ + { name: 'CI', id: 5001 }, + { name: 'Integration Tests', id: 5002 }, + { name: 'Deploy', id: 5003 }, + ], +}; + +const branches = ['main', 'develop', 'feature/auth-v2', 'fix/memory-leak', 'release/v2.1', 'hotfix/cve-2026', 'feat/dark-mode', 'chore/deps-update']; +const events = ['push', 'pull_request', 'workflow_dispatch', 'schedule', 'release']; +const labels = [ + ['ubuntu-latest'], + ['ubuntu-22.04'], + ['windows-latest'], + ['macos-latest'], + ['macos-14'], + ['self-hosted', 'linux', 'x64'], + ['self-hosted', 'macOS', 'arm64'], + ['ubuntu-latest', 'gpu'], +]; + +const conclusions = ['success', 'success', 'success', 'success', 'success', 'success', 'failure', 'cancelled', 'success', 'success']; +const statuses = ['completed', 'completed', 'completed', 'completed', 'completed', 'completed', 'completed', 'completed', 'in_progress', 'queued']; + +// Deterministic seeded random +let seed = 42; +function rand() { + seed = (seed * 16807 + 0) % 2147483647; + return (seed - 1) / 2147483646; +} +function pick(arr) { return arr[Math.floor(rand() * arr.length)]; } +function randInt(min, max) { return Math.floor(rand() * (max - min + 1)) + min; } + +// ── Generate Runs ────────────────────────────────────────── +let runIdCounter = 10000; +const allRuns = []; + +repos.forEach((repo) => { + const repoWorkflows = workflows[repo.fullName]; + repoWorkflows.forEach((wf) => { + const runCount = randInt(5, 15); + for (let i = 0; i < runCount; i++) { + const hoursAgo = rand() * 168; // last 7 days + const durationSec = randInt(12, 600); + const status = pick(statuses); + const conclusion = status === 'completed' ? pick(conclusions) : null; + const branch = pick(branches); + const event = pick(events); + const label = pick(labels); + const runNumber = runCount - i; + const createdAt = hour(hoursAgo); + const updatedAt = status === 'completed' + ? new Date(new Date(createdAt).getTime() + durationSec * 1000).toISOString() + : null; + + const runId = runIdCounter++; + const jobCount = randInt(1, 4); + const jobs = []; + for (let j = 0; j < jobCount; j++) { + const jobNames = ['build', 'test', 'lint', 'deploy', 'security-scan', 'e2e', 'docker-build', 'publish']; + const jobName = jobNames[j % jobNames.length]; + const jobStatus = status === 'in_progress' && j === jobCount - 1 ? 'in_progress' : + status === 'queued' ? 'queued' : 'completed'; + const jobConclusion = jobStatus === 'completed' ? (conclusion === 'failure' && j === jobCount - 1 ? 'failure' : 'success') : null; + jobs.push({ + id: runId * 100 + j, + name: jobName, + status: jobStatus, + conclusion: jobConclusion, + started_at: createdAt, + completed_at: jobStatus === 'completed' ? new Date(new Date(createdAt).getTime() + randInt(10, durationSec) * 1000).toISOString() : null, + runner_name: label.includes('self-hosted') ? `runner-${randInt(1, 5)}` : null, + labels: label, + steps: [ + { name: 'Set up job', status: 'completed', conclusion: 'success', number: 1, started_at: createdAt, completed_at: createdAt }, + { name: 'Checkout', status: 'completed', conclusion: 'success', number: 2, started_at: createdAt, completed_at: createdAt }, + { name: `Run ${jobName}`, status: jobStatus, conclusion: jobConclusion, number: 3, started_at: createdAt, completed_at: jobStatus === 'completed' ? updatedAt : null }, + ], + }); + } + + allRuns.push({ + _id: `mock_${runId}`, + repository: { name: repo.name.split('/')[1], fullName: repo.fullName, owner: repo.name.split('/')[0] }, + workflow: { id: wf.id, name: wf.name, path: `.github/workflows/${wf.name.toLowerCase().replace(/\s+/g, '-')}.yml` }, + run: { + id: runId, + number: runNumber, + status, + conclusion, + head_branch: branch, + event, + created_at: createdAt, + updated_at: updatedAt || createdAt, + url: `https://github.com/${repo.fullName}/actions/runs/${runId}`, + labels: label, + }, + jobs, + }); + } + }); +}); + +// Sort by created_at desc +allRuns.sort((a, b) => new Date(b.run.created_at) - new Date(a.run.created_at)); + +// ── Metrics ────────────────────────────────────────── +function computeWorkflowMetrics() { + const totalRuns = allRuns.filter(r => r.run.status === 'completed').length; + const successRuns = allRuns.filter(r => r.run.conclusion === 'success').length; + const failureRuns = allRuns.filter(r => r.run.conclusion === 'failure').length; + const cancelledRuns = allRuns.filter(r => r.run.conclusion === 'cancelled').length; + const inProgressRuns = allRuns.filter(r => r.run.status === 'in_progress').length; + const queuedRuns = allRuns.filter(r => r.run.status === 'queued').length; + + const durations = allRuns + .filter(r => r.run.status === 'completed' && r.run.updated_at) + .map(r => (new Date(r.run.updated_at) - new Date(r.run.created_at)) / 1000); + const avgDuration = durations.length ? durations.reduce((a, b) => a + b, 0) / durations.length : 0; + + // Per-repo breakdown + const repoMetrics = {}; + repos.forEach(repo => { + const runs = allRuns.filter(r => r.repository.fullName === repo.fullName); + const completed = runs.filter(r => r.run.status === 'completed'); + const success = runs.filter(r => r.run.conclusion === 'success').length; + repoMetrics[repo.fullName] = { + totalRuns: runs.length, + successRate: completed.length ? ((success / completed.length) * 100).toFixed(1) : 0, + avgDuration: completed.length + ? completed.reduce((acc, r) => acc + (new Date(r.run.updated_at || r.run.created_at) - new Date(r.run.created_at)) / 1000, 0) / completed.length + : 0, + }; + }); + + return { + total: allRuns.length, + totalRuns, + successRuns, + failureRuns, + cancelledRuns, + inProgressRuns, + queuedRuns, + avgDuration, + successRate: totalRuns ? ((successRuns / totalRuns) * 100).toFixed(1) : 0, + repoMetrics, + }; +} + +function computeJobMetrics() { + const allJobs = allRuns.flatMap(r => r.jobs || []); + const completed = allJobs.filter(j => j.status === 'completed'); + const success = allJobs.filter(j => j.conclusion === 'success').length; + return { + totalJobs: allJobs.length, + completedJobs: completed.length, + successRate: completed.length ? ((success / completed.length) * 100).toFixed(1) : 0, + avgDuration: completed.length + ? completed.reduce((acc, j) => { + if (j.started_at && j.completed_at) return acc + (new Date(j.completed_at) - new Date(j.started_at)) / 1000; + return acc; + }, 0) / completed.length + : 0, + }; +} + +function computeStats() { + const repoStats = repos.map(repo => { + const runs = allRuns.filter(r => r.repository.fullName === repo.fullName); + const completed = runs.filter(r => r.run.status === 'completed'); + const success = runs.filter(r => r.run.conclusion === 'success').length; + const durations = completed + .filter(r => r.run.updated_at) + .map(r => (new Date(r.run.updated_at) - new Date(r.run.created_at))); + const avgDuration = durations.length ? durations.reduce((a, b) => a + b, 0) / durations.length : 0; + + // recent runs for trends + const recentRuns = runs + .filter(r => new Date(r.run.created_at) > new Date(now.getTime() - 7 * 24 * 3600000)) + .sort((a, b) => new Date(a.run.created_at) - new Date(b.run.created_at)) + .map(r => ({ + status: r.run.status, + conclusion: r.run.conclusion, + created_at: r.run.created_at, + updated_at: r.run.updated_at, + })); + + return { + _id: repo.fullName, + totalRuns: runs.length, + successfulRuns: success, + failedRuns: runs.filter(r => r.run.conclusion === 'failure').length, + avgDuration, + recentRuns, + }; + }); + + return { + overview: { + totalRepos: repos.length, + totalRuns: allRuns.length, + overallSuccessRate: parseFloat(computeWorkflowMetrics().successRate), + }, + repositories: repoStats, + }; +} + +// ── Exports ────────────────────────────────────────── +export { allRuns, repos, workflows, computeWorkflowMetrics, computeJobMetrics, computeStats }; diff --git a/client/src/api/mockSocketService.js b/client/src/api/mockSocketService.js new file mode 100644 index 0000000..4048f41 --- /dev/null +++ b/client/src/api/mockSocketService.js @@ -0,0 +1,27 @@ +// Mock Socket Service for RunWatch Demo Mode +// No-op socket that simulates occasional workflow updates + +const noop = () => {}; + +// Fake socket object +export const socket = { + on: noop, + off: noop, + emit: noop, + connected: true, + id: 'demo-socket', +}; + +// Simulated real-time updates — periodically triggers onWorkflowUpdate callback +export const setupSocketListeners = (callbacks) => { + console.log('[Demo Mode] Mock socket listeners set up'); + + // No real socket events in demo mode — UI is static + // Could add simulated updates here for live-feel + + return () => { + console.log('[Demo Mode] Mock socket listeners cleaned up'); + }; +}; + +export default { socket, setupSocketListeners }; diff --git a/client/src/api/realApiService.js b/client/src/api/realApiService.js new file mode 100644 index 0000000..e015c01 --- /dev/null +++ b/client/src/api/realApiService.js @@ -0,0 +1,258 @@ +import axios from 'axios'; +import io from 'socket.io-client'; + +// Use the environment variables, falling back to development defaults if not set +const API_URL = process.env.REACT_APP_API_URL || 'http://localhost/api'; +const WS_URL = process.env.REACT_APP_WEBSOCKET_URL || 'ws://localhost'; + +console.log('Using API URL:', API_URL); +console.log('Using WebSocket URL:', WS_URL); + +// Axios instance with 429 retry logic (exponential backoff) +const api = axios.create(); +api.interceptors.response.use(undefined, async (error) => { + const config = error.config; + if (error.response?.status === 429 && (!config._retryCount || config._retryCount < 3)) { + config._retryCount = (config._retryCount || 0) + 1; + const retryAfter = error.response.headers['retry-after']; + const delay = retryAfter ? Number(retryAfter) * 1000 : Math.min(1000 * 2 ** config._retryCount, 15000); + await new Promise(resolve => setTimeout(resolve, delay)); + return api(config); + } + return Promise.reject(error); +}); + +// Create socket connection with environment-aware configuration +export const socket = io(WS_URL, { + transports: ['websocket'], + path: '/socket.io' +}); + +// API Services +const apiService = { + // Get all workflow runs + getWorkflowRuns: async (page = 1, pageSize = 30, search = '', status = 'all') => { + try { + const response = await api.get(`${API_URL}/workflow-runs`, { + params: { page, pageSize, search, status } + }); + return response.data.data || { data: [], pagination: { total: 0, page: 1, pageSize: 30, totalPages: 1 } }; + } catch (error) { + console.error('Error fetching workflow runs:', error); + throw error; + } + }, + + // Get workflow runs for a specific repository + getRepoWorkflowRuns: async (repoName, workflowName = null, page = 1, pageSize = 30) => { + try { + const params = { page, pageSize }; + if (workflowName) { + params.workflowName = workflowName; + } + const response = await api.get(`${API_URL}/workflow-runs/repo/${repoName}`, { params }); + return response.data.data || { data: [], pagination: { total: 0, page: 1, pageSize: 0, totalPages: 1 } }; + } catch (error) { + console.error(`Error fetching workflow runs for repo ${repoName}:`, error); + throw error; + } + }, + + // Get workflow run by ID + getWorkflowRunById: async (id) => { + try { + const response = await api.get(`${API_URL}/workflow-runs/${id}`); + return response.data.data; + } catch (error) { + console.error(`Error fetching workflow run ${id}:`, error); + throw error; + } + }, + + // Sync workflow run + syncWorkflowRun: async (id) => { + try { + const response = await api.post(`${API_URL}/workflow-runs/${id}/sync`); + return response.data.data; + } catch (error) { + console.error(`Error syncing workflow run ${id}:`, error); + throw error; + } + }, + + deleteWorkflowRun: async (id) => { + try { + const response = await api.delete(`${API_URL}/workflow-runs/${id}`); + return response.data.data; + } catch (error) { + console.error(`Error deleting workflow run ${id}:`, error); + throw error; + } + }, + + // Sync all workflow runs for a repository + syncWorkflowRuns: async (repoName) => { + try { + const response = await api.post(`${API_URL}/workflow-runs/repo/${repoName}/sync`); + return response.data.data; + } catch (error) { + console.error(`Error syncing workflow runs for repo ${repoName}:`, error); + throw error; + } + }, + + // Get workflow statistics + getWorkflowStats: async () => { + try { + const response = await api.get(`${API_URL}/stats`); + return response.data.data || {}; + } catch (error) { + console.error('Error fetching workflow stats:', error); + throw error; + } + }, + + // Get available organizations + getOrganizations: async () => { + try { + const response = await api.get(`${API_URL}/organizations`); + return response.data; + } catch (error) { + console.error('Error fetching organizations:', error); + throw error; + } + }, + + // Sync GitHub data using installation ID + syncGitHubData: async (installationId, options = { maxWorkflowRuns: 100 }) => { + try { + const response = await fetch(`${API_URL}/sync/${encodeURIComponent(String(installationId))}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(options) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to sync GitHub data'); + } + + return await response.json(); + } catch (error) { + console.error('Error syncing GitHub data:', error); + throw error; + } + }, + + // Get database status + getDatabaseStatus: async () => { + try { + const response = await api.get(`${API_URL}/db/status`); + return response.data.data || {}; + } catch (error) { + console.error('Error fetching database status:', error); + throw error; + } + }, + + // Get sync history + getSyncHistory: async () => { + try { + const response = await api.get(`${API_URL}/sync/history`); + return response.data; + } catch (error) { + console.error('Error fetching sync history:', error); + throw error; + } + }, + + // Get active sync status + getActiveSync: async () => { + try { + const response = await api.get(`${API_URL}/sync/active`); + return response.data; + } catch (error) { + console.error('Error fetching active sync:', error); + throw error; + } + }, + + // Get active metrics + getActiveMetrics: async () => { + try { + const response = await api.get(`${API_URL}/workflow-runs/metrics`); + return response.data.data; + } catch (error) { + console.error('Error fetching active metrics:', error); + throw error; + } + }, + + // Get queued workflows + getQueuedWorkflows: async () => { + try { + const response = await api.get(`${API_URL}/workflow-runs/queued`); + return response.data.data; + } catch (error) { + console.error('Error fetching queued workflows:', error); + throw error; + } + }, + + // Create database backup (requires admin token) + async createDatabaseBackup(adminToken) { + const headers = {}; + if (adminToken) { + headers['Authorization'] = `Bearer ${adminToken}`; + } + const response = await api.get(`${API_URL}/database/backup`, { headers }); + return response.data; + }, + + // Restore database backup (requires admin token) + async restoreDatabaseBackup(backupData, adminToken) { + const headers = {}; + if (adminToken) { + headers['Authorization'] = `Bearer ${adminToken}`; + } + const response = await api.post(`${API_URL}/database/restore`, backupData, { headers }); + return response.data; + }, + + // Get workflow metrics + getWorkflowMetrics: async () => { + try { + const response = await api.get(`${API_URL}/workflow-runs/metrics`); + return response.data.data; + } catch (error) { + console.error('Error fetching workflow metrics:', error); + throw error; + } + }, + + // Get job metrics + getJobMetrics: async () => { + try { + const response = await api.get(`${API_URL}/workflow-runs/jobs/metrics`); + return response.data.data; + } catch (error) { + console.error('Error fetching job metrics:', error); + throw error; + } + }, + + // Get the status of the database + getDatabaseStatus: async () => { + try { + const response = await api.get(`${API_URL}/db/status`); + return response.data.data || {}; + } catch (error) { + console.error('Error fetching database status:', error); + throw error; + } + } +}; + +export default apiService; \ No newline at end of file diff --git a/client/src/api/realSocketService.js b/client/src/api/realSocketService.js new file mode 100644 index 0000000..47ef3ef --- /dev/null +++ b/client/src/api/realSocketService.js @@ -0,0 +1,220 @@ +import io from 'socket.io-client'; +import apiService from './apiService'; + +// Use the environment variables, falling back to development defaults if not set +const WS_URL = process.env.REACT_APP_WEBSOCKET_URL || 'ws://localhost'; + +// Default configuration for alerts +export const defaultAlertConfig = { + queuedTimeAlertThreshold: 5 // Alert threshold in minutes for queued workflows +}; + +// Create socket connection +export const socket = io(WS_URL, { + transports: ['websocket'], + path: '/socket.io' +}); + +// Debounce function to prevent rapid successive updates +const debounce = (func, wait) => { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +}; + +// Keep track of last update timestamps to prevent out-of-order updates +const lastUpdateTimes = new Map(); + +// Map to track workflows being monitored for queue time +const queuedWorkflows = new Map(); + +// Load existing queued workflows from the server +const loadExistingQueuedWorkflows = async (alertConfig = defaultAlertConfig, onLongQueuedWorkflow = null) => { + try { + console.log('Loading existing queued workflows from server...'); + const workflows = await apiService.getQueuedWorkflows(); + + console.log(`Loaded ${workflows.length} queued workflows from server`); + + workflows.forEach(workflow => { + if (workflow.run && workflow.run.status && ['queued', 'waiting', 'pending'].includes(workflow.run.status)) { + queuedWorkflows.set(workflow.run.id, { + id: workflow.run.id, + name: workflow.workflow.name, + repository: workflow.repository.fullName, + queued_at: workflow.run.updated_at || workflow.run.created_at, + alerted: false // Mark as not alerted so we can catch long-running queued jobs + }); + + console.log(`Added existing queued workflow: ${workflow.workflow.name} (${workflow.run.id})`); + } + }); + + // Run the check immediately after loading existing workflows + if (queuedWorkflows.size > 0) { + console.log('Running immediate check for long-queued workflows on load'); + checkQueuedWorkflows(alertConfig, onLongQueuedWorkflow); + } + } catch (error) { + console.error('Error loading existing queued workflows:', error); + } +}; + +// Function to check if a workflow has been queued for too long +const checkQueuedWorkflows = (alertConfig = defaultAlertConfig, onLongQueuedWorkflow = null) => { + const threshold = alertConfig?.queuedTimeAlertThreshold || defaultAlertConfig.queuedTimeAlertThreshold; + const now = new Date(); + + console.log(`Checking ${queuedWorkflows.size} queued workflows against threshold of ${threshold} minutes`); + + queuedWorkflows.forEach((workflow, id) => { + const queuedTime = new Date(workflow.queued_at); + const queuedMinutes = (now - queuedTime) / (1000 * 60); + + console.log(`Workflow ${workflow.name} (${id}) has been queued for ${queuedMinutes.toFixed(2)} minutes`); + + if (queuedMinutes >= threshold && !workflow.alerted) { + // Mark as alerted so we don't send multiple alerts + workflow.alerted = true; + queuedWorkflows.set(id, workflow); + + console.log(`ALERT: Workflow ${workflow.name} exceeded queue threshold (${queuedMinutes.toFixed(2)} minutes)`); + + const eventData = { + workflow: workflow.name, + repository: workflow.repository, + queuedMinutes: Math.floor(queuedMinutes), + id: id + }; + + // Invoke the callback directly — do NOT emit to server (untrusted client broadcasts are disabled) + if (onLongQueuedWorkflow) { + onLongQueuedWorkflow(eventData); + } + + console.log('Long-queued-workflow alert fired:', eventData); + } + }); +}; + +export const setupSocketListeners = (callbacks) => { + console.log('Setting up socket listeners'); + + // Set up alert config early so it can be used in the initial check + const alertConfig = callbacks.alertConfig || defaultAlertConfig; + + // Load existing queued workflows when initializing and pass alert config + loadExistingQueuedWorkflows(alertConfig, callbacks.onLongQueuedWorkflow); + + const handleUpdate = (eventName, data, callback) => { + const lastUpdate = lastUpdateTimes.get(data.run.id) || 0; + const currentUpdate = new Date(data.run.updated_at).getTime(); + + // Only process if this update is newer than the last one + if (currentUpdate > lastUpdate) { + lastUpdateTimes.set(data.run.id, currentUpdate); + callback(data); + } else { + console.log(`Skipping outdated ${eventName} update for workflow ${data.run.id}`); + } + }; + + // Debounced callback handlers + const debouncedWorkflowUpdate = debounce((data) => { + if (callbacks.onWorkflowUpdate) { + handleUpdate('workflow', data, callbacks.onWorkflowUpdate); + } + }, 250); + + const debouncedJobsUpdate = debounce((data) => { + if (callbacks.onJobsUpdate) { + handleUpdate('jobs', data, callbacks.onJobsUpdate); + } + }, 250); + + // Queue time monitoring + // Track workflows in queued state for monitoring + socket.on('workflowUpdate', (data) => { + console.log('Received workflow update:', data); + + // For workflows that are queued, add them to the monitoring list + if (data.run && data.run.status === 'queued') { + queuedWorkflows.set(data.run.id, { + id: data.run.id, + name: data.workflow.name, + repository: data.repository.fullName, + queued_at: data.run.updated_at || data.run.created_at, + alerted: false + }); + } + // If workflow is no longer queued, remove from monitoring + else if (data.run && data.run.status !== 'queued' && queuedWorkflows.has(data.run.id)) { + queuedWorkflows.delete(data.run.id); + } + + if (callbacks.onNewWorkflow) { + callbacks.onNewWorkflow(data); + } + debouncedWorkflowUpdate(data); + }); + + socket.on('workflow_update', (data) => { + console.log('Received workflow_update event:', data); + + // Similar queue monitoring for workflow_update events + if (data.run && data.run.status === 'queued') { + queuedWorkflows.set(data.run.id, { + id: data.run.id, + name: data.workflow.name, + repository: data.repository.fullName, + queued_at: data.run.updated_at || data.run.created_at, + alerted: false + }); + } + else if (data.run && data.run.status !== 'queued' && queuedWorkflows.has(data.run.id)) { + queuedWorkflows.delete(data.run.id); + } + + debouncedWorkflowUpdate(data); + }); + + socket.on('workflowJobsUpdate', (data) => { + console.log('Received workflowJobsUpdate event:', data); + debouncedJobsUpdate(data); + }); + + socket.on('workflowDeleted', (data) => { + console.log('Received workflowDeleted event:', data); + if (callbacks.onWorkflowDeleted) { + callbacks.onWorkflowDeleted(data); + } + }); + + // Setup the queue time monitoring + const queueMonitorInterval = setInterval(() => checkQueuedWorkflows(alertConfig, callbacks.onLongQueuedWorkflow), 30000); // Check every 30 seconds + + // Note: long-queued-workflow alerts are fired locally via checkQueuedWorkflows callback, + // not received from the server (client-to-server broadcasts are disabled for security). + + // Cleanup function + return () => { + socket.off('workflowUpdate'); + socket.off('workflow_update'); + socket.off('workflowJobsUpdate'); + socket.off('workflowDeleted'); + clearInterval(queueMonitorInterval); + lastUpdateTimes.clear(); + queuedWorkflows.clear(); + }; +}; + +export default { + socket, + setupSocketListeners +}; \ No newline at end of file diff --git a/client/src/api/socketService.js b/client/src/api/socketService.js index 47ef3ef..f58ba40 100644 --- a/client/src/api/socketService.js +++ b/client/src/api/socketService.js @@ -1,220 +1,13 @@ -import io from 'socket.io-client'; -import apiService from './apiService'; +// Socket Service proxy — switches between real and mock based on REACT_APP_DEMO_MODE +import * as realSocket from './realSocketService'; +import * as mockSocket from './mockSocketService'; -// Use the environment variables, falling back to development defaults if not set -const WS_URL = process.env.REACT_APP_WEBSOCKET_URL || 'ws://localhost'; +const isDemoMode = process.env.REACT_APP_DEMO_MODE === 'true'; -// Default configuration for alerts -export const defaultAlertConfig = { - queuedTimeAlertThreshold: 5 // Alert threshold in minutes for queued workflows -}; +const active = isDemoMode ? mockSocket : realSocket; -// Create socket connection -export const socket = io(WS_URL, { - transports: ['websocket'], - path: '/socket.io' -}); +export const socket = active.socket; +export const setupSocketListeners = active.setupSocketListeners; +export const defaultAlertConfig = isDemoMode ? {} : (realSocket.defaultAlertConfig || {}); -// Debounce function to prevent rapid successive updates -const debounce = (func, wait) => { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -}; - -// Keep track of last update timestamps to prevent out-of-order updates -const lastUpdateTimes = new Map(); - -// Map to track workflows being monitored for queue time -const queuedWorkflows = new Map(); - -// Load existing queued workflows from the server -const loadExistingQueuedWorkflows = async (alertConfig = defaultAlertConfig, onLongQueuedWorkflow = null) => { - try { - console.log('Loading existing queued workflows from server...'); - const workflows = await apiService.getQueuedWorkflows(); - - console.log(`Loaded ${workflows.length} queued workflows from server`); - - workflows.forEach(workflow => { - if (workflow.run && workflow.run.status && ['queued', 'waiting', 'pending'].includes(workflow.run.status)) { - queuedWorkflows.set(workflow.run.id, { - id: workflow.run.id, - name: workflow.workflow.name, - repository: workflow.repository.fullName, - queued_at: workflow.run.updated_at || workflow.run.created_at, - alerted: false // Mark as not alerted so we can catch long-running queued jobs - }); - - console.log(`Added existing queued workflow: ${workflow.workflow.name} (${workflow.run.id})`); - } - }); - - // Run the check immediately after loading existing workflows - if (queuedWorkflows.size > 0) { - console.log('Running immediate check for long-queued workflows on load'); - checkQueuedWorkflows(alertConfig, onLongQueuedWorkflow); - } - } catch (error) { - console.error('Error loading existing queued workflows:', error); - } -}; - -// Function to check if a workflow has been queued for too long -const checkQueuedWorkflows = (alertConfig = defaultAlertConfig, onLongQueuedWorkflow = null) => { - const threshold = alertConfig?.queuedTimeAlertThreshold || defaultAlertConfig.queuedTimeAlertThreshold; - const now = new Date(); - - console.log(`Checking ${queuedWorkflows.size} queued workflows against threshold of ${threshold} minutes`); - - queuedWorkflows.forEach((workflow, id) => { - const queuedTime = new Date(workflow.queued_at); - const queuedMinutes = (now - queuedTime) / (1000 * 60); - - console.log(`Workflow ${workflow.name} (${id}) has been queued for ${queuedMinutes.toFixed(2)} minutes`); - - if (queuedMinutes >= threshold && !workflow.alerted) { - // Mark as alerted so we don't send multiple alerts - workflow.alerted = true; - queuedWorkflows.set(id, workflow); - - console.log(`ALERT: Workflow ${workflow.name} exceeded queue threshold (${queuedMinutes.toFixed(2)} minutes)`); - - const eventData = { - workflow: workflow.name, - repository: workflow.repository, - queuedMinutes: Math.floor(queuedMinutes), - id: id - }; - - // Invoke the callback directly — do NOT emit to server (untrusted client broadcasts are disabled) - if (onLongQueuedWorkflow) { - onLongQueuedWorkflow(eventData); - } - - console.log('Long-queued-workflow alert fired:', eventData); - } - }); -}; - -export const setupSocketListeners = (callbacks) => { - console.log('Setting up socket listeners'); - - // Set up alert config early so it can be used in the initial check - const alertConfig = callbacks.alertConfig || defaultAlertConfig; - - // Load existing queued workflows when initializing and pass alert config - loadExistingQueuedWorkflows(alertConfig, callbacks.onLongQueuedWorkflow); - - const handleUpdate = (eventName, data, callback) => { - const lastUpdate = lastUpdateTimes.get(data.run.id) || 0; - const currentUpdate = new Date(data.run.updated_at).getTime(); - - // Only process if this update is newer than the last one - if (currentUpdate > lastUpdate) { - lastUpdateTimes.set(data.run.id, currentUpdate); - callback(data); - } else { - console.log(`Skipping outdated ${eventName} update for workflow ${data.run.id}`); - } - }; - - // Debounced callback handlers - const debouncedWorkflowUpdate = debounce((data) => { - if (callbacks.onWorkflowUpdate) { - handleUpdate('workflow', data, callbacks.onWorkflowUpdate); - } - }, 250); - - const debouncedJobsUpdate = debounce((data) => { - if (callbacks.onJobsUpdate) { - handleUpdate('jobs', data, callbacks.onJobsUpdate); - } - }, 250); - - // Queue time monitoring - // Track workflows in queued state for monitoring - socket.on('workflowUpdate', (data) => { - console.log('Received workflow update:', data); - - // For workflows that are queued, add them to the monitoring list - if (data.run && data.run.status === 'queued') { - queuedWorkflows.set(data.run.id, { - id: data.run.id, - name: data.workflow.name, - repository: data.repository.fullName, - queued_at: data.run.updated_at || data.run.created_at, - alerted: false - }); - } - // If workflow is no longer queued, remove from monitoring - else if (data.run && data.run.status !== 'queued' && queuedWorkflows.has(data.run.id)) { - queuedWorkflows.delete(data.run.id); - } - - if (callbacks.onNewWorkflow) { - callbacks.onNewWorkflow(data); - } - debouncedWorkflowUpdate(data); - }); - - socket.on('workflow_update', (data) => { - console.log('Received workflow_update event:', data); - - // Similar queue monitoring for workflow_update events - if (data.run && data.run.status === 'queued') { - queuedWorkflows.set(data.run.id, { - id: data.run.id, - name: data.workflow.name, - repository: data.repository.fullName, - queued_at: data.run.updated_at || data.run.created_at, - alerted: false - }); - } - else if (data.run && data.run.status !== 'queued' && queuedWorkflows.has(data.run.id)) { - queuedWorkflows.delete(data.run.id); - } - - debouncedWorkflowUpdate(data); - }); - - socket.on('workflowJobsUpdate', (data) => { - console.log('Received workflowJobsUpdate event:', data); - debouncedJobsUpdate(data); - }); - - socket.on('workflowDeleted', (data) => { - console.log('Received workflowDeleted event:', data); - if (callbacks.onWorkflowDeleted) { - callbacks.onWorkflowDeleted(data); - } - }); - - // Setup the queue time monitoring - const queueMonitorInterval = setInterval(() => checkQueuedWorkflows(alertConfig, callbacks.onLongQueuedWorkflow), 30000); // Check every 30 seconds - - // Note: long-queued-workflow alerts are fired locally via checkQueuedWorkflows callback, - // not received from the server (client-to-server broadcasts are disabled for security). - - // Cleanup function - return () => { - socket.off('workflowUpdate'); - socket.off('workflow_update'); - socket.off('workflowJobsUpdate'); - socket.off('workflowDeleted'); - clearInterval(queueMonitorInterval); - lastUpdateTimes.clear(); - queuedWorkflows.clear(); - }; -}; - -export default { - socket, - setupSocketListeners -}; \ No newline at end of file +export default { socket, setupSocketListeners, defaultAlertConfig }; diff --git a/client/src/common/components/DemoBanner.jsx b/client/src/common/components/DemoBanner.jsx new file mode 100644 index 0000000..235ba75 --- /dev/null +++ b/client/src/common/components/DemoBanner.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Box, Typography, Chip } from '@mui/material'; +import ScienceIcon from '@mui/icons-material/Science'; + +const DemoBanner = () => ( + + } + label="DEMO" + size="small" + sx={{ + bgcolor: 'rgba(139, 92, 246, 0.2)', + color: '#A78BFA', + border: '1px solid rgba(139, 92, 246, 0.4)', + fontWeight: 700, + fontSize: '0.7rem', + height: 24, + }} + /> + + This is a demo with mock data.{' '} + + Install RunWatch → + + + +); + +export default DemoBanner; From cca8b67b7579aad5470eee35484f80179d0cad9b Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:18:24 -0600 Subject: [PATCH 2/2] fix: resolve CodeQL tainted format string alerts in realApiService Replace template literals in console.error calls with separate arguments to avoid externally-controlled format string warnings. --- client/src/api/realApiService.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/api/realApiService.js b/client/src/api/realApiService.js index e015c01..2759cde 100644 --- a/client/src/api/realApiService.js +++ b/client/src/api/realApiService.js @@ -53,7 +53,7 @@ const apiService = { const response = await api.get(`${API_URL}/workflow-runs/repo/${repoName}`, { params }); return response.data.data || { data: [], pagination: { total: 0, page: 1, pageSize: 0, totalPages: 1 } }; } catch (error) { - console.error(`Error fetching workflow runs for repo ${repoName}:`, error); + console.error('Error fetching workflow runs for repo:', repoName, error); throw error; } }, @@ -64,7 +64,7 @@ const apiService = { const response = await api.get(`${API_URL}/workflow-runs/${id}`); return response.data.data; } catch (error) { - console.error(`Error fetching workflow run ${id}:`, error); + console.error('Error fetching workflow run:', id, error); throw error; } }, @@ -75,7 +75,7 @@ const apiService = { const response = await api.post(`${API_URL}/workflow-runs/${id}/sync`); return response.data.data; } catch (error) { - console.error(`Error syncing workflow run ${id}:`, error); + console.error('Error syncing workflow run:', id, error); throw error; } }, @@ -85,7 +85,7 @@ const apiService = { const response = await api.delete(`${API_URL}/workflow-runs/${id}`); return response.data.data; } catch (error) { - console.error(`Error deleting workflow run ${id}:`, error); + console.error('Error deleting workflow run:', id, error); throw error; } }, @@ -96,7 +96,7 @@ const apiService = { const response = await api.post(`${API_URL}/workflow-runs/repo/${repoName}/sync`); return response.data.data; } catch (error) { - console.error(`Error syncing workflow runs for repo ${repoName}:`, error); + console.error('Error syncing workflow runs for repo:', repoName, error); throw error; } },