show woodpecker common apps on dashboard
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 18:20:35 +09:00
parent 762cd77539
commit 1f716c7719
4 changed files with 179 additions and 17 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react'; import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
import { Button, Card, Modal } from '../../components/ui'; import { Button, Card, Modal } from '../../components/ui';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
@@ -42,6 +42,18 @@ function migrationTime(job: RuntimeControlJob) {
return Number.isNaN(value) ? 0 : value; return Number.isNaN(value) ? 0 : value;
} }
function sanitizeK8sName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function uniqueValues(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
const migrationPageSize = 5; const migrationPageSize = 5;
export function MigrationManagementPage() { export function MigrationManagementPage() {
@@ -64,6 +76,18 @@ export function MigrationManagementPage() {
const selectedRelease = releases.find((release) => release.id === releaseId); const selectedRelease = releases.find((release) => release.id === releaseId);
const migrationComponents = selectedApp?.components?.filter((component) => component.type === 'MIGRATION') ?? []; const migrationComponents = selectedApp?.components?.filter((component) => component.type === 'MIGRATION') ?? [];
const images = imageEntries(selectedRelease?.imageTags); const images = imageEntries(selectedRelease?.imageTags);
const namespaceOptions = useMemo(
() => uniqueValues([selectedApp?.namespace || '', namespace]),
[namespace, selectedApp?.namespace]
);
const envSecretOptions = useMemo(
() => uniqueValues([
...images.map(([name]) => `${sanitizeK8sName(name)}-secret`),
`${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`,
envSecretName
]),
[envSecretName, images, selectedApp?.key]
);
const selectedImage = selectedImageTag(selectedRelease, imageComponentName); const selectedImage = selectedImageTag(selectedRelease, imageComponentName);
const sortedJobs = [...jobs].sort((a, b) => migrationTime(b) - migrationTime(a)); const sortedJobs = [...jobs].sort((a, b) => migrationTime(b) - migrationTime(a));
const totalPages = Math.max(1, Math.ceil(sortedJobs.length / migrationPageSize)); const totalPages = Math.max(1, Math.ceil(sortedJobs.length / migrationPageSize));
@@ -83,7 +107,7 @@ export function MigrationManagementPage() {
setMigrationComponentId((prev) => ( setMigrationComponentId((prev) => (
migrationComponents.some((component) => component.id === prev) ? prev : migrationComponents[0]?.id || '' migrationComponents.some((component) => component.id === prev) ? prev : migrationComponents[0]?.id || ''
)); ));
setEnvSecretName((prev) => prev || `${selectedApp?.key ?? 'app'}-be-secret`); setEnvSecretName((prev) => prev || `${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -98,10 +122,14 @@ export function MigrationManagementPage() {
setImageComponentName((prev) => (nextImages.some(([name]) => name === prev) ? prev : nextImages[0]?.[0] || '')); setImageComponentName((prev) => (nextImages.some(([name]) => name === prev) ? prev : nextImages[0]?.[0] || ''));
}, [selectedRelease?.id]); }, [selectedRelease?.id]);
useEffect(() => {
if (imageComponentName) setEnvSecretName(`${sanitizeK8sName(imageComponentName)}-secret`);
}, [imageComponentName]);
useEffect(() => { useEffect(() => {
setNamespace(selectedApp?.namespace || ''); setNamespace(selectedApp?.namespace || '');
setMigrationComponentId(migrationComponents[0]?.id || ''); setMigrationComponentId(migrationComponents[0]?.id || '');
setEnvSecretName(`${selectedApp?.key ?? 'app'}-be-secret`); setEnvSecretName(`${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`);
setPage(1); setPage(1);
}, [selectedAppId]); }, [selectedAppId]);
@@ -117,7 +145,7 @@ export function MigrationManagementPage() {
} }
}; };
const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && command.trim()); const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && envSecretName && command.trim());
return ( return (
<> <>
@@ -182,19 +210,25 @@ export function MigrationManagementPage() {
</label> </label>
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span> <span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
<input <select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={namespace} value={namespace}
onChange={(event) => setNamespace(event.target.value)} onChange={(event) => setNamespace(event.target.value)}
/> >
{namespaceOptions.length === 0 ? <option value="">네임스페이스 없음</option> : null}
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label> </label>
<label className="block"> <label className="block">
<span className="text-xs font-medium text-muted-foreground">envFrom Secret</span> <span className="text-xs font-medium text-muted-foreground">envFrom Secret</span>
<input <select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={envSecretName} value={envSecretName}
onChange={(event) => setEnvSecretName(event.target.value)} onChange={(event) => setEnvSecretName(event.target.value)}
/> >
{envSecretOptions.length === 0 ? <option value="">Secret 없음</option> : null}
{envSecretOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label> </label>
<label className="block lg:col-span-3"> <label className="block lg:col-span-3">
<span className="text-xs font-medium text-muted-foreground">명령</span> <span className="text-xs font-medium text-muted-foreground">명령</span>
+134 -6
View File
@@ -1,19 +1,89 @@
import { Activity, Boxes, FileKey, Users } from 'lucide-react'; import { Activity, Boxes, ExternalLink, FileKey, GitBranch, RefreshCw } from 'lucide-react';
import { Card } from '../../components/ui'; import { useEffect, useMemo, useState } from 'react';
import { Badge, Button, Card, CardHeader } from '../../components/ui';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import { apiRequest } from '../../lib/api';
import { cn } from '../../lib/utils';
type CommonAppPipeline = {
id: string;
repository: string;
message: string;
branch: string;
status: string;
createdAt: number;
url?: string;
};
function relativeTime(timestamp: number) {
if (!timestamp) return '-';
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds < 60) return '방금 전';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}분 전`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}일 전`;
return new Intl.DateTimeFormat('ko-KR', { month: 'short', day: 'numeric' }).format(new Date(timestamp));
}
function pipelineTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
const normalized = status.toLowerCase();
if (['success', 'succeeded', 'done'].includes(normalized)) return 'green';
if (['failure', 'failed', 'error', 'killed'].includes(normalized)) return 'red';
if (['running', 'started'].includes(normalized)) return 'blue';
if (['pending', 'blocked', 'created'].includes(normalized)) return 'amber';
return 'neutral';
}
async function loadCommonAppPipelines(): Promise<CommonAppPipeline[]> {
const response = await apiRequest<{ repos: CommonAppPipeline[] }>('/woodpecker/repos');
return response.repos.slice(0, 8);
}
export function DashboardPage() { export function DashboardPage() {
const [commonApps, setCommonApps] = useState<CommonAppPipeline[]>([]);
const [commonAppsLoading, setCommonAppsLoading] = useState(true);
const [commonAppsFailed, setCommonAppsFailed] = useState(false);
const refreshCommonApps = () => {
setCommonAppsLoading(true);
loadCommonAppPipelines()
.then((pipelines) => {
setCommonApps(pipelines);
setCommonAppsFailed(false);
})
.catch(() => {
setCommonApps([]);
setCommonAppsFailed(true);
})
.finally(() => setCommonAppsLoading(false));
};
useEffect(() => {
refreshCommonApps();
}, []);
const launcherRows = useMemo(
() => [
['최신 버전', '런처 버전 관리'],
['빌드/배포', '런처 빌드 관리'],
['소스 연결', '런처 소스 관리']
],
[]
);
return ( return (
<> <>
<PageTitle <PageTitle
eyebrow="플랫폼 운영" eyebrow="플랫폼 운영"
title="대시보드" title="대시보드"
description="런처, 앱, 사용자, 라이센스, 런타임 상태를 관리하는 스튜디오 어드민 콘솔입니다." description="공통 앱, 런처, 앱, 라이센스, 런타임 상태를 관리하는 스튜디오 어드민 콘솔입니다."
/> />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[ {[
['활성 앱', '10', Boxes], ['활성 앱', '10', Boxes],
['접속 사용자', '1,284', Users],
['라이센스 사용률', '72%', FileKey], ['라이센스 사용률', '72%', FileKey],
['런타임 정상률', '96.4%', Activity] ['런타임 정상률', '96.4%', Activity]
].map(([label, value, Icon]) => ( ].map(([label, value, Icon]) => (
@@ -26,7 +96,65 @@ export function DashboardPage() {
</Card> </Card>
))} ))}
</div> </div>
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<Card className="min-w-0 overflow-hidden">
<CardHeader title="런처 버전 관리" description="런처 클라이언트와 런처 백엔드의 버전 기준을 확인합니다." />
<div className="divide-y divide-border">
{launcherRows.map(([label, value]) => (
<div key={label} className="flex items-center justify-between gap-4 px-5 py-4 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="truncate font-semibold">{value}</span>
</div>
))}
</div>
</Card>
<Card className="min-w-0 overflow-hidden">
<CardHeader
title="공통앱"
description="Woodpecker의 모든 저장소를 마지막 파이프라인 생성 순으로 표시합니다."
action={
<Button variant="outline" className="size-8 px-0" onClick={refreshCommonApps} disabled={commonAppsLoading} title="새로고침" aria-label="새로고침">
<RefreshCw className={cn('size-4', commonAppsLoading && 'animate-spin')} />
</Button>
}
/>
<div className="max-h-[420px] overflow-y-auto">
{commonAppsFailed ? (
<div className="flex min-h-36 items-center justify-center px-5 text-sm text-rose-300">Woodpecker 저장소를 불러오지 못했습니다.</div>
) : commonAppsLoading && commonApps.length === 0 ? (
<div className="flex min-h-36 items-center justify-center px-5 text-sm text-muted-foreground">저장소를 불러오는 중입니다.</div>
) : commonApps.length === 0 ? (
<div className="flex min-h-36 items-center justify-center px-5 text-sm text-muted-foreground">표시할 저장소가 없습니다.</div>
) : (
<div className="divide-y divide-border">
{commonApps.map((pipeline) => (
<div key={pipeline.id} className="px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-semibold">{pipeline.repository}</p>
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">{pipeline.message}</p>
</div>
{pipeline.url ? (
<a href={pipeline.url} target="_blank" rel="noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground" aria-label="Woodpecker 저장소 열기">
<ExternalLink className="size-4" />
</a>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge tone={pipelineTone(pipeline.status)}>{pipeline.status}</Badge>
<span>{pipeline.branch}</span>
<span>{relativeTime(pipeline.createdAt)}</span>
</div>
</div>
))}
</div>
)}
</div>
</Card>
</div>
</> </>
); );
} }
+2 -2
View File
@@ -103,7 +103,7 @@ export function UserSessionsPage() {
return ( return (
<> <>
<PageTitle eyebrow="운영" title="사용자/세션" description="접속 중인 사용자와 세션 상태를 확인하고 필요한 제어 작업을 실행합니다." /> <PageTitle eyebrow="운영" title="사용자/세션" description="사용자의 유효토큰, 만료, 종료 상태를 확인하고 필요한 제어 작업을 실행합니다." />
{failed ? ( {failed ? (
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200"> <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
사용자/세션 데이터를 불러오지 못했습니다. 사용자/세션 데이터를 불러오지 못했습니다.
@@ -149,7 +149,7 @@ export function UserSessionsPage() {
terminateUserSession(row.sessionId).then(refresh).catch(() => setFailed(true)); terminateUserSession(row.sessionId).then(refresh).catch(() => setFailed(true));
}} }}
> >
세션 종료 유효토큰 종료
</button> </button>
</div> </div>
); );
+1 -1
View File
@@ -7,7 +7,7 @@ export type BadgeTone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet
export function statusTone(status?: string | null): BadgeTone { export function statusTone(status?: string | null): BadgeTone {
if (!status) return 'neutral'; if (!status) return 'neutral';
if (['활성', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green'; if (['활성', '유효토큰', '완료', '게시됨', '게시중', '실행 중', '배포됨', '성공', 'Running', 'ACTIVE'].includes(status)) return 'green';
if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber'; if (['주의', '예약됨', '예약', '대기', '준비중', '카나리', '만료', 'PENDING'].includes(status)) return 'amber';
if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red'; if (['잠김', '실패', '오프라인', '비활성', '종료', 'FAILED', 'Error', 'CrashLoopBackOff'].includes(status)) return 'red';
if (['런처'].includes(status)) return 'violet'; if (['런처'].includes(status)) return 'violet';