This commit is contained in:
@@ -1,19 +1,89 @@
|
||||
import { Activity, Boxes, FileKey, Users } from 'lucide-react';
|
||||
import { Card } from '../../components/ui';
|
||||
import { Activity, Boxes, ExternalLink, FileKey, GitBranch, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
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() {
|
||||
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 (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="플랫폼 운영"
|
||||
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],
|
||||
['접속 사용자', '1,284', Users],
|
||||
['라이센스 사용률', '72%', FileKey],
|
||||
['런타임 정상률', '96.4%', Activity]
|
||||
].map(([label, value, Icon]) => (
|
||||
@@ -26,7 +96,65 @@ export function DashboardPage() {
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user