From 09dd756effce4ee05f9997c14cdd861be60e9c0d Mon Sep 17 00:00:00 2001 From: Kelly Date: Tue, 9 Dec 2025 11:26:41 -0700 Subject: [PATCH] feat(admin): Add deploy status panel to dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows running version vs latest git commit, pipeline status with steps, and how many commits behind if not on latest. Uses Woodpecker and Gitea APIs to fetch CI/CD information. Auto-refreshes every 30 seconds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/index.ts | 3 + backend/src/routes/deploy-status.ts | 269 ++++++++++++++++ cannaiq/src/components/DeployStatus.tsx | 329 ++++++++++++++++++++ cannaiq/src/pages/Dashboard.tsx | 4 + cannaiq/src/pages/OrchestratorDashboard.tsx | 4 + 5 files changed, 609 insertions(+) create mode 100644 backend/src/routes/deploy-status.ts create mode 100644 cannaiq/src/components/DeployStatus.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index 51cee953..23bca892 100755 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -107,6 +107,7 @@ import apiPermissionsRoutes from './routes/api-permissions'; import parallelScrapeRoutes from './routes/parallel-scrape'; import crawlerSandboxRoutes from './routes/crawler-sandbox'; import versionRoutes from './routes/version'; +import deployStatusRoutes from './routes/deploy-status'; import publicApiRoutes from './routes/public-api'; import usersRoutes from './routes/users'; import staleProcessesRoutes from './routes/stale-processes'; @@ -180,6 +181,8 @@ app.use('/api/api-permissions', apiPermissionsRoutes); app.use('/api/parallel-scrape', parallelScrapeRoutes); app.use('/api/crawler-sandbox', crawlerSandboxRoutes); app.use('/api/version', versionRoutes); +app.use('/api/admin/deploy-status', deployStatusRoutes); +console.log('[DeployStatus] Routes registered at /api/admin/deploy-status'); app.use('/api/users', usersRoutes); app.use('/api/stale-processes', staleProcessesRoutes); // Admin routes - orchestrator actions diff --git a/backend/src/routes/deploy-status.ts b/backend/src/routes/deploy-status.ts new file mode 100644 index 00000000..8368ba8a --- /dev/null +++ b/backend/src/routes/deploy-status.ts @@ -0,0 +1,269 @@ +import { Router, Request, Response } from 'express'; +import axios from 'axios'; + +const router = Router(); + +// Woodpecker API config - uses env vars or falls back +const WOODPECKER_SERVER = process.env.WOODPECKER_SERVER || 'https://ci.cannabrands.app'; +const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN; +const GITEA_SERVER = process.env.GITEA_SERVER || 'https://code.cannabrands.app'; +const GITEA_TOKEN = process.env.GITEA_TOKEN; +const REPO_OWNER = 'Creationshop'; +const REPO_NAME = 'dispensary-scraper'; + +interface PipelineStep { + name: string; + state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'; + started?: number; + stopped?: number; +} + +interface PipelineInfo { + number: number; + status: string; + event: string; + branch: string; + message: string; + commit: string; + author: string; + created: number; + started?: number; + finished?: number; + steps?: PipelineStep[]; +} + +interface DeployStatusResponse { + running: { + sha: string; + sha_full: string; + build_time: string; + image_tag: string; + }; + latest: { + sha: string; + sha_full: string; + message: string; + author: string; + timestamp: string; + } | null; + is_latest: boolean; + commits_behind: number; + pipeline: PipelineInfo | null; + error?: string; +} + +/** + * Fetch latest commit from Gitea + */ +async function getLatestCommit(): Promise<{ + sha: string; + message: string; + author: string; + timestamp: string; +} | null> { + if (!GITEA_TOKEN) { + console.warn('[DeployStatus] GITEA_TOKEN not set, skipping latest commit fetch'); + return null; + } + + try { + const response = await axios.get( + `${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/commits?limit=1`, + { + headers: { Authorization: `token ${GITEA_TOKEN}` }, + timeout: 5000, + } + ); + + if (response.data && response.data.length > 0) { + const commit = response.data[0]; + return { + sha: commit.sha, + message: commit.commit?.message?.split('\n')[0] || '', + author: commit.commit?.author?.name || commit.author?.login || 'unknown', + timestamp: commit.commit?.author?.date || commit.created, + }; + } + } catch (error: any) { + console.error('[DeployStatus] Failed to fetch latest commit:', error.message); + } + + return null; +} + +/** + * Fetch latest pipeline from Woodpecker + */ +async function getLatestPipeline(): Promise { + if (!WOODPECKER_TOKEN) { + console.warn('[DeployStatus] WOODPECKER_TOKEN not set, skipping pipeline fetch'); + return null; + } + + try { + // Get latest pipeline + const listResponse = await axios.get( + `${WOODPECKER_SERVER}/api/repos/${REPO_OWNER}/${REPO_NAME}/pipelines?page=1&per_page=1`, + { + headers: { Authorization: `Bearer ${WOODPECKER_TOKEN}` }, + timeout: 5000, + } + ); + + if (!listResponse.data || listResponse.data.length === 0) { + return null; + } + + const pipeline = listResponse.data[0]; + + // Get pipeline steps + let steps: PipelineStep[] = []; + try { + const stepsResponse = await axios.get( + `${WOODPECKER_SERVER}/api/repos/${REPO_OWNER}/${REPO_NAME}/pipelines/${pipeline.number}`, + { + headers: { Authorization: `Bearer ${WOODPECKER_TOKEN}` }, + timeout: 5000, + } + ); + + if (stepsResponse.data?.workflows) { + for (const workflow of stepsResponse.data.workflows) { + if (workflow.children) { + for (const step of workflow.children) { + steps.push({ + name: step.name, + state: step.state, + started: step.start_time, + stopped: step.end_time, + }); + } + } + } + } + } catch (stepError) { + // Steps fetch failed, continue without them + } + + return { + number: pipeline.number, + status: pipeline.status, + event: pipeline.event, + branch: pipeline.branch, + message: pipeline.message?.split('\n')[0] || '', + commit: pipeline.commit?.slice(0, 8) || '', + author: pipeline.author || 'unknown', + created: pipeline.created_at, + started: pipeline.started_at, + finished: pipeline.finished_at, + steps, + }; + } catch (error: any) { + console.error('[DeployStatus] Failed to fetch pipeline:', error.message); + } + + return null; +} + +/** + * Count commits between two SHAs + */ +async function countCommitsBetween(fromSha: string, toSha: string): Promise { + if (!GITEA_TOKEN || !fromSha || !toSha) return 0; + if (fromSha === toSha) return 0; + + try { + const response = await axios.get( + `${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/commits?sha=${toSha}&limit=50`, + { + headers: { Authorization: `token ${GITEA_TOKEN}` }, + timeout: 5000, + } + ); + + if (response.data) { + const commits = response.data; + for (let i = 0; i < commits.length; i++) { + if (commits[i].sha.startsWith(fromSha)) { + return i; + } + } + // If not found in first 50, assume more than 50 behind + return commits.length; + } + } catch (error: any) { + console.error('[DeployStatus] Failed to count commits:', error.message); + } + + return 0; +} + +/** + * GET /api/admin/deploy-status + * Returns deployment status with version comparison and CI info + */ +router.get('/', async (req: Request, res: Response) => { + try { + // Get running version from env vars (set during Docker build) + const runningSha = process.env.APP_GIT_SHA || 'unknown'; + const running = { + sha: runningSha.slice(0, 8), + sha_full: runningSha, + build_time: process.env.APP_BUILD_TIME || new Date().toISOString(), + image_tag: process.env.CONTAINER_IMAGE_TAG?.slice(0, 8) || 'local', + }; + + // Fetch latest commit and pipeline in parallel + const [latestCommit, pipeline] = await Promise.all([ + getLatestCommit(), + getLatestPipeline(), + ]); + + // Build latest info + const latest = latestCommit ? { + sha: latestCommit.sha.slice(0, 8), + sha_full: latestCommit.sha, + message: latestCommit.message, + author: latestCommit.author, + timestamp: latestCommit.timestamp, + } : null; + + // Determine if running latest + const isLatest = latest + ? runningSha.startsWith(latest.sha_full.slice(0, 8)) || + latest.sha_full.startsWith(runningSha.slice(0, 8)) + : true; + + // Count commits behind + const commitsBehind = isLatest + ? 0 + : await countCommitsBetween(runningSha, latest?.sha_full || ''); + + const response: DeployStatusResponse = { + running, + latest, + is_latest: isLatest, + commits_behind: commitsBehind, + pipeline, + }; + + res.json(response); + } catch (error: any) { + console.error('[DeployStatus] Error:', error); + res.status(500).json({ + error: error.message, + running: { + sha: process.env.APP_GIT_SHA?.slice(0, 8) || 'unknown', + sha_full: process.env.APP_GIT_SHA || 'unknown', + build_time: process.env.APP_BUILD_TIME || 'unknown', + image_tag: process.env.CONTAINER_IMAGE_TAG?.slice(0, 8) || 'local', + }, + latest: null, + is_latest: true, + commits_behind: 0, + pipeline: null, + }); + } +}); + +export default router; diff --git a/cannaiq/src/components/DeployStatus.tsx b/cannaiq/src/components/DeployStatus.tsx new file mode 100644 index 00000000..b917e779 --- /dev/null +++ b/cannaiq/src/components/DeployStatus.tsx @@ -0,0 +1,329 @@ +import { useEffect, useState } from 'react'; +import { api } from '../lib/api'; + +interface PipelineStep { + name: string; + state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'; +} + +interface DeployStatusData { + running: { + sha: string; + sha_full: string; + build_time: string; + image_tag: string; + }; + latest: { + sha: string; + sha_full: string; + message: string; + author: string; + timestamp: string; + } | null; + is_latest: boolean; + commits_behind: number; + pipeline: { + number: number; + status: string; + event: string; + branch: string; + message: string; + commit: string; + author: string; + created: number; + steps?: PipelineStep[]; + } | null; + error?: string; +} + +const statusColors: Record = { + success: '#10b981', + running: '#f59e0b', + pending: '#6b7280', + failure: '#ef4444', + error: '#ef4444', + skipped: '#9ca3af', +}; + +const statusIcons: Record = { + success: '\u2713', + running: '\u25B6', + pending: '\u25CB', + failure: '\u2717', + error: '\u2717', + skipped: '\u2212', +}; + +export function DeployStatus() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchStatus = async () => { + try { + setLoading(true); + const response = await api.get('/api/admin/deploy-status'); + setData(response); + setError(null); + } catch (err: any) { + setError(err.message || 'Failed to fetch deploy status'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchStatus(); + // Auto-refresh every 30 seconds + const interval = setInterval(fetchStatus, 30000); + return () => clearInterval(interval); + }, []); + + const formatTime = (timestamp: string | number) => { + const date = typeof timestamp === 'number' + ? new Date(timestamp * 1000) + : new Date(timestamp); + return date.toLocaleString(); + }; + + const formatTimeAgo = (timestamp: string | number) => { + const date = typeof timestamp === 'number' + ? new Date(timestamp * 1000) + : new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; + }; + + if (loading && !data) { + return ( +
+ Loading deploy status... +
+ ); + } + + if (error && !data) { + return ( +
+ Error: {error} +
+ ); + } + + if (!data) return null; + + const pipelineStatus = data.pipeline?.status || 'unknown'; + + return ( +
+ {/* Header */} +
+
+ + Deploy Status + + {data.is_latest ? ( + + Up to date + + ) : ( + + {data.commits_behind} commit{data.commits_behind !== 1 ? 's' : ''} behind + + )} +
+ +
+ + {/* Version Info */} +
+ {/* Running Version */} +
+
+ Running Version +
+
+ + {data.running.sha} + + + {formatTimeAgo(data.running.build_time)} + +
+
+ + {/* Latest Commit */} +
+
+ Latest Commit +
+ {data.latest ? ( +
+
+ + {data.latest.sha} + + + {formatTimeAgo(data.latest.timestamp)} + +
+
+ {data.latest.message} +
+
+ ) : ( + Unable to fetch + )} +
+
+ + {/* Pipeline Status */} + {data.pipeline && ( +
+
+
+ + {statusIcons[pipelineStatus] || '?'} + + + Pipeline #{data.pipeline.number} + + + {pipelineStatus} + +
+ + {data.pipeline.branch} \u2022 {data.pipeline.commit} + +
+ + {/* Pipeline Steps */} + {data.pipeline.steps && data.pipeline.steps.length > 0 && ( +
+ {data.pipeline.steps.map((step, idx) => ( +
+ + {statusIcons[step.state] || '?'} + + {step.name} +
+ ))} +
+ )} + + {/* Commit message */} +
+ {data.pipeline.message} +
+
+ )} +
+ ); +} diff --git a/cannaiq/src/pages/Dashboard.tsx b/cannaiq/src/pages/Dashboard.tsx index dfc22692..98548e03 100755 --- a/cannaiq/src/pages/Dashboard.tsx +++ b/cannaiq/src/pages/Dashboard.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Layout } from '../components/Layout'; import { HealthPanel } from '../components/HealthPanel'; +import { DeployStatus } from '../components/DeployStatus'; import { api } from '../lib/api'; import { useNavigate } from 'react-router-dom'; import { @@ -220,6 +221,9 @@ export function Dashboard() { {/* System Health */} + {/* Deploy Status */} + + {/* Stats Grid */}
{/* Products */} diff --git a/cannaiq/src/pages/OrchestratorDashboard.tsx b/cannaiq/src/pages/OrchestratorDashboard.tsx index 15c6525f..1cc197ce 100644 --- a/cannaiq/src/pages/OrchestratorDashboard.tsx +++ b/cannaiq/src/pages/OrchestratorDashboard.tsx @@ -23,6 +23,7 @@ import { ArrowUpCircle, } from 'lucide-react'; import { StoreOrchestratorPanel } from '../components/StoreOrchestratorPanel'; +import { DeployStatus } from '../components/DeployStatus'; interface CrawlHealth { status: 'ok' | 'degraded' | 'stale' | 'error'; @@ -286,6 +287,9 @@ export function OrchestratorDashboard() {
+ {/* Deploy Status Panel */} + + {/* Metrics Cards - Clickable - Responsive: 2→3→4→7 columns */} {metrics && (