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>
270 lines
7.1 KiB
TypeScript
270 lines
7.1 KiB
TypeScript
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;
|