connect tailscale node status through platform gateway
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-08 17:51:16 +09:00
parent 90a22c098c
commit 38fe8bf098
17 changed files with 655 additions and 649 deletions
+18 -12
View File
@@ -36,9 +36,12 @@ import { RuntimeLogsPage as RuntimeLogsApiPage } from './pages/runtime/RuntimeLo
import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage'; import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage';
import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage'; import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage';
import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage'; import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage';
import { LauncherCommonSettingsPage as LauncherCommonSettingsApiPage } from './pages/launcher/LauncherCommonSettingsPage';
import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage'; import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage';
import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage'; import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage';
import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage';
import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage';
import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage';
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
import { import {
AgentCommandsPage as AgentCommandsRestoredPage, AgentCommandsPage as AgentCommandsRestoredPage,
AgentInstallStatusPage as AgentInstallStatusRestoredPage, AgentInstallStatusPage as AgentInstallStatusRestoredPage,
@@ -49,19 +52,20 @@ import {
GatewayDevicesPage as GatewayDevicesRestoredPage, GatewayDevicesPage as GatewayDevicesRestoredPage,
GatewayStatusPage as GatewayStatusRestoredPage, GatewayStatusPage as GatewayStatusRestoredPage,
TailscaleAclPage as TailscaleAclRestoredPage, TailscaleAclPage as TailscaleAclRestoredPage,
TailscaleDiagnosticsPage as TailscaleDiagnosticsRestoredPage,
TailscaleRoutesPage as TailscaleRoutesRestoredPage, TailscaleRoutesPage as TailscaleRoutesRestoredPage,
TailscaleStatusPage as TailscaleStatusRestoredPage TailscaleStatusPage as TailscaleStatusRestoredPage
} from './pages/RestoredPages'; } from './pages/RestoredPages';
type ViewKey = type ViewKey =
| 'dashboard' | 'dashboard'
| 'commonSources'
| 'commonBuilds'
| 'commonComponents'
| 'launcherSources' | 'launcherSources'
| 'launcherBuilds' | 'launcherBuilds'
| 'launcherVersions' | 'launcherVersions'
| 'launcherComponents' | 'launcherComponents'
| 'launcherApps' | 'launcherApps'
| 'launcherCommonSettings'
| 'notices' | 'notices'
| 'appManagement' | 'appManagement'
| 'appRepositories' | 'appRepositories'
@@ -76,7 +80,6 @@ type ViewKey =
| 'tailscaleStatus' | 'tailscaleStatus'
| 'tailscaleRoutes' | 'tailscaleRoutes'
| 'tailscaleAcl' | 'tailscaleAcl'
| 'tailscaleDiagnostics'
| 'agentRegistration' | 'agentRegistration'
| 'agentInstallStatus' | 'agentInstallStatus'
| 'agentSshKeys' | 'agentSshKeys'
@@ -95,7 +98,6 @@ const viewByLabel: Record<string, ViewKey> = {
'버전 관리': 'launcherVersions', '버전 관리': 'launcherVersions',
구성요소: 'launcherComponents', 구성요소: 'launcherComponents',
'앱 연결': 'launcherApps', '앱 연결': 'launcherApps',
'공통 설정': 'launcherCommonSettings',
공지사항: 'notices', 공지사항: 'notices',
'앱 관리': 'appManagement', '앱 관리': 'appManagement',
'저장소 관리': 'appRepositories', '저장소 관리': 'appRepositories',
@@ -111,7 +113,6 @@ const viewByLabel: Record<string, ViewKey> = {
'노드 현황': 'tailscaleStatus', '노드 현황': 'tailscaleStatus',
라우트: 'tailscaleRoutes', 라우트: 'tailscaleRoutes',
ACL: 'tailscaleAcl', ACL: 'tailscaleAcl',
'연결 진단': 'tailscaleDiagnostics',
'장비 등록': 'agentRegistration', '장비 등록': 'agentRegistration',
'설치 상태': 'agentInstallStatus', '설치 상태': 'agentInstallStatus',
'SSH 키 배포': 'agentSshKeys', 'SSH 키 배포': 'agentSshKeys',
@@ -127,12 +128,14 @@ const viewByLabel: Record<string, ViewKey> = {
const pathByView: Record<ViewKey, string> = { const pathByView: Record<ViewKey, string> = {
dashboard: '/dashboard', dashboard: '/dashboard',
commonSources: '/common-apps/sources',
commonBuilds: '/common-apps/builds',
commonComponents: '/common-apps/components',
launcherSources: '/launcher/sources', launcherSources: '/launcher/sources',
launcherBuilds: '/launcher/builds', launcherBuilds: '/launcher/builds',
launcherVersions: '/launcher/versions', launcherVersions: '/launcher/versions',
launcherComponents: '/launcher/components', launcherComponents: '/launcher/components',
launcherApps: '/launcher/apps', launcherApps: '/launcher/apps',
launcherCommonSettings: '/launcher/common-settings',
notices: '/operations/notices', notices: '/operations/notices',
appManagement: '/applications/apps', appManagement: '/applications/apps',
appRepositories: '/applications/repositories', appRepositories: '/applications/repositories',
@@ -147,7 +150,6 @@ const pathByView: Record<ViewKey, string> = {
tailscaleStatus: '/tailscale/status', tailscaleStatus: '/tailscale/status',
tailscaleRoutes: '/tailscale/routes', tailscaleRoutes: '/tailscale/routes',
tailscaleAcl: '/tailscale/acl', tailscaleAcl: '/tailscale/acl',
tailscaleDiagnostics: '/tailscale/diagnostics',
agentRegistration: '/agents/registration', agentRegistration: '/agents/registration',
agentInstallStatus: '/agents/install-status', agentInstallStatus: '/agents/install-status',
agentSshKeys: '/agents/ssh-keys', agentSshKeys: '/agents/ssh-keys',
@@ -1388,6 +1390,7 @@ function DashboardPage({
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);
const loadingVisible = useMinimumLoading(dashboardLoading);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -1486,6 +1489,7 @@ function DashboardPage({
<ContainerPanel resources={runtimeResources} loading={dashboardLoading} onOpen={onRuntimeLogsOpen} /> <ContainerPanel resources={runtimeResources} loading={dashboardLoading} onOpen={onRuntimeLogsOpen} />
<NoticePanel notices={dashboardNotices} loading={dashboardLoading} onOpen={onNoticesOpen} /> <NoticePanel notices={dashboardNotices} loading={dashboardLoading} onOpen={onNoticesOpen} />
</div> </div>
<LoadingModal open={loadingVisible} />
</> </>
); );
} }
@@ -1538,6 +1542,12 @@ function App() {
const renderView = () => { const renderView = () => {
switch (activeView) { switch (activeView) {
case 'commonSources':
return <CommonAppSourcePage />;
case 'commonBuilds':
return <CommonAppBuildPage />;
case 'commonComponents':
return <CommonAppComponentsPage />;
case 'launcherVersions': case 'launcherVersions':
return <LauncherVersionsApiPage />; return <LauncherVersionsApiPage />;
case 'launcherComponents': case 'launcherComponents':
@@ -1548,8 +1558,6 @@ function App() {
return <LauncherBuildPage />; return <LauncherBuildPage />;
case 'launcherApps': case 'launcherApps':
return <LauncherAppsApiPage />; return <LauncherAppsApiPage />;
case 'launcherCommonSettings':
return <LauncherCommonSettingsApiPage />;
case 'notices': case 'notices':
return <NoticesApiPage />; return <NoticesApiPage />;
case 'adminManagement': case 'adminManagement':
@@ -1587,8 +1595,6 @@ function App() {
return <TailscaleRoutesRestoredPage />; return <TailscaleRoutesRestoredPage />;
case 'tailscaleAcl': case 'tailscaleAcl':
return <TailscaleAclRestoredPage />; return <TailscaleAclRestoredPage />;
case 'tailscaleDiagnostics':
return <TailscaleDiagnosticsRestoredPage />;
case 'agentRegistration': case 'agentRegistration':
return <AgentRegistrationRestoredPage />; return <AgentRegistrationRestoredPage />;
case 'agentInstallStatus': case 'agentInstallStatus':
+10 -126
View File
@@ -18,6 +18,15 @@ export const navGroups = [
label: '플랫폼', label: '플랫폼',
items: [ items: [
{ label: '대시보드', icon: LayoutDashboard, active: true }, { label: '대시보드', icon: LayoutDashboard, active: true },
{
label: '공통앱',
icon: ServerCog,
children: [
{ label: '소스 관리', view: 'commonSources' },
{ label: '빌드 관리', view: 'commonBuilds' },
{ label: '구성요소', view: 'commonComponents' }
]
},
{ {
label: '런처', label: '런처',
icon: Rocket, icon: Rocket,
@@ -26,7 +35,6 @@ export const navGroups = [
{ label: '빌드 관리', view: 'launcherBuilds' }, { label: '빌드 관리', view: 'launcherBuilds' },
{ label: '버전 관리' }, { label: '버전 관리' },
{ label: '구성요소', view: 'launcherComponents' }, { label: '구성요소', view: 'launcherComponents' },
{ label: '공통 설정' },
{ label: '앱 연결' } { label: '앱 연결' }
] ]
}, },
@@ -51,8 +59,7 @@ export const navGroups = [
children: [ children: [
{ label: '노드 현황' }, { label: '노드 현황' },
{ label: '라우트' }, { label: '라우트' },
{ label: 'ACL' }, { label: 'ACL' }
{ label: '연결 진단' }
] ]
}, },
{ {
@@ -118,129 +125,6 @@ export const launcherVersions = [
} }
]; ];
export const launcherCommonSettings = [
{
name: '공통 인증 서버',
key: 'AUTH_BASE_URL',
value: 'http://localhost:4100',
type: 'endpoint',
status: '활성',
description: 'atnmy-stdo-auth. 로그인, 토큰 갱신, 사용자 프로필, handoff 토큰을 제공하는 공통 인증 API'
},
{
name: '인증 DB',
key: 'AUTH_DATABASE_URL',
value: 'postgres://atnmy_auth:***@auth-postgres:5432/atnmy_auth',
type: 'database',
status: '활성',
description: 'users, refresh_tokens, handoff_tokens, licenses, user_app_roles, organizations 저장소'
},
{
name: '인증 Redis',
key: 'AUTH_REDIS_URL',
value: 'redis://auth-redis:6379',
type: 'cache',
status: '활성',
description: '이메일 인증 코드, 비밀번호 재설정 코드, 로그인 실패 제한 상태를 저장'
},
{
name: 'JWT Issuer',
key: 'AUTH_JWT_ISSUER',
value: 'atnmy-stdo-auth',
type: 'security',
status: '활성',
description: '공통 인증 서버가 발급하는 access token issuer'
},
{
name: 'Refresh Cookie',
key: 'AUTH_REFRESH_COOKIE_NAME',
value: 'atnmy_refresh',
type: 'security',
status: '활성',
description: 'refresh token 저장용 HttpOnly 쿠키 이름. SameSite=Lax, Path=/ 기준'
},
{
name: 'Handoff Token',
key: 'AUTH_HANDOFF_TOKEN_TTL_SECONDS',
value: '60',
type: 'security',
status: '활성',
description: '런처에서 앱으로 인증 상태를 넘길 때 사용하는 일회성 토큰 TTL'
},
{
name: 'CORS Origins',
key: 'AUTH_CORS_ORIGINS',
value: '5173,5174,5175,5176,4200,4300,4400',
type: 'cors',
status: '활성',
description: '런처와 앱 개발 포트에서 인증 API 호출을 허용하는 origin 목록'
},
{
name: '인증 메일',
key: 'AUTH_SMTP_HOST',
value: 'smtp.gmail.com:587',
type: 'email',
status: '활성',
description: '회원가입 이메일 인증 코드와 비밀번호 재설정 코드 발송'
},
{
name: '플랫폼 게이트웨이',
key: 'PLATFORM_GATEWAY_BASE_URL',
value: 'https://platform-gateway.autonomystudio.local',
type: 'endpoint',
status: '활성',
description: '런처가 클러스터, 테일스케일, 원격 실행 상태를 조회할 때 거치는 공통 게이트웨이'
},
{
name: '런처 API',
key: 'LAUNCHER_API_BASE_URL',
value: 'https://launcher-api.autonomystudio.local',
type: 'endpoint',
status: '활성',
description: '앱 목록, 런처 버전, 공지, 다운로드 정보를 제공하는 API'
},
{
name: '플랫폼 Gitea',
key: 'PLATFORM_GITEA_BASE_URL',
value: 'https://gitea.autonomystudio.local',
type: 'repository',
status: '활성',
description: '런처와 앱별 소스, 버전 태그, 릴리즈 산출물 기준 저장소. 앱 내부 업무용 저장소와 분리'
},
{
name: '플랫폼 Gitea 토큰',
key: 'PLATFORM_GITEA_TOKEN_REF',
value: 'secret/platform-gitea-readonly',
type: 'secret',
status: '활성',
description: '앱 버전별 소스 조회와 릴리즈 메타데이터 수집에 사용하는 읽기 권한 토큰'
},
{
name: '테일스케일 연결',
key: 'TAILSCALE_STATUS_PROVIDER',
value: 'platform-gateway/tailscale',
type: 'integration',
status: '활성',
description: '런처가 테일스케일 세부 설정을 직접 관리하지 않고 게이트웨이에서 연결 상태만 조회'
},
{
name: '파일 서버',
key: 'DOWNLOAD_BASE_URL',
value: 'https://testfile.autonomystudio.net/media/platform',
type: 'storage',
status: '활성',
description: '런처와 앱 설치 파일을 제공하는 다운로드 베이스 URL'
},
{
name: '라이센스 검증',
key: 'LICENSE_CHECK_MODE',
value: 'online',
type: 'policy',
status: '활성',
description: '런처 앱 실행 전 라이센스와 앱 권한 검증 방식'
}
];
export const authApiEndpoints = [ export const authApiEndpoints = [
{ group: '상태', method: 'GET', path: '/health', purpose: '서비스 및 Redis 상태 확인', auth: '없음' }, { group: '상태', method: 'GET', path: '/health', purpose: '서비스 및 Redis 상태 확인', auth: '없음' },
{ group: '인증', method: 'POST', path: '/auth/login', purpose: 'access token 발급 및 refresh cookie 설정', auth: '없음' }, { group: '인증', method: 'POST', path: '/auth/login', purpose: 'access token 발급 및 refresh cookie 설정', auth: '없음' },
-1
View File
@@ -42,7 +42,6 @@ export const navGroups: NavGroup[] = [
{ label: '빌드 관리', path: '/launcher/builds' }, { label: '빌드 관리', path: '/launcher/builds' },
{ label: '버전 관리', path: '/launcher/versions' }, { label: '버전 관리', path: '/launcher/versions' },
{ label: '구성요소', path: '/launcher/components' }, { label: '구성요소', path: '/launcher/components' },
{ label: '공통 설정', path: '/launcher/common-settings' },
{ label: '앱 연결', path: '/launcher/apps' } { label: '앱 연결', path: '/launcher/apps' }
] ]
}, },
-107
View File
@@ -5,10 +5,8 @@ import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/
import { import {
agentDevices, agentDevices,
alerts, alerts,
authApiEndpoints,
gatewayConnections, gatewayConnections,
k3sWorkloads, k3sWorkloads,
launcherCommonSettings,
launcherVersions, launcherVersions,
runtimeCommands, runtimeCommands,
tailscaleSettings tailscaleSettings
@@ -315,111 +313,6 @@ export function LauncherAppsPage() {
); );
} }
export function LauncherCommonSettingsPage() {
const [apiOpen, setApiOpen] = useState(false);
return (
<>
<PageTitle
eyebrow="런처 운영"
title="공통 설정"
description="런처에서 공통으로 사용하는 연결값과 기본 설정을 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setApiOpen(true)}>
<SlidersHorizontal className="size-4" />
공통 인증 API 보기
</Button>
<Button className="h-8 px-3 text-[13px]">
<Plus className="size-4" />
설정 추가
</Button>
</>
}
/>
<div className="grid gap-4 md:grid-cols-4">
{[
['설정 항목', `${launcherCommonSettings.length}개`],
['Endpoint', `${launcherCommonSettings.filter((item) => item.type === 'endpoint').length}개`],
['Database', `${launcherCommonSettings.filter((item) => item.type === 'database').length}개`],
['활성 상태', `${launcherCommonSettings.filter((item) => item.status === '활성').length}개`]
].map(([label, value]) => (
<Card className="p-5" key={label}>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-3 text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
<Card className="mt-4 min-w-0 overflow-hidden">
<CardHeader title="런처 연결 설정" description="여러 기능에서 함께 쓰는 주소, 인증, 저장소 관련 값을 정리합니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[1120px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="w-[280px] px-5 py-3 font-medium">설정</th>
<th className="px-5 py-3 font-medium">Key</th>
<th className="px-5 py-3 font-medium">Type</th>
<th className="px-5 py-3 font-medium">Value</th>
<th className="px-5 py-3 font-medium" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{launcherCommonSettings.map((item) => (
<tr className="hover:bg-muted/20" key={item.key}>
<td className="px-5 py-3">
<p className="inline-flex items-center gap-2 font-medium">
<span className={item.status === '활성' ? 'size-2 rounded-full bg-emerald-400' : 'size-2 rounded-full bg-muted-foreground'} />
{item.name}
</p>
</td>
<td className="px-5 py-3 font-medium">{item.key}</td>
<td className="px-5 py-3">{item.type}</td>
<td className="px-5 py-3 text-muted-foreground">{item.value}</td>
<td className="px-5 py-3 text-right">
<Button variant="ghost" className="size-8 px-0" aria-label="상세">
<MoreHorizontal className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Modal open={apiOpen} title="공통 인증 API" onClose={() => setApiOpen(false)} className="!max-w-[1280px]" footer={<Button variant="ghost" onClick={() => setApiOpen(false)}>닫기</Button>}>
<div className="max-h-[70vh] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[980px] text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">그룹</th>
<th className="px-5 py-3 font-medium">Method</th>
<th className="px-5 py-3 font-medium">Path</th>
<th className="px-5 py-3 font-medium">용도</th>
<th className="px-5 py-3 font-medium">권한</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{authApiEndpoints.map((endpoint) => (
<tr className="hover:bg-muted/20" key={`${endpoint.method}-${endpoint.path}`}>
<td className="px-5 py-3">{endpoint.group}</td>
<td className="px-5 py-3 font-semibold">{endpoint.method}</td>
<td className="px-5 py-3 font-medium">{endpoint.path}</td>
<td className="px-5 py-3 text-muted-foreground">{endpoint.purpose}</td>
<td className="px-5 py-3">
<Badge tone={endpoint.auth === '없음' ? 'neutral' : endpoint.auth === 'ADMIN' ? 'amber' : 'blue'}>{endpoint.auth}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Modal>
</>
);
}
export function TailscaleStatusPage() { export function TailscaleStatusPage() {
const [nodes, setNodes] = useState<TailscaleNode[]>([]); const [nodes, setNodes] = useState<TailscaleNode[]>([]);
const [source, setSource] = useState(''); const [source, setSource] = useState('');
@@ -447,7 +447,7 @@ export function SourceManagementPage() {
disabled={filteredSources.length === 0} disabled={filteredSources.length === 0}
> >
{filteredSources.map((source) => ( {filteredSources.map((source) => (
<option key={source.id} value={source.id}>{source.app?.name ?? '-'} / {source.name}</option> <option key={source.id} value={source.id}>{source.name}</option>
))} ))}
{filteredSources.length === 0 ? <option value="">연결된 소스 없음</option> : null} {filteredSources.length === 0 ? <option value="">연결된 소스 없음</option> : null}
</select> </select>
@@ -496,7 +496,7 @@ export function SourceManagementPage() {
<Card className="min-w-0 overflow-hidden"> <Card className="min-w-0 overflow-hidden">
<CardHeader <CardHeader
title="커밋 목록" title="커밋 목록"
description={selectedSource ? `${selectedSource.app?.name ?? '-'} / ${selectedSource.name} 기준 커밋 내역입니다.` : '소스를 선택하세요.'} description={selectedSource ? `${selectedSource.name} 기준 커밋 내역입니다.` : '소스를 선택하세요.'}
action={ action={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => ( {(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => (
@@ -0,0 +1,110 @@
import { RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Badge, Button, Card, CardHeader } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { cn } from '../../lib/utils';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { listCommonAppPipelines, type CommonAppPipeline } from './common-app-api';
import { formatPipelineTime, isActivePipelineStatus, pipelineStatusTone } from './common-app-utils';
export function CommonAppBuildPage() {
const { toast } = useToast();
const [pipelines, setPipelines] = useState<CommonAppPipeline[]>([]);
const [loading, setLoading] = useState(true);
const loadingVisible = useMinimumLoading(loading);
const runningCount = useMemo(
() => pipelines.filter((pipeline) => isActivePipelineStatus(pipeline.status)).length,
[pipelines]
);
const refresh = (silent = false) => {
if (!silent) setLoading(true);
listCommonAppPipelines()
.then(setPipelines)
.catch((error: Error) => {
if (!silent) toast({ type: 'error', title: '공통앱 빌드 조회 실패', message: error.message });
})
.finally(() => {
if (!silent) setLoading(false);
});
};
useEffect(() => {
refresh();
}, []);
useEffect(() => {
const timer = window.setInterval(() => refresh(true), runningCount > 0 ? 3000 : 10000);
return () => window.clearInterval(timer);
}, [runningCount]);
return (
<>
<PageTitle
eyebrow="공통앱"
title="빌드 관리"
description="공통앱 저장소의 Woodpecker 파이프라인 진행 상태입니다."
actions={
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => refresh()} disabled={loading}>
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
새로고침
</Button>
}
/>
<div className="mb-4 grid gap-4 md:grid-cols-3">
<Card className="p-5">
<p className="text-sm text-muted-foreground">진행 중</p>
<p className="mt-3 text-2xl font-semibold">{runningCount}</p>
</Card>
<Card className="p-5">
<p className="text-sm text-muted-foreground">연결 저장소</p>
<p className="mt-3 text-2xl font-semibold">{pipelines.length}</p>
</Card>
<Card className="p-5">
<p className="text-sm text-muted-foreground">갱신 주기</p>
<p className="mt-3 text-2xl font-semibold">{runningCount > 0 ? '3초' : '10초'}</p>
</Card>
</div>
<Card>
<CardHeader title="파이프라인" description="최근 파이프라인 생성 순으로 표시합니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] whitespace-nowrap text-left text-sm">
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">저장소</th>
<th className="px-5 py-3 font-medium">브랜치</th>
<th className="px-5 py-3 font-medium">상태</th>
<th className="px-5 py-3 font-medium">메시지</th>
<th className="px-5 py-3 font-medium">생성시각</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{pipelines.length === 0 && !loading ? (
<tr>
<td colSpan={5} className="px-5 py-10 text-center text-muted-foreground">표시할 파이프라인이 없습니다.</td>
</tr>
) : null}
{pipelines.map((pipeline) => (
<tr key={pipeline.id} className="hover:bg-muted/20">
<td className="px-5 py-4 font-medium">{pipeline.repository}</td>
<td className="px-5 py-4">{pipeline.branch || '-'}</td>
<td className="px-5 py-4">
<Badge tone={pipelineStatusTone(pipeline.status)}>{pipeline.status || '-'}</Badge>
</td>
<td className="max-w-[420px] truncate px-5 py-4">{pipeline.message || '-'}</td>
<td className="px-5 py-4 text-muted-foreground">{formatPipelineTime(pipeline.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<LoadingModal open={loadingVisible} />
</>
);
}
@@ -0,0 +1,329 @@
import { Plus, RotateCcw, Server, Settings, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { cn } from '../../lib/utils';
import type { RuntimeResource } from '../../types/runtime';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import {
loadLauncherRuntimeResources,
loadRuntimeWorkloadEnv,
saveRuntimeWorkloadEnv
} from '../launcher/launcher-api';
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
function statusTone(status?: string): 'green' | 'amber' | 'red' | 'neutral' {
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(status ?? '')) return 'green';
if (['Pending', 'ContainerCreating'].includes(status ?? '')) return 'amber';
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'red';
return 'neutral';
}
function apiKind(kind: string) {
return kind.toLowerCase();
}
function findResource(resources: RuntimeResource[], kind: string, name: string) {
return resources.find((resource) => resource.kind === kind && resource.name === name);
}
function serviceResource(resources: RuntimeResource[], name?: string) {
if (!name) return undefined;
return resources.find((resource) => resource.kind === 'Service' && resource.name === name);
}
function relatedResources(resources: RuntimeResource[], component: CommonAppComponent) {
const names = new Set([component.workloadName, component.serviceName].filter(Boolean));
return resources.filter((resource) => {
if (names.has(resource.name)) return true;
return resource.name.includes(component.workloadName);
});
}
type EnvVariable = {
key: string;
value: string;
isSecret: boolean;
editable: boolean;
source: string;
};
export function CommonAppComponentsPage() {
const { toast } = useToast();
const [resources, setResources] = useState<RuntimeResource[]>([]);
const [loading, setLoading] = useState(true);
const [envTarget, setEnvTarget] = useState<CommonAppComponent | null>(null);
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
const [envLoading, setEnvLoading] = useState(false);
const [envSaving, setEnvSaving] = useState(false);
const loadingVisible = useMinimumLoading(loading || envLoading);
const refresh = () => {
setLoading(true);
loadLauncherRuntimeResources(COMMON_APP_NAMESPACE)
.then((runtime) => setResources(runtime.resources ?? []))
.catch((error: Error) => {
toast({ type: 'error', title: '공통앱 구성요소 조회 실패', message: error.message });
})
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
}, []);
const openEnv = (component: CommonAppComponent) => {
setEnvTarget(component);
setEnvVariables([]);
setEnvLoading(true);
loadRuntimeWorkloadEnv(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
.then((result) => setEnvVariables(result.variables ?? []))
.catch((error: Error) => {
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
})
.finally(() => setEnvLoading(false));
};
const updateEnvVariable = (index: number, patch: Partial<Pick<EnvVariable, 'key' | 'value' | 'isSecret'>>) => {
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
variableIndex === index ? { ...variable, ...patch } : variable
)));
};
const removeEnvVariable = (index: number) => {
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
};
const saveEnv = () => {
if (!envTarget) return;
setEnvSaving(true);
saveRuntimeWorkloadEnv(
COMMON_APP_NAMESPACE,
apiKind(envTarget.kind),
envTarget.workloadName,
envVariables
.filter((variable) => variable.editable)
.map((variable) => ({ key: variable.key.trim(), value: variable.value, isSecret: variable.isSecret }))
.filter((variable) => variable.key)
)
.then((result) => {
setEnvVariables(result.variables ?? []);
toast({ type: 'success', title: '설정값 저장', message: `${envTarget.name} 설정값을 반영했습니다.` });
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: '설정값 저장 실패', message: error.message });
})
.finally(() => setEnvSaving(false));
};
return (
<>
<PageTitle
eyebrow="공통앱"
title="구성요소"
description="공통앱 워크로드 상태와 설정값을 관리합니다."
actions={
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
새로고침
</Button>
}
/>
<div className="grid gap-4">
{commonAppComponents.map((component) => {
const workload = findResource(resources, component.kind, component.workloadName);
const service = serviceResource(resources, component.serviceName);
const details = relatedResources(resources, component);
const status = workload?.status ?? 'NotFound';
return (
<Card key={component.key} className="overflow-hidden">
<CardHeader
title={component.name}
description={component.description}
action={<Badge tone={statusTone(status)}>{status}</Badge>}
/>
<div className="grid gap-5 p-5 xl:grid-cols-[minmax(280px,0.75fr)_minmax(0,1.25fr)]">
<div className="rounded-md border border-border bg-background p-4">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Server className="size-5" />
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{component.repository}</p>
<p className="mt-1 text-xs text-muted-foreground">{component.kind}/{component.workloadName}</p>
</div>
</div>
<div className="mt-4 grid gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Ready</p>
<p className="mt-1 font-medium">{workload?.ready ?? '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Service</p>
<p className="mt-1 font-medium">{service ? `${service.status} · ${service.detail}` : '-'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Image</p>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">{workload?.detail ?? '-'}</p>
</div>
</div>
<div className="mt-4">
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
<Settings className="size-4" />
설정값
</Button>
</div>
</div>
<div className="min-w-0 overflow-x-auto rounded-md border border-border">
<table className="w-full min-w-[760px] whitespace-nowrap text-left text-sm">
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium">Kind</th>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Ready</th>
<th className="px-4 py-3 font-medium">Age</th>
<th className="px-4 py-3 font-medium">Detail</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{details.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">관련 리소스가 없습니다.</td>
</tr>
) : null}
{details.map((resource) => (
<tr key={`${resource.kind}-${resource.name}`} className="hover:bg-muted/20">
<td className="px-4 py-3 font-semibold">{resource.kind}</td>
<td className="px-4 py-3 font-mono text-xs">{resource.name}</td>
<td className="px-4 py-3"><Badge tone={statusTone(resource.status)}>{resource.status}</Badge></td>
<td className="px-4 py-3">{resource.ready}</td>
<td className="px-4 py-3 text-muted-foreground">{resource.age}</td>
<td className="max-w-[360px] truncate px-4 py-3 font-mono text-xs text-muted-foreground">{resource.detail || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Card>
);
})}
</div>
<Modal
open={Boolean(envTarget)}
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
onClose={() => setEnvTarget(null)}
className="!max-w-4xl"
footer={
<>
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
{envSaving ? '저장 중' : '저장'}
</Button>
</>
}
>
<div className="space-y-4">
<div className="rounded-md border border-border">
<table className="w-full table-fixed text-left text-sm">
<colgroup>
<col className="w-[26%]" />
<col />
<col className="w-20" />
<col className="w-20" />
<col className="w-12" />
</colgroup>
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-3 font-medium">Key</th>
<th className="px-3 py-3 font-medium">Value</th>
<th className="px-3 py-3 font-medium">Secret</th>
<th className="px-3 py-3 font-medium">Source</th>
<th className="px-2 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{envLoading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">설정값을 불러오는 중입니다.</td>
</tr>
) : null}
{!envLoading && envVariables.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">등록된 설정값이 없습니다.</td>
</tr>
) : null}
{!envLoading && envVariables.map((variable, index) => (
<tr key={`${variable.key}-${index}`}>
<td className="min-w-0 px-3 py-2.5">
<input
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
value={variable.key}
disabled={!variable.editable}
onChange={(event) => updateEnvVariable(index, { key: event.target.value })}
/>
</td>
<td className="min-w-0 px-3 py-2.5">
<input
className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-60"
value={variable.value}
disabled={!variable.editable}
onChange={(event) => updateEnvVariable(index, { value: event.target.value })}
/>
</td>
<td className="px-3 py-2.5">
<label className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
className="size-4 accent-primary"
checked={variable.isSecret}
disabled={!variable.editable}
onChange={(event) => updateEnvVariable(index, { isSecret: event.target.checked })}
/>
Secret
</label>
</td>
<td className="px-3 py-2.5">
<Badge tone={variable.editable ? 'blue' : 'neutral'}>{variable.source}</Badge>
</td>
<td className="px-2 py-2.5">
<Button
variant="outline"
className="size-8 px-0"
disabled={!variable.editable}
onClick={() => removeEnvVariable(index)}
aria-label="설정값 삭제"
title="설정값 삭제"
>
<Trash2 className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button
variant="outline"
onClick={() => setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }])}
disabled={envLoading}
>
<Plus className="size-4" />
설정값 추가
</Button>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
}
@@ -0,0 +1,95 @@
import { ExternalLink, RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Badge, Button, Card, CardHeader } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { cn } from '../../lib/utils';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import { listCommonAppPipelines, type CommonAppPipeline } from './common-app-api';
import { commonAppComponents, formatPipelineTime, pipelineStatusTone } from './common-app-utils';
export function CommonAppSourcePage() {
const { toast } = useToast();
const [pipelines, setPipelines] = useState<CommonAppPipeline[]>([]);
const [loading, setLoading] = useState(true);
const loadingVisible = useMinimumLoading(loading);
const pipelineByRepo = useMemo(() => new Map(pipelines.map((pipeline) => [pipeline.repository, pipeline])), [pipelines]);
const refresh = () => {
setLoading(true);
listCommonAppPipelines()
.then(setPipelines)
.catch((error: Error) => {
toast({ type: 'error', title: '공통앱 소스 조회 실패', message: error.message });
})
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
}, []);
return (
<>
<PageTitle
eyebrow="공통앱"
title="소스 관리"
description="플랫폼 공통앱 저장소와 최근 파이프라인 기준 상태입니다."
actions={
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
새로고침
</Button>
}
/>
<Card>
<CardHeader title="저장소" description="Woodpecker에 연결된 공통앱 저장소입니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] whitespace-nowrap text-left text-sm">
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">저장소</th>
<th className="px-5 py-3 font-medium">구성요소</th>
<th className="px-5 py-3 font-medium">브랜치</th>
<th className="px-5 py-3 font-medium">최근 파이프라인</th>
<th className="px-5 py-3 font-medium">상태</th>
<th className="px-5 py-3 font-medium">생성시각</th>
<th className="px-5 py-3 font-medium">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{commonAppComponents.map((component) => {
const pipeline = pipelineByRepo.get(component.repository);
return (
<tr key={component.key} className="hover:bg-muted/20">
<td className="px-5 py-4 font-medium">{component.repository}</td>
<td className="px-5 py-4 text-muted-foreground">{component.name}</td>
<td className="px-5 py-4">{pipeline?.branch || '-'}</td>
<td className="max-w-[340px] truncate px-5 py-4">{pipeline?.message || '-'}</td>
<td className="px-5 py-4">
<Badge tone={pipelineStatusTone(pipeline?.status)}>{pipeline?.status || '-'}</Badge>
</td>
<td className="px-5 py-4 text-muted-foreground">{formatPipelineTime(pipeline?.createdAt)}</td>
<td className="px-5 py-4">
{pipeline?.url ? (
<Button variant="outline" className="size-8 px-0" aria-label="저장소 열기" onClick={() => window.open(pipeline.url ?? undefined, '_blank', 'noopener,noreferrer')}>
<ExternalLink className="size-4" />
</Button>
) : (
'-'
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<LoadingModal open={loadingVisible} />
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { apiRequest } from '../../lib/api';
export type CommonAppPipeline = {
id: string;
repository: string;
message: string;
branch: string;
status: string;
createdAt: number;
url?: string | null;
};
export function listCommonAppPipelines() {
return apiRequest<{ repos?: CommonAppPipeline[] }>('/woodpecker/repos')
.then((result) => result.repos ?? []);
}
+70
View File
@@ -0,0 +1,70 @@
export const COMMON_APP_NAMESPACE = 'studio-system';
export type CommonAppComponent = {
key: string;
name: string;
description: string;
repository: string;
kind: 'Deployment';
workloadName: string;
serviceName?: string;
};
export const commonAppComponents: CommonAppComponent[] = [
{
key: 'studio-admin-fe',
name: 'Admin FE',
description: '관리자 웹 콘솔 프론트엔드입니다.',
repository: 'studio/studio-admin-fe',
kind: 'Deployment',
workloadName: 'studio-admin-fe',
serviceName: 'studio-admin-fe'
},
{
key: 'studio-admin-be',
name: 'Admin BE',
description: '관리자 웹 콘솔 API 서버입니다.',
repository: 'studio/studio-admin-be',
kind: 'Deployment',
workloadName: 'studio-admin-be',
serviceName: 'studio-admin-be'
},
{
key: 'studio-auth-be',
name: 'Auth BE',
description: '공통 인증, 사용자, 라이센스 API 서버입니다.',
repository: 'studio/studio-auth-be',
kind: 'Deployment',
workloadName: 'studio-auth-be',
serviceName: 'studio-auth-be'
},
{
key: 'platform-gateway',
name: 'Platform Gateway',
description: 'Headscale, Agent, 클러스터 연동용 게이트웨이입니다.',
repository: 'studio/platform-gateway',
kind: 'Deployment',
workloadName: 'platform-gateway',
serviceName: 'platform-gateway'
}
];
export function formatPipelineTime(value?: number | null) {
if (!value) return '-';
const ms = value < 10_000_000_000 ? value * 1000 : value;
return new Date(ms).toLocaleString();
}
export function pipelineStatusTone(status?: string | null): 'green' | 'amber' | 'red' | 'blue' | 'neutral' {
const value = (status ?? '').toLowerCase();
if (['success', 'succeeded', 'passed'].includes(value)) return 'green';
if (['running', 'started'].includes(value)) return 'blue';
if (['pending', 'queued', 'blocked', 'created'].includes(value)) return 'amber';
if (['failure', 'failed', 'error', 'killed', 'declined'].includes(value)) return 'red';
return 'neutral';
}
export function isActivePipelineStatus(status?: string | null) {
const value = (status ?? '').toLowerCase();
return ['running', 'started', 'pending', 'queued', 'blocked', 'created'].includes(value);
}
+1 -1
View File
@@ -174,7 +174,7 @@ export function LauncherAppsPage() {
return ( return (
<> <>
<PageTitle <PageTitle
eyebrow="런처 운영" eyebrow="런처"
title="앱 연결" title="앱 연결"
description="등록된 앱 중 런처에 노출할 앱과 표시 순서를 관리합니다." description="등록된 앱 중 런처에 노출할 앱과 표시 순서를 관리합니다."
actions={ actions={
+1 -1
View File
@@ -268,7 +268,7 @@ export function LauncherBuildPage() {
return ( return (
<> <>
<PageTitle <PageTitle
eyebrow="런처 운영" eyebrow="런처"
title="빌드 관리" title="빌드 관리"
description="런처 클라이언트 설치 파일과 백엔드 컨테이너 이미지를 구성요소별로 빌드합니다." description="런처 클라이언트 설치 파일과 백엔드 컨테이너 이미지를 구성요소별로 빌드합니다."
actions={ actions={
@@ -1,341 +0,0 @@
import { ChevronLeft, ChevronRight, MoreHorizontal, Plus, SlidersHorizontal, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { authApiEndpoints } from '../../data/mock';
import { PageTitle } from '../../layout/AppLayout';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import {
createLauncherCommonSetting,
deleteLauncherCommonSetting,
listLauncherCommonSettings,
updateLauncherCommonSetting,
type LauncherCommonSetting
} from './launcher-api';
type FormState = {
key: string;
name: string;
type: string;
value: string;
description: string;
isActive: boolean;
isSecret: boolean;
};
const emptyForm: FormState = {
key: '',
name: '',
type: 'endpoint',
value: '',
description: '',
isActive: true,
isSecret: false
};
const settingsPageSize = 10;
function maskValue(setting: LauncherCommonSetting) {
if (!setting.isSecret) return setting.value;
if (!setting.value) return '-';
return '******';
}
function toForm(setting?: LauncherCommonSetting): FormState {
if (!setting) return emptyForm;
return {
key: setting.key,
name: setting.name,
type: setting.type,
value: setting.value,
description: setting.description ?? '',
isActive: setting.isActive,
isSecret: setting.isSecret
};
}
export function LauncherCommonSettingsPage() {
const { toast } = useToast();
const [apiOpen, setApiOpen] = useState(false);
const [settings, setSettings] = useState<LauncherCommonSetting[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<LauncherCommonSetting | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [page, setPage] = useState(1);
const loadingVisible = useMinimumLoading(loading);
const totalPages = Math.max(1, Math.ceil(settings.length / settingsPageSize));
const pagedSettings = settings.slice((page - 1) * settingsPageSize, page * settingsPageSize);
const summary = useMemo(() => ({
total: settings.length,
endpoint: settings.filter((item) => item.type === 'endpoint').length,
database: settings.filter((item) => item.type === 'database').length,
active: settings.filter((item) => item.isActive).length
}), [settings]);
const refresh = () => {
setLoading(true);
listLauncherCommonSettings()
.then(setSettings)
.catch((error: Error) => {
toast({ type: 'error', title: '공통 설정 조회 실패', message: error.message });
})
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
}, []);
useEffect(() => {
setPage((currentPage) => Math.min(currentPage, totalPages));
}, [totalPages]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setModalOpen(true);
};
const openEdit = (setting: LauncherCommonSetting) => {
setEditing(setting);
setForm(toForm(setting));
setModalOpen(true);
};
const submit = (event: FormEvent) => {
event.preventDefault();
setSaving(true);
const payload = {
key: form.key.trim(),
name: form.name.trim(),
type: form.type.trim(),
value: form.value.trim(),
description: form.description.trim(),
isActive: form.isActive,
isSecret: form.isSecret
};
const request = editing
? updateLauncherCommonSetting(editing.id, payload)
: createLauncherCommonSetting(payload);
request
.then(() => {
toast({ type: 'success', message: editing ? '설정을 수정했습니다.' : '설정을 추가했습니다.' });
setModalOpen(false);
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: editing ? '설정 수정 실패' : '설정 추가 실패', message: error.message });
})
.finally(() => setSaving(false));
};
const remove = () => {
if (!editing) return;
setSaving(true);
deleteLauncherCommonSetting(editing.id)
.then(() => {
toast({ type: 'success', message: '설정을 삭제했습니다.' });
setModalOpen(false);
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: '설정 삭제 실패', message: error.message });
})
.finally(() => setSaving(false));
};
return (
<>
<PageTitle
eyebrow="런처 운영"
title="공통 설정"
description="런처에서 공통으로 사용하는 연결값과 기본 설정을 관리합니다."
actions={
<>
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={() => setApiOpen(true)}>
<SlidersHorizontal className="size-4" />
공통 인증 API 보기
</Button>
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
<Plus className="size-4" />
설정 추가
</Button>
</>
}
/>
<div className="grid gap-4 md:grid-cols-4">
{[
['설정 항목', `${summary.total}개`],
['Endpoint', `${summary.endpoint}개`],
['Database', `${summary.database}개`],
['활성 상태', `${summary.active}개`]
].map(([label, value]) => (
<Card className="p-5" key={label}>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-3 text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
<Card className="mt-4 min-w-0 overflow-hidden">
<CardHeader title="런처 연결 설정" description="여러 기능에서 함께 쓰는 주소, 인증, 저장소 관련 값을 정리합니다." />
<div className="overflow-x-auto">
<table className="w-full min-w-[1120px] text-left text-sm">
<thead className="border-b border-border text-xs text-muted-foreground">
<tr>
<th className="w-[280px] px-5 py-3 font-medium">설정</th>
<th className="px-5 py-3 font-medium">Key</th>
<th className="px-5 py-3 font-medium">Type</th>
<th className="px-5 py-3 font-medium">Value</th>
<th className="px-5 py-3 text-right font-medium">관리</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{!loading && settings.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-12 text-center text-sm text-muted-foreground">등록된 설정이 없습니다.</td>
</tr>
) : null}
{pagedSettings.map((setting) => (
<tr className="hover:bg-muted/20" key={setting.id}>
<td className="px-5 py-3">
<p className="inline-flex items-center gap-2 font-medium">
<span className={setting.isActive ? 'size-2 rounded-full bg-emerald-400' : 'size-2 rounded-full bg-muted-foreground'} />
{setting.name}
</p>
{setting.description ? <p className="mt-1 truncate text-xs text-muted-foreground">{setting.description}</p> : null}
</td>
<td className="px-5 py-3 font-medium">{setting.key}</td>
<td className="px-5 py-3">{setting.type}</td>
<td className="px-5 py-3 text-muted-foreground">{maskValue(setting)}</td>
<td className="px-5 py-3 text-right">
<Button variant="ghost" className="size-8 px-0" aria-label={`${setting.name} 관리`} onClick={() => openEdit(setting)}>
<MoreHorizontal className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
<span className="text-xs text-muted-foreground">
{loading ? '불러오는 중' : `총 ${settings.length}건 · ${page} / ${totalPages}`}
</span>
<div className="flex gap-2">
<Button
variant="outline"
className="h-8 px-3 text-[13px]"
disabled={page <= 1}
onClick={() => setPage((currentPage) => Math.max(1, currentPage - 1))}
>
<ChevronLeft className="size-4" />
이전
</Button>
<Button
variant="outline"
className="h-8 px-3 text-[13px]"
disabled={page >= totalPages}
onClick={() => setPage((currentPage) => Math.min(totalPages, currentPage + 1))}
>
다음
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</Card>
<Modal
open={modalOpen}
title={editing ? '설정 수정' : '설정 추가'}
onClose={() => setModalOpen(false)}
className="!max-w-2xl"
footer={
<>
{editing ? (
<Button variant="outline" className="border-rose-500/40 text-rose-300 hover:bg-rose-500/10" onClick={remove} disabled={saving}>
<Trash2 className="size-4" />
삭제
</Button>
) : null}
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
<Button onClick={submit as unknown as () => void} disabled={saving || !form.key.trim() || !form.name.trim() || !form.type.trim()}>
{saving ? '저장 중' : '저장'}
</Button>
</>
}
>
<form className="grid gap-4" onSubmit={submit}>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">설정명</span>
<input 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={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Type</span>
<input 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={form.type} onChange={(event) => setForm((prev) => ({ ...prev, type: event.target.value }))} />
</label>
</div>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Key</span>
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.key} onChange={(event) => setForm((prev) => ({ ...prev, key: event.target.value }))} />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">Value</span>
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.value} onChange={(event) => setForm((prev) => ({ ...prev, value: event.target.value }))} />
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">설명</span>
<textarea className="mt-2 min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.description} onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))} />
</label>
<div className="flex flex-wrap gap-4">
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((prev) => ({ ...prev, isActive: event.target.checked }))} />
활성
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isSecret} onChange={(event) => setForm((prev) => ({ ...prev, isSecret: event.target.checked }))} />
민감값
</label>
</div>
</form>
</Modal>
<Modal open={apiOpen} title="공통 인증 API" onClose={() => setApiOpen(false)} className="!max-w-[1280px]" footer={<Button variant="ghost" onClick={() => setApiOpen(false)}>닫기</Button>}>
<div className="max-h-[70vh] overflow-x-auto overflow-y-auto">
<table className="w-full min-w-[980px] text-left text-sm">
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
<tr>
<th className="px-5 py-3 font-medium">그룹</th>
<th className="px-5 py-3 font-medium">Method</th>
<th className="px-5 py-3 font-medium">Path</th>
<th className="px-5 py-3 font-medium">용도</th>
<th className="px-5 py-3 font-medium">권한</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{authApiEndpoints.map((endpoint) => (
<tr className="hover:bg-muted/20" key={`${endpoint.method}-${endpoint.path}`}>
<td className="px-5 py-3">{endpoint.group}</td>
<td className="px-5 py-3 font-semibold">{endpoint.method}</td>
<td className="px-5 py-3 font-medium">{endpoint.path}</td>
<td className="px-5 py-3 text-muted-foreground">{endpoint.purpose}</td>
<td className="px-5 py-3">
<Badge tone={endpoint.auth === '없음' ? 'neutral' : endpoint.auth === 'ADMIN' ? 'amber' : 'blue'}>{endpoint.auth}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
}
@@ -228,7 +228,7 @@ export function LauncherComponentsPage() {
return ( return (
<> <>
<PageTitle <PageTitle
eyebrow="런처 운영" eyebrow="런처"
title="구성요소" title="구성요소"
description="런처 클라이언트, 백엔드, DB의 배포 기준과 런타임 상태를 한 화면에서 확인합니다." description="런처 클라이언트, 백엔드, DB의 배포 기준과 런타임 상태를 한 화면에서 확인합니다."
actions={ actions={
+1 -1
View File
@@ -537,7 +537,7 @@ export function LauncherSourcePage() {
return ( return (
<> <>
<PageTitle <PageTitle
eyebrow="런처 운영" eyebrow="런처"
title="소스 관리" title="소스 관리"
description="런처 클라이언트와 백엔드 소스 저장소를 구성요소별로 연결합니다." description="런처 클라이언트와 백엔드 소스 저장소를 구성요소별로 연결합니다."
actions={ actions={
+1 -1
View File
@@ -144,7 +144,7 @@ export function LauncherVersionsPage() {
return ( return (
<> <>
<PageTitle <PageTitle
eyebrow="런처 운영" eyebrow="런처"
title="버전 관리" title="버전 관리"
description="스튜디오 메인 런처 클라이언트 릴리즈와 백엔드 배포를 관리합니다." description="스튜디오 메인 런처 클라이언트 릴리즈와 백엔드 배포를 관리합니다."
actions={ actions={
-55
View File
@@ -129,19 +129,6 @@ export type LauncherAppLink = {
release?: AppRelease | null; release?: AppRelease | null;
}; };
export type LauncherCommonSetting = {
id: string;
key: string;
name: string;
type: string;
value: string;
description?: string | null;
isActive: boolean;
isSecret: boolean;
createdAt: string;
updatedAt?: string;
};
export type LauncherUnlinkedApp = AppWithComponents & { export type LauncherUnlinkedApp = AppWithComponents & {
releases?: AppRelease[]; releases?: AppRelease[];
}; };
@@ -380,48 +367,6 @@ export function deleteLauncherAppLink(id: string) {
return apiRequest<{ deleted: boolean }>(`/launcher/app-links/${id}`, { method: 'DELETE' }); return apiRequest<{ deleted: boolean }>(`/launcher/app-links/${id}`, { method: 'DELETE' });
} }
export function listLauncherCommonSettings() {
return apiRequest<LauncherCommonSetting[]>('/launcher/common-settings');
}
export function createLauncherCommonSetting(input: {
key: string;
name: string;
type: string;
value: string;
description?: string;
isActive?: boolean;
isSecret?: boolean;
}) {
return apiRequest<{ setting: LauncherCommonSetting }>('/launcher/common-settings', {
method: 'POST',
body: JSON.stringify(input)
});
}
export function updateLauncherCommonSetting(
id: string,
input: Partial<{
key: string;
name: string;
type: string;
value: string;
description: string;
isActive: boolean;
isSecret: boolean;
}>
) {
return apiRequest<{ setting: LauncherCommonSetting }>(`/launcher/common-settings/${id}`, {
method: 'PATCH',
body: JSON.stringify(input)
});
}
export function deleteLauncherCommonSetting(id: string) {
return apiRequest<{ deleted: boolean }>(`/launcher/common-settings/${id}`, { method: 'DELETE' });
}
export type RuntimeWorkloadEnvVariable = { export type RuntimeWorkloadEnvVariable = {
key: string; key: string;
value: string; value: string;