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;