From 38fe8bf09899db505c0431094db12c9cc917e876 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 8 Jul 2026 17:51:16 +0900 Subject: [PATCH] connect tailscale node status through platform gateway --- src/App.tsx | 30 +- src/data/mock.ts | 136 +------ src/layout/navigation.ts | 1 - src/pages/RestoredPages.tsx | 107 ------ .../applications/SourceManagementPage.tsx | 4 +- src/pages/common-apps/CommonAppBuildPage.tsx | 110 ++++++ .../common-apps/CommonAppComponentsPage.tsx | 329 +++++++++++++++++ src/pages/common-apps/CommonAppSourcePage.tsx | 95 +++++ src/pages/common-apps/common-app-api.ts | 16 + src/pages/common-apps/common-app-utils.ts | 70 ++++ src/pages/launcher/LauncherAppsPage.tsx | 2 +- src/pages/launcher/LauncherBuildPage.tsx | 2 +- .../launcher/LauncherCommonSettingsPage.tsx | 341 ------------------ src/pages/launcher/LauncherComponentsPage.tsx | 2 +- src/pages/launcher/LauncherSourcePage.tsx | 2 +- src/pages/launcher/LauncherVersionsPage.tsx | 2 +- src/pages/launcher/launcher-api.ts | 55 --- 17 files changed, 655 insertions(+), 649 deletions(-) create mode 100644 src/pages/common-apps/CommonAppBuildPage.tsx create mode 100644 src/pages/common-apps/CommonAppComponentsPage.tsx create mode 100644 src/pages/common-apps/CommonAppSourcePage.tsx create mode 100644 src/pages/common-apps/common-app-api.ts create mode 100644 src/pages/common-apps/common-app-utils.ts delete mode 100644 src/pages/launcher/LauncherCommonSettingsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 8ba211b..225c509 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,9 +36,12 @@ import { RuntimeLogsPage as RuntimeLogsApiPage } from './pages/runtime/RuntimeLo import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage'; import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage'; import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage'; -import { LauncherCommonSettingsPage as LauncherCommonSettingsApiPage } from './pages/launcher/LauncherCommonSettingsPage'; import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage'; 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 { AgentCommandsPage as AgentCommandsRestoredPage, AgentInstallStatusPage as AgentInstallStatusRestoredPage, @@ -49,19 +52,20 @@ import { GatewayDevicesPage as GatewayDevicesRestoredPage, GatewayStatusPage as GatewayStatusRestoredPage, TailscaleAclPage as TailscaleAclRestoredPage, - TailscaleDiagnosticsPage as TailscaleDiagnosticsRestoredPage, TailscaleRoutesPage as TailscaleRoutesRestoredPage, TailscaleStatusPage as TailscaleStatusRestoredPage } from './pages/RestoredPages'; type ViewKey = | 'dashboard' + | 'commonSources' + | 'commonBuilds' + | 'commonComponents' | 'launcherSources' | 'launcherBuilds' | 'launcherVersions' | 'launcherComponents' | 'launcherApps' - | 'launcherCommonSettings' | 'notices' | 'appManagement' | 'appRepositories' @@ -76,7 +80,6 @@ type ViewKey = | 'tailscaleStatus' | 'tailscaleRoutes' | 'tailscaleAcl' - | 'tailscaleDiagnostics' | 'agentRegistration' | 'agentInstallStatus' | 'agentSshKeys' @@ -95,7 +98,6 @@ const viewByLabel: Record = { '버전 관리': 'launcherVersions', 구성요소: 'launcherComponents', '앱 연결': 'launcherApps', - '공통 설정': 'launcherCommonSettings', 공지사항: 'notices', '앱 관리': 'appManagement', '저장소 관리': 'appRepositories', @@ -111,7 +113,6 @@ const viewByLabel: Record = { '노드 현황': 'tailscaleStatus', 라우트: 'tailscaleRoutes', ACL: 'tailscaleAcl', - '연결 진단': 'tailscaleDiagnostics', '장비 등록': 'agentRegistration', '설치 상태': 'agentInstallStatus', 'SSH 키 배포': 'agentSshKeys', @@ -127,12 +128,14 @@ const viewByLabel: Record = { const pathByView: Record = { dashboard: '/dashboard', + commonSources: '/common-apps/sources', + commonBuilds: '/common-apps/builds', + commonComponents: '/common-apps/components', launcherSources: '/launcher/sources', launcherBuilds: '/launcher/builds', launcherVersions: '/launcher/versions', launcherComponents: '/launcher/components', launcherApps: '/launcher/apps', - launcherCommonSettings: '/launcher/common-settings', notices: '/operations/notices', appManagement: '/applications/apps', appRepositories: '/applications/repositories', @@ -147,7 +150,6 @@ const pathByView: Record = { tailscaleStatus: '/tailscale/status', tailscaleRoutes: '/tailscale/routes', tailscaleAcl: '/tailscale/acl', - tailscaleDiagnostics: '/tailscale/diagnostics', agentRegistration: '/agents/registration', agentInstallStatus: '/agents/install-status', agentSshKeys: '/agents/ssh-keys', @@ -1388,6 +1390,7 @@ function DashboardPage({ const [dashboardNotices, setDashboardNotices] = useState([]); const [runtimeResources, setRuntimeResources] = useState([]); const [dashboardLoading, setDashboardLoading] = useState(true); + const loadingVisible = useMinimumLoading(dashboardLoading); useEffect(() => { let mounted = true; @@ -1486,6 +1489,7 @@ function DashboardPage({ + ); } @@ -1538,6 +1542,12 @@ function App() { const renderView = () => { switch (activeView) { + case 'commonSources': + return ; + case 'commonBuilds': + return ; + case 'commonComponents': + return ; case 'launcherVersions': return ; case 'launcherComponents': @@ -1548,8 +1558,6 @@ function App() { return ; case 'launcherApps': return ; - case 'launcherCommonSettings': - return ; case 'notices': return ; case 'adminManagement': @@ -1587,8 +1595,6 @@ function App() { return ; case 'tailscaleAcl': return ; - case 'tailscaleDiagnostics': - return ; case 'agentRegistration': return ; case 'agentInstallStatus': diff --git a/src/data/mock.ts b/src/data/mock.ts index 8fa38d8..6609ad4 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -18,6 +18,15 @@ export const navGroups = [ label: '플랫폼', items: [ { label: '대시보드', icon: LayoutDashboard, active: true }, + { + label: '공통앱', + icon: ServerCog, + children: [ + { label: '소스 관리', view: 'commonSources' }, + { label: '빌드 관리', view: 'commonBuilds' }, + { label: '구성요소', view: 'commonComponents' } + ] + }, { label: '런처', icon: Rocket, @@ -26,7 +35,6 @@ export const navGroups = [ { label: '빌드 관리', view: 'launcherBuilds' }, { label: '버전 관리' }, { label: '구성요소', view: 'launcherComponents' }, - { label: '공통 설정' }, { label: '앱 연결' } ] }, @@ -51,8 +59,7 @@ export const navGroups = [ children: [ { label: '노드 현황' }, { label: '라우트' }, - { label: 'ACL' }, - { label: '연결 진단' } + { label: 'ACL' } ] }, { @@ -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 = [ { group: '상태', method: 'GET', path: '/health', purpose: '서비스 및 Redis 상태 확인', auth: '없음' }, { group: '인증', method: 'POST', path: '/auth/login', purpose: 'access token 발급 및 refresh cookie 설정', auth: '없음' }, diff --git a/src/layout/navigation.ts b/src/layout/navigation.ts index a17a0ef..c68372e 100644 --- a/src/layout/navigation.ts +++ b/src/layout/navigation.ts @@ -42,7 +42,6 @@ export const navGroups: NavGroup[] = [ { label: '빌드 관리', path: '/launcher/builds' }, { label: '버전 관리', path: '/launcher/versions' }, { label: '구성요소', path: '/launcher/components' }, - { label: '공통 설정', path: '/launcher/common-settings' }, { label: '앱 연결', path: '/launcher/apps' } ] }, diff --git a/src/pages/RestoredPages.tsx b/src/pages/RestoredPages.tsx index 846209f..e518a64 100644 --- a/src/pages/RestoredPages.tsx +++ b/src/pages/RestoredPages.tsx @@ -5,10 +5,8 @@ import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ import { agentDevices, alerts, - authApiEndpoints, gatewayConnections, k3sWorkloads, - launcherCommonSettings, launcherVersions, runtimeCommands, tailscaleSettings @@ -315,111 +313,6 @@ export function LauncherAppsPage() { ); } -export function LauncherCommonSettingsPage() { - const [apiOpen, setApiOpen] = useState(false); - - return ( - <> - - - - - } - /> -
- {[ - ['설정 항목', `${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]) => ( - -

{label}

-

{value}

-
- ))} -
- - - -
- - - - - - - - - - - {launcherCommonSettings.map((item) => ( - - - - - - - - ))} - -
설정KeyTypeValue -
-

- - {item.name} -

-
{item.key}{item.type}{item.value} - -
-
-
- - setApiOpen(false)} className="!max-w-[1280px]" footer={}> -
- - - - - - - - - - - - {authApiEndpoints.map((endpoint) => ( - - - - - - - - ))} - -
그룹MethodPath용도권한
{endpoint.group}{endpoint.method}{endpoint.path}{endpoint.purpose} - {endpoint.auth} -
-
-
- - ); -} - export function TailscaleStatusPage() { const [nodes, setNodes] = useState([]); const [source, setSource] = useState(''); diff --git a/src/pages/applications/SourceManagementPage.tsx b/src/pages/applications/SourceManagementPage.tsx index 8843169..e27cc5e 100644 --- a/src/pages/applications/SourceManagementPage.tsx +++ b/src/pages/applications/SourceManagementPage.tsx @@ -447,7 +447,7 @@ export function SourceManagementPage() { disabled={filteredSources.length === 0} > {filteredSources.map((source) => ( - + ))} {filteredSources.length === 0 ? : null} @@ -496,7 +496,7 @@ export function SourceManagementPage() { {(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => ( diff --git a/src/pages/common-apps/CommonAppBuildPage.tsx b/src/pages/common-apps/CommonAppBuildPage.tsx new file mode 100644 index 0000000..ea56715 --- /dev/null +++ b/src/pages/common-apps/CommonAppBuildPage.tsx @@ -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([]); + 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 ( + <> + refresh()} disabled={loading}> + + 새로고침 + + } + /> + +
+ +

진행 중

+

{runningCount}

+
+ +

연결 저장소

+

{pipelines.length}

+
+ +

갱신 주기

+

{runningCount > 0 ? '3초' : '10초'}

+
+
+ + + +
+ + + + + + + + + + + + {pipelines.length === 0 && !loading ? ( + + + + ) : null} + {pipelines.map((pipeline) => ( + + + + + + + + ))} + +
저장소브랜치상태메시지생성시각
표시할 파이프라인이 없습니다.
{pipeline.repository}{pipeline.branch || '-'} + {pipeline.status || '-'} + {pipeline.message || '-'}{formatPipelineTime(pipeline.createdAt)}
+
+
+ + + + ); +} diff --git a/src/pages/common-apps/CommonAppComponentsPage.tsx b/src/pages/common-apps/CommonAppComponentsPage.tsx new file mode 100644 index 0000000..9eb4de7 --- /dev/null +++ b/src/pages/common-apps/CommonAppComponentsPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [envTarget, setEnvTarget] = useState(null); + const [envVariables, setEnvVariables] = useState([]); + 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>) => { + 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 ( + <> + + + 새로고침 + + } + /> + +
+ {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 ( + + {status}} + /> +
+
+
+ + + +
+

{component.repository}

+

{component.kind}/{component.workloadName}

+
+
+ +
+
+

Ready

+

{workload?.ready ?? '-'}

+
+
+

Service

+

{service ? `${service.status} · ${service.detail}` : '-'}

+
+
+

Image

+

{workload?.detail ?? '-'}

+
+
+ +
+ +
+
+ +
+ + + + + + + + + + + + + {details.length === 0 ? ( + + + + ) : null} + {details.map((resource) => ( + + + + + + + + + ))} + +
KindNameStatusReadyAgeDetail
관련 리소스가 없습니다.
{resource.kind}{resource.name}{resource.status}{resource.ready}{resource.age}{resource.detail || '-'}
+
+
+
+ ); + })} +
+ + setEnvTarget(null)} + className="!max-w-4xl" + footer={ + <> + + + + } + > +
+
+ + + + + + + + + + + + + + + + + + + {envLoading ? ( + + + + ) : null} + {!envLoading && envVariables.length === 0 ? ( + + + + ) : null} + {!envLoading && envVariables.map((variable, index) => ( + + + + + + + + ))} + +
KeyValueSecretSource
설정값을 불러오는 중입니다.
등록된 설정값이 없습니다.
+ updateEnvVariable(index, { key: event.target.value })} + /> + + updateEnvVariable(index, { value: event.target.value })} + /> + + + + {variable.source} + + +
+
+ +
+
+ + + + ); +} diff --git a/src/pages/common-apps/CommonAppSourcePage.tsx b/src/pages/common-apps/CommonAppSourcePage.tsx new file mode 100644 index 0000000..643ad5a --- /dev/null +++ b/src/pages/common-apps/CommonAppSourcePage.tsx @@ -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([]); + 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 ( + <> + + + 새로고침 + + } + /> + + + +
+ + + + + + + + + + + + + + {commonAppComponents.map((component) => { + const pipeline = pipelineByRepo.get(component.repository); + return ( + + + + + + + + + + ); + })} + +
저장소구성요소브랜치최근 파이프라인상태생성시각관리
{component.repository}{component.name}{pipeline?.branch || '-'}{pipeline?.message || '-'} + {pipeline?.status || '-'} + {formatPipelineTime(pipeline?.createdAt)} + {pipeline?.url ? ( + + ) : ( + '-' + )} +
+
+
+ + + + ); +} diff --git a/src/pages/common-apps/common-app-api.ts b/src/pages/common-apps/common-app-api.ts new file mode 100644 index 0000000..ed50fbb --- /dev/null +++ b/src/pages/common-apps/common-app-api.ts @@ -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 ?? []); +} diff --git a/src/pages/common-apps/common-app-utils.ts b/src/pages/common-apps/common-app-utils.ts new file mode 100644 index 0000000..ed9daa8 --- /dev/null +++ b/src/pages/common-apps/common-app-utils.ts @@ -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); +} diff --git a/src/pages/launcher/LauncherAppsPage.tsx b/src/pages/launcher/LauncherAppsPage.tsx index c2585d3..9f7a7d1 100644 --- a/src/pages/launcher/LauncherAppsPage.tsx +++ b/src/pages/launcher/LauncherAppsPage.tsx @@ -174,7 +174,7 @@ export function LauncherAppsPage() { return ( <> ([]); - const [loading, setLoading] = useState(true); - const [modalOpen, setModalOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [form, setForm] = useState(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 ( - <> - - - - - } - /> - -
- {[ - ['설정 항목', `${summary.total}개`], - ['Endpoint', `${summary.endpoint}개`], - ['Database', `${summary.database}개`], - ['활성 상태', `${summary.active}개`] - ].map(([label, value]) => ( - -

{label}

-

{value}

-
- ))} -
- - - -
- - - - - - - - - - - - {!loading && settings.length === 0 ? ( - - - - ) : null} - {pagedSettings.map((setting) => ( - - - - - - - - ))} - -
설정KeyTypeValue관리
등록된 설정이 없습니다.
-

- - {setting.name} -

- {setting.description ?

{setting.description}

: null} -
{setting.key}{setting.type}{maskValue(setting)} - -
-
-
- - {loading ? '불러오는 중' : `총 ${settings.length}건 · ${page} / ${totalPages}`} - -
- - -
-
-
- - setModalOpen(false)} - className="!max-w-2xl" - footer={ - <> - {editing ? ( - - ) : null} - - - - } - > -
-
- - -
- - -