This commit is contained in:
+140
-78
@@ -2,11 +2,11 @@ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'rea
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
GitBranch,
|
||||||
Copy,
|
Copy,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Lock,
|
Lock,
|
||||||
@@ -285,6 +285,16 @@ type ApiAppRelease = {
|
|||||||
app?: ApiApp;
|
app?: ApiApp;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CommonAppPipeline = {
|
||||||
|
id: string;
|
||||||
|
repository: string;
|
||||||
|
message: string;
|
||||||
|
branch: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: number;
|
||||||
|
url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type ApiNotice = {
|
type ApiNotice = {
|
||||||
id: string;
|
id: string;
|
||||||
appKey: string;
|
appKey: string;
|
||||||
@@ -402,6 +412,19 @@ function getTone(status: string) {
|
|||||||
return statusTone[status as keyof typeof statusTone] ?? 'neutral';
|
return statusTone[status as keyof typeof statusTone] ?? 'neutral';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(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));
|
||||||
|
}
|
||||||
|
|
||||||
type NavGroup = (typeof navGroups)[number];
|
type NavGroup = (typeof navGroups)[number];
|
||||||
type NavItem = NavGroup['items'][number];
|
type NavItem = NavGroup['items'][number];
|
||||||
type NavChild = { label: string; view?: string };
|
type NavChild = { label: string; view?: string };
|
||||||
@@ -922,94 +945,118 @@ function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVer
|
|||||||
const activeVersion = versions.find((version) => version.isActive) ?? versions[0];
|
const activeVersion = versions.find((version) => version.isActive) ?? versions[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="xl:col-span-3">
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="런처 버전 관리"
|
title="런처 버전 관리"
|
||||||
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태를 관리합니다."
|
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태를 관리합니다."
|
||||||
action={<DashboardLinkButton onClick={onOpen} label="런처 버전 관리로 이동" />}
|
action={<DashboardLinkButton onClick={onOpen} label="런처 버전 관리로 이동" />}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-4 p-5 lg:grid-cols-[1fr_280px]">
|
<div className="p-5">
|
||||||
<div className="space-y-4">
|
{loading ? (
|
||||||
{loading ? (
|
<div className="min-h-48 rounded-md border border-border bg-muted/20 p-4">
|
||||||
<div className="min-h-48 rounded-md border border-border bg-muted/20 p-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="min-w-0 space-y-2">
|
||||||
<div className="min-w-0 space-y-2">
|
<p className="font-medium">Studio Launcher</p>
|
||||||
|
<SkeletonBlock className="h-3 w-56" />
|
||||||
|
</div>
|
||||||
|
<SkeletonBlock className="h-5 w-14 rounded-md" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid gap-3 text-sm">
|
||||||
|
<div className="rounded-md bg-background px-3 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">배포 파일</p>
|
||||||
|
<SkeletonBlock className="mt-3 h-4 w-52" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-background px-3 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
||||||
|
<SkeletonBlock className="mt-3 h-4 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-background px-3 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
|
||||||
|
<SkeletonBlock className="mt-3 h-4 w-4/5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : activeVersion ? (
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-medium">Studio Launcher</p>
|
<p className="font-medium">Studio Launcher</p>
|
||||||
<SkeletonBlock className="h-3 w-56" />
|
<Badge tone={activeVersion.isActive ? 'green' : 'neutral'}>{activeVersion.isActive ? '활성' : '비활성'}</Badge>
|
||||||
</div>
|
|
||||||
<SkeletonBlock className="h-5 w-14 rounded-md" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 grid gap-3 text-sm">
|
|
||||||
<div className="rounded-md bg-background px-3 py-3">
|
|
||||||
<p className="text-xs text-muted-foreground">배포 파일</p>
|
|
||||||
<SkeletonBlock className="mt-3 h-4 w-52" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md bg-background px-3 py-3">
|
|
||||||
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
|
||||||
<SkeletonBlock className="mt-3 h-4 w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md bg-background px-3 py-3">
|
|
||||||
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
|
|
||||||
<SkeletonBlock className="mt-3 h-4 w-4/5" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
최신 {activeVersion.version} · 최소 지원 {activeVersion.minimumVersion ?? '-'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : activeVersion ? (
|
<div className="mt-4 grid gap-3 text-sm">
|
||||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
<div className="rounded-md bg-background px-3 py-2">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<p className="text-xs text-muted-foreground">배포 파일</p>
|
||||||
<div className="min-w-0">
|
<p className="mt-1 break-all font-medium leading-5">{activeVersion.artifact}</p>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-medium">Studio Launcher</p>
|
|
||||||
<Badge tone={activeVersion.isActive ? 'green' : 'neutral'}>{activeVersion.isActive ? '활성' : '비활성'}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
최신 {activeVersion.version} · 최소 지원 {activeVersion.minimumVersion ?? '-'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid gap-3 text-sm">
|
<div className="rounded-md bg-background px-3 py-2">
|
||||||
<div className="rounded-md bg-background px-3 py-2">
|
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
||||||
<p className="text-xs text-muted-foreground">배포 파일</p>
|
<p className="mt-1 break-all font-medium leading-5">{activeVersion.downloadUrl}</p>
|
||||||
<p className="mt-1 break-all font-medium leading-5">{activeVersion.artifact}</p>
|
</div>
|
||||||
</div>
|
<div className="rounded-md bg-background px-3 py-2">
|
||||||
<div className="rounded-md bg-background px-3 py-2">
|
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
|
||||||
<p className="text-xs text-muted-foreground">다운로드 URL</p>
|
<p className="mt-1 text-sm leading-6 text-foreground">{activeVersion.releaseNotes || '릴리즈 노트가 없습니다.'}</p>
|
||||||
<p className="mt-1 break-all font-medium leading-5">{activeVersion.downloadUrl}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md bg-background px-3 py-2">
|
|
||||||
<p className="text-xs text-muted-foreground">릴리즈 노트</p>
|
|
||||||
<p className="mt-1 text-sm leading-6 text-foreground">{activeVersion.releaseNotes || '릴리즈 노트가 없습니다.'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex min-h-48 items-center justify-center rounded-md border border-border bg-muted/20 p-4 text-sm text-muted-foreground">
|
) : (
|
||||||
등록된 런처 버전이 없습니다.
|
<div className="flex min-h-48 items-center justify-center rounded-md border border-border bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||||
</div>
|
등록된 런처 버전이 없습니다.
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="rounded-md border border-border bg-background p-4">
|
</div>
|
||||||
<p className="text-sm font-medium">런처 상태 요약</p>
|
</Card>
|
||||||
<div className="mt-4 space-y-3">
|
);
|
||||||
{loading ? (
|
}
|
||||||
Array.from({ length: 3 }).map((_, index) => (
|
|
||||||
<div key={index} className="flex gap-3 rounded-md bg-muted/30 p-3">
|
function CommonAppsPanel({ pipelines, loading }: { pipelines: CommonAppPipeline[]; loading: boolean }) {
|
||||||
<SkeletonBlock className="mt-0.5 size-4 shrink-0 rounded-full" />
|
return (
|
||||||
<SkeletonBlock className="h-4 flex-1" />
|
<Card className="min-w-0 overflow-hidden">
|
||||||
</div>
|
<CardHeader title="공통앱" description="Woodpecker의 모든 저장소를 마지막 파이프라인 생성 순으로 표시합니다." />
|
||||||
))
|
<div className="max-h-[420px] overflow-y-auto">
|
||||||
) : [
|
{loading ? (
|
||||||
`${versions.length}개 런처 버전 등록`,
|
<div className="space-y-3 p-5">
|
||||||
activeVersion?.repository ? `소스 연결 ${activeVersion.repository}` : '런처 소스 연결 확인 필요',
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
activeVersion?.downloadUrl ? '다운로드 URL 등록됨' : '다운로드 URL 확인 필요'
|
<div key={index} className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
].map((label) => (
|
<SkeletonBlock className="h-4 w-48" />
|
||||||
<div key={label} className="flex gap-3 rounded-md bg-muted/30 p-3">
|
<SkeletonBlock className="mt-3 h-3 w-64" />
|
||||||
<Bell className="mt-0.5 size-4 text-muted-foreground" />
|
|
||||||
<p className="text-sm leading-5">{label}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : pipelines.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">
|
||||||
|
{pipelines.slice(0, 8).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={getTone(pipeline.status)}>{pipeline.status}</Badge>
|
||||||
|
<span>{pipeline.branch}</span>
|
||||||
|
<span>{formatRelativeTime(pipeline.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -2343,6 +2390,17 @@ function loadNotices() {
|
|||||||
.then((data) => data.notices ?? []);
|
.then((data) => data.notices ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadCommonAppPipelines() {
|
||||||
|
return apiFetch(`${apiBaseUrl}/woodpecker/repos`, { headers: authHeaders() })
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Woodpecker repositories request failed');
|
||||||
|
}
|
||||||
|
return response.json() as Promise<{ repos: CommonAppPipeline[] }>;
|
||||||
|
})
|
||||||
|
.then((data) => data.repos ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
function loadRuntimeNamespaceResources(namespace: string) {
|
function loadRuntimeNamespaceResources(namespace: string) {
|
||||||
return apiFetch(`${apiBaseUrl}/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`, { headers: authHeaders() })
|
return apiFetch(`${apiBaseUrl}/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`, { headers: authHeaders() })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -3203,6 +3261,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 [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
|
||||||
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
|
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
|
||||||
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
|
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
|
||||||
const [dashboardLoading, setDashboardLoading] = useState(true);
|
const [dashboardLoading, setDashboardLoading] = useState(true);
|
||||||
@@ -3212,12 +3271,13 @@ function DashboardPage({
|
|||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
setDashboardLoading(true);
|
setDashboardLoading(true);
|
||||||
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult] = await Promise.allSettled([
|
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([
|
||||||
loadLauncherVersions(),
|
loadLauncherVersions(),
|
||||||
loadApps(),
|
loadApps(),
|
||||||
loadAppReleases(),
|
loadAppReleases(),
|
||||||
loadUserSessions(),
|
loadUserSessions(),
|
||||||
loadNotices()
|
loadNotices(),
|
||||||
|
loadCommonAppPipelines()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : [];
|
const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : [];
|
||||||
@@ -3225,6 +3285,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 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([
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
@@ -3261,6 +3322,7 @@ function DashboardPage({
|
|||||||
setDashboardApps(nextApps);
|
setDashboardApps(nextApps);
|
||||||
setDashboardReleases(nextReleases);
|
setDashboardReleases(nextReleases);
|
||||||
setRecentSessions(nextSessions);
|
setRecentSessions(nextSessions);
|
||||||
|
setCommonAppPipelines(nextCommonApps);
|
||||||
setDashboardNotices(nextNotices);
|
setDashboardNotices(nextNotices);
|
||||||
setRuntimeResources(nextRuntimeResources);
|
setRuntimeResources(nextRuntimeResources);
|
||||||
setDashboardLoading(false);
|
setDashboardLoading(false);
|
||||||
@@ -3286,9 +3348,9 @@ function DashboardPage({
|
|||||||
|
|
||||||
<MetricCards apps={dashboardApps} sessions={recentSessions} runtimeResources={runtimeResources} loading={dashboardLoading} />
|
<MetricCards apps={dashboardApps} sessions={recentSessions} runtimeResources={runtimeResources} loading={dashboardLoading} />
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 xl:grid-cols-4">
|
<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} />
|
||||||
<SessionsPanel sessions={recentSessions.slice(0, 5)} loading={dashboardLoading} onMore={onUserSessionsOpen} />
|
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user