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:
@@ -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
|
||||
|
||||
269
backend/src/routes/deploy-status.ts
Normal file
269
backend/src/routes/deploy-status.ts
Normal 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;
|
||||
Reference in New Issue
Block a user