feat(admin): Add deploy status panel to dashboard

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 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-09 11:26:41 -07:00
parent ec8ef6210c
commit 09dd756eff
5 changed files with 609 additions and 0 deletions

View File

@@ -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

View File

@@ -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<PipelineInfo | null> {
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<number> {
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;

View File

@@ -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<string, string> = {
success: '#10b981',
running: '#f59e0b',
pending: '#6b7280',
failure: '#ef4444',
error: '#ef4444',
skipped: '#9ca3af',
};
const statusIcons: Record<string, string> = {
success: '\u2713',
running: '\u25B6',
pending: '\u25CB',
failure: '\u2717',
error: '\u2717',
skipped: '\u2212',
};
export function DeployStatus() {
const [data, setData] = useState<DeployStatusData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div style={{ padding: '20px', background: '#1f2937', borderRadius: '8px', color: '#9ca3af' }}>
Loading deploy status...
</div>
);
}
if (error && !data) {
return (
<div style={{ padding: '20px', background: '#1f2937', borderRadius: '8px', color: '#ef4444' }}>
Error: {error}
</div>
);
}
if (!data) return null;
const pipelineStatus = data.pipeline?.status || 'unknown';
return (
<div style={{
background: '#1f2937',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid #374151'
}}>
{/* Header */}
<div style={{
padding: '16px 20px',
borderBottom: '1px solid #374151',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px', fontWeight: '600', color: '#f3f4f6' }}>
Deploy Status
</span>
{data.is_latest ? (
<span style={{
background: '#10b981',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
Up to date
</span>
) : (
<span style={{
background: '#f59e0b',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
{data.commits_behind} commit{data.commits_behind !== 1 ? 's' : ''} behind
</span>
)}
</div>
<button
onClick={fetchStatus}
disabled={loading}
style={{
background: '#374151',
border: 'none',
padding: '6px 12px',
borderRadius: '4px',
color: '#9ca3af',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '12px'
}}
>
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{/* Version Info */}
<div style={{ padding: '16px 20px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
{/* Running Version */}
<div>
<div style={{ color: '#9ca3af', fontSize: '12px', marginBottom: '8px', textTransform: 'uppercase' }}>
Running Version
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{
background: '#374151',
padding: '4px 8px',
borderRadius: '4px',
color: '#10b981',
fontSize: '14px',
fontFamily: 'monospace'
}}>
{data.running.sha}
</code>
<span style={{ color: '#6b7280', fontSize: '12px' }}>
{formatTimeAgo(data.running.build_time)}
</span>
</div>
</div>
{/* Latest Commit */}
<div>
<div style={{ color: '#9ca3af', fontSize: '12px', marginBottom: '8px', textTransform: 'uppercase' }}>
Latest Commit
</div>
{data.latest ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{
background: '#374151',
padding: '4px 8px',
borderRadius: '4px',
color: data.is_latest ? '#10b981' : '#f59e0b',
fontSize: '14px',
fontFamily: 'monospace'
}}>
{data.latest.sha}
</code>
<span style={{ color: '#6b7280', fontSize: '12px' }}>
{formatTimeAgo(data.latest.timestamp)}
</span>
</div>
<div style={{
color: '#9ca3af',
fontSize: '13px',
marginTop: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '300px'
}}>
{data.latest.message}
</div>
</div>
) : (
<span style={{ color: '#6b7280' }}>Unable to fetch</span>
)}
</div>
</div>
{/* Pipeline Status */}
{data.pipeline && (
<div style={{
padding: '16px 20px',
borderTop: '1px solid #374151',
background: '#111827'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{
color: statusColors[pipelineStatus] || '#6b7280',
fontSize: '16px'
}}>
{statusIcons[pipelineStatus] || '?'}
</span>
<span style={{ color: '#f3f4f6', fontWeight: '500' }}>
Pipeline #{data.pipeline.number}
</span>
<span style={{
color: statusColors[pipelineStatus] || '#6b7280',
fontSize: '13px',
textTransform: 'capitalize'
}}>
{pipelineStatus}
</span>
</div>
<span style={{ color: '#6b7280', fontSize: '12px' }}>
{data.pipeline.branch} \u2022 {data.pipeline.commit}
</span>
</div>
{/* Pipeline Steps */}
{data.pipeline.steps && data.pipeline.steps.length > 0 && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap'
}}>
{data.pipeline.steps.map((step, idx) => (
<div
key={idx}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
background: '#1f2937',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}}
>
<span style={{ color: statusColors[step.state] || '#6b7280' }}>
{statusIcons[step.state] || '?'}
</span>
<span style={{ color: '#9ca3af' }}>{step.name}</span>
</div>
))}
</div>
)}
{/* Commit message */}
<div style={{
color: '#6b7280',
fontSize: '12px',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{data.pipeline.message}
</div>
</div>
)}
</div>
);
}

View File

@@ -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 */}
<HealthPanel showQueues={false} refreshInterval={60000} />
{/* Deploy Status */}
<DeployStatus />
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-6">
{/* Products */}

View File

@@ -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() {
</div>
</div>
{/* Deploy Status Panel */}
<DeployStatus />
{/* Metrics Cards - Clickable - Responsive: 2→3→4→7 columns */}
{metrics && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-7 gap-3 md:gap-4">