merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-07 09:05:21 +09:00
2 changed files with 51 additions and 9 deletions
+50 -8
View File
@@ -285,6 +285,15 @@ type ApiAppRelease = {
app?: ApiApp; app?: ApiApp;
}; };
type LicensePlanSummary = {
id: string;
code: string;
name: string;
assignedCount: number;
activeUserCount: number;
expiringSoonCount: number;
};
type CommonAppPipeline = { type CommonAppPipeline = {
id: string; id: string;
repository: string; repository: string;
@@ -385,6 +394,18 @@ type AdminRow = {
}; };
const statusTone = { const statusTone = {
success: 'green',
succeeded: 'green',
done: 'green',
running: 'blue',
started: 'blue',
pending: 'amber',
blocked: 'amber',
created: 'amber',
failure: 'red',
failed: 'red',
error: 'red',
killed: 'red',
활성: 'green', 활성: 'green',
'실행 중': 'green', '실행 중': 'green',
게시됨: 'green', 게시됨: 'green',
@@ -869,16 +890,22 @@ function SkeletonBlock({ className }: { className?: string }) {
function MetricCards({ function MetricCards({
apps: dashboardApps, apps: dashboardApps,
sessions, sessions,
licensePlans,
runtimeResources, runtimeResources,
loading loading
}: { }: {
apps: ApiApp[]; apps: ApiApp[];
sessions: UserSessionRow[]; sessions: UserSessionRow[];
licensePlans: LicensePlanSummary[];
runtimeResources: DashboardRuntimeResource[]; runtimeResources: DashboardRuntimeResource[];
loading: boolean; loading: boolean;
}) { }) {
const activeApps = dashboardApps.filter((app) => isActiveStatus(app.status)).length; const activeApps = dashboardApps.filter((app) => isActiveStatus(app.status)).length;
const activeSessions = sessions.filter((session) => session.state === '활성').length; const activeSessions = sessions.filter((session) => session.state === '활성').length;
const assignedLicenses = licensePlans.reduce((sum, plan) => sum + plan.assignedCount, 0);
const activeLicenseUsers = licensePlans.reduce((sum, plan) => sum + plan.activeUserCount, 0);
const expiringSoonLicenses = licensePlans.reduce((sum, plan) => sum + plan.expiringSoonCount, 0);
const licenseUsageRate = assignedLicenses ? Math.round((activeLicenseUsers / assignedLicenses) * 100) : null;
const healthyRuntimeCount = runtimeResources.filter(isRuntimeResourceHealthy).length; const healthyRuntimeCount = runtimeResources.filter(isRuntimeResourceHealthy).length;
const runtimeRate = runtimeResources.length ? Math.round((healthyRuntimeCount / runtimeResources.length) * 100) : null; const runtimeRate = runtimeResources.length ? Math.round((healthyRuntimeCount / runtimeResources.length) * 100) : null;
const cards = [ const cards = [
@@ -898,10 +925,10 @@ function MetricCards({
}, },
{ {
label: '라이센스 사용률', label: '라이센스 사용률',
value: '-', value: loading || licenseUsageRate === null ? '-' : `${licenseUsageRate}%`,
delta: '연동 필요', delta: assignedLicenses ? `${activeLicenseUsers}/${assignedLicenses}` : '할당 없음',
hint: '라이센스 백엔드 집계 API 필요', hint: expiringSoonLicenses ? `만료 예정 ${expiringSoonLicenses}명` : '활성 라이센스 사용자 기준',
tone: 'neutral' as const tone: licenseUsageRate !== null && licenseUsageRate >= 90 ? 'amber' as const : 'green' as const
}, },
{ {
label: '런타임 정상률', label: '런타임 정상률',
@@ -1017,7 +1044,7 @@ function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVer
function CommonAppsPanel({ pipelines, loading }: { pipelines: CommonAppPipeline[]; loading: boolean }) { function CommonAppsPanel({ pipelines, loading }: { pipelines: CommonAppPipeline[]; loading: boolean }) {
return ( return (
<Card className="min-w-0 overflow-hidden"> <Card className="min-w-0 overflow-hidden">
<CardHeader title="공통앱" description="Woodpecker의 모든 저장소를 마지막 파이프라인 생성 순으로 표시합니다." /> <CardHeader title="공통앱" description="공통앱의 최근 빌드 상태를 확인합니다." />
<div className="max-h-[420px] overflow-y-auto"> <div className="max-h-[420px] overflow-y-auto">
{loading ? ( {loading ? (
<div className="space-y-3 p-5"> <div className="space-y-3 p-5">
@@ -2390,6 +2417,17 @@ function loadNotices() {
.then((data) => data.notices ?? []); .then((data) => data.notices ?? []);
} }
function loadLicensePlans() {
return apiFetch(`${apiBaseUrl}/licenses/plans`, { headers: authHeaders() })
.then((response) => {
if (!response.ok) {
throw new Error('License plans request failed');
}
return response.json() as Promise<{ plans?: LicensePlanSummary[] }>;
})
.then((data) => data.plans ?? []);
}
function loadCommonAppPipelines() { function loadCommonAppPipelines() {
return apiFetch(`${apiBaseUrl}/woodpecker/repos`, { headers: authHeaders() }) return apiFetch(`${apiBaseUrl}/woodpecker/repos`, { headers: authHeaders() })
.then((response) => { .then((response) => {
@@ -3261,6 +3299,7 @@ function DashboardPage({
const [dashboardApps, setDashboardApps] = useState<ApiApp[]>([]); const [dashboardApps, setDashboardApps] = useState<ApiApp[]>([]);
const [dashboardReleases, setDashboardReleases] = useState<ApiAppRelease[]>([]); const [dashboardReleases, setDashboardReleases] = useState<ApiAppRelease[]>([]);
const [recentSessions, setRecentSessions] = useState<UserSessionRow[]>([]); const [recentSessions, setRecentSessions] = useState<UserSessionRow[]>([]);
const [licensePlansSummary, setLicensePlansSummary] = useState<LicensePlanSummary[]>([]);
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]); const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]); const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]); const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
@@ -3271,12 +3310,13 @@ function DashboardPage({
async function loadDashboard() { async function loadDashboard() {
setDashboardLoading(true); setDashboardLoading(true);
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([ const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, licensePlansResult, commonAppsResult] = await Promise.allSettled([
loadLauncherVersions(), loadLauncherVersions(),
loadApps(), loadApps(),
loadAppReleases(), loadAppReleases(),
loadUserSessions(), loadUserSessions(),
loadNotices(), loadNotices(),
loadLicensePlans(),
loadCommonAppPipelines() loadCommonAppPipelines()
]); ]);
@@ -3285,6 +3325,7 @@ function DashboardPage({
const nextReleases = releasesResult.status === 'fulfilled' ? releasesResult.value : []; const nextReleases = releasesResult.status === 'fulfilled' ? releasesResult.value : [];
const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : []; const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : []; const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : [];
const nextLicensePlans = licensePlansResult.status === 'fulfilled' ? licensePlansResult.value : [];
const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : []; const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
const namespaceApps = nextApps.filter((app) => app.namespace); const namespaceApps = nextApps.filter((app) => app.namespace);
const [runtimeResults, metricResults] = await Promise.all([ const [runtimeResults, metricResults] = await Promise.all([
@@ -3322,6 +3363,7 @@ function DashboardPage({
setDashboardApps(nextApps); setDashboardApps(nextApps);
setDashboardReleases(nextReleases); setDashboardReleases(nextReleases);
setRecentSessions(nextSessions); setRecentSessions(nextSessions);
setLicensePlansSummary(nextLicensePlans);
setCommonAppPipelines(nextCommonApps); setCommonAppPipelines(nextCommonApps);
setDashboardNotices(nextNotices); setDashboardNotices(nextNotices);
setRuntimeResources(nextRuntimeResources); setRuntimeResources(nextRuntimeResources);
@@ -3343,10 +3385,10 @@ function DashboardPage({
<PageTitle <PageTitle
eyebrow="플랫폼 운영" eyebrow="플랫폼 운영"
title="대시보드" title="대시보드"
description="플랫폼 운영에 필요한 주요 현황과 작업 흐름을 한눈에 확인합니다." description="플랫폼의 현재 상태와 운영 흐름을 빠르게 파악합니다."
/> />
<MetricCards apps={dashboardApps} sessions={recentSessions} runtimeResources={runtimeResources} loading={dashboardLoading} /> <MetricCards apps={dashboardApps} sessions={recentSessions} licensePlans={licensePlansSummary} runtimeResources={runtimeResources} loading={dashboardLoading} />
<div className="mt-4 grid gap-4 xl:grid-cols-2"> <div className="mt-4 grid gap-4 xl:grid-cols-2">
<LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} /> <LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} />
+1 -1
View File
@@ -111,7 +111,7 @@ export function DashboardPage() {
<Card className="min-w-0 overflow-hidden"> <Card className="min-w-0 overflow-hidden">
<CardHeader <CardHeader
title="공통앱" title="공통앱"
description="Woodpecker의 모든 저장소를 마지막 파이프라인 생성 순으로 표시합니다." description="공통앱의 최근 빌드 상태를 확인합니다."
action={ action={
<Button variant="outline" className="size-8 px-0" onClick={refreshCommonApps} disabled={commonAppsLoading} title="새로고침" aria-label="새로고침"> <Button variant="outline" className="size-8 px-0" onClick={refreshCommonApps} disabled={commonAppsLoading} title="새로고침" aria-label="새로고침">
<RefreshCw className={cn('size-4', commonAppsLoading && 'animate-spin')} /> <RefreshCw className={cn('size-4', commonAppsLoading && 'animate-spin')} />