improve runtime environment variable modal usability
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+11
-13
@@ -13,7 +13,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { navGroups } from './data/mock';
|
||||
import { navGroups } from './layout/navigation';
|
||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from './components/ui';
|
||||
import { formatDateTime as formatDateTimeValue } from './lib/date-format';
|
||||
import { apiBaseUrl, apiFetch, apiRequest, authExpiredEventName, authHeaders, clearAdminSession } from './lib/api';
|
||||
@@ -58,7 +58,6 @@ import { MonitoringPage } from './pages/system/MonitoringPage';
|
||||
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
|
||||
import {
|
||||
AgentCommandsPage as AgentCommandsRestoredPage,
|
||||
AgentInstallStatusPage as AgentInstallStatusRestoredPage,
|
||||
AgentRegistrationPage as AgentRegistrationRestoredPage,
|
||||
AgentSshKeysPage as AgentSshKeysRestoredPage,
|
||||
TailscaleAclPage as TailscaleAclRestoredPage,
|
||||
@@ -132,12 +131,11 @@ const viewByLabel: Record<string, ViewKey> = {
|
||||
네트워크: 'infraNetwork',
|
||||
스토리지: 'infraStorage',
|
||||
모니터링: 'monitoring',
|
||||
'장비 등록': 'agentRegistration',
|
||||
'설치 상태': 'agentInstallStatus',
|
||||
'SSH 키 배포': 'agentSshKeys',
|
||||
'플랫폼 등록': 'agentRegistration',
|
||||
'SSH 키': 'agentSshKeys',
|
||||
'원격 명령': 'agentCommands',
|
||||
'로그/이벤트': 'runtimeLogs',
|
||||
'장비 제어': 'runtimeDeviceControl',
|
||||
'연결 제어': 'runtimeDeviceControl',
|
||||
'관리자 관리': 'adminManagement',
|
||||
'조직 관리': 'organizations',
|
||||
'사용자 관리': 'userSessions',
|
||||
@@ -578,10 +576,11 @@ function formatRelativeTime(timestamp: number) {
|
||||
|
||||
type NavGroup = (typeof navGroups)[number];
|
||||
type NavItem = NavGroup['items'][number];
|
||||
type NavChild = { label: string; view?: string };
|
||||
type NavChild = { label: string; path?: string };
|
||||
|
||||
function resolveNavView(item: NavItem | NavChild): ViewKey | undefined {
|
||||
return 'view' in item && item.view ? item.view as ViewKey : viewByLabel[item.label];
|
||||
const path = 'path' in item ? item.path : undefined;
|
||||
return path ? viewByPath[path] : viewByLabel[item.label];
|
||||
}
|
||||
|
||||
function isOwnerRole(role?: string | null) {
|
||||
@@ -686,9 +685,10 @@ function Sidebar({
|
||||
<p className="mb-2 px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const hasChildren = 'children' in item && item.children;
|
||||
const children = item.children;
|
||||
const hasChildren = Boolean(children?.length);
|
||||
const isParentActive = hasChildren
|
||||
? item.children.some((child) => resolveNavView(child) === activeView)
|
||||
? children!.some((child) => resolveNavView(child) === activeView)
|
||||
: activeView === resolveNavView(item);
|
||||
const isMenuOpen = Boolean(normalizedMenuSearch) || (openMenus[item.label] ?? isParentActive);
|
||||
|
||||
@@ -723,7 +723,7 @@ function Sidebar({
|
||||
</button>
|
||||
{hasChildren && isMenuOpen ? (
|
||||
<div className="mt-1 space-y-1 pl-6">
|
||||
{item.children.map((child) => {
|
||||
{children!.map((child) => {
|
||||
const isChildActive = activeView === resolveNavView(child);
|
||||
|
||||
return (
|
||||
@@ -2333,8 +2333,6 @@ function App() {
|
||||
return <MonitoringPage />;
|
||||
case 'agentRegistration':
|
||||
return <AgentRegistrationRestoredPage />;
|
||||
case 'agentInstallStatus':
|
||||
return <AgentInstallStatusRestoredPage />;
|
||||
case 'agentSshKeys':
|
||||
return <AgentSshKeysRestoredPage />;
|
||||
case 'agentCommands':
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,771 +0,0 @@
|
||||
import {
|
||||
MonitorPlay,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Boxes,
|
||||
ChartNoAxesColumnIncreasing,
|
||||
FileKey,
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
MonitorUp,
|
||||
Network,
|
||||
ScrollText,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
|
||||
export const navGroups = [
|
||||
{
|
||||
label: '플랫폼',
|
||||
items: [
|
||||
{ label: '대시보드', icon: LayoutDashboard, active: true },
|
||||
{
|
||||
label: '공통앱',
|
||||
icon: ServerCog,
|
||||
children: [
|
||||
{ label: '소스 관리', view: 'commonSources' },
|
||||
{ label: '빌드 관리', view: 'commonBuilds' },
|
||||
{ label: '구성요소', view: 'commonComponents' },
|
||||
{ label: '로그/이벤트', view: 'commonLogs' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '런처',
|
||||
icon: MonitorPlay,
|
||||
children: [
|
||||
{ label: '소스 관리', view: 'launcherSources' },
|
||||
{ label: '빌드 관리', view: 'launcherBuilds' },
|
||||
{ label: '버전 관리' },
|
||||
{ label: '구성요소', view: 'launcherComponents' },
|
||||
{ label: '로그/이벤트', view: 'launcherLogs' },
|
||||
{ label: '앱 연결' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '어플리케이션',
|
||||
icon: Boxes,
|
||||
children: [
|
||||
{ label: '앱 관리' },
|
||||
{ label: '저장소 관리' },
|
||||
{ label: '소스 관리' },
|
||||
{ label: '빌드 관리' },
|
||||
{ label: '릴리즈 관리' },
|
||||
{ label: '실행 설정' },
|
||||
{ label: '배포 관리' },
|
||||
{ label: '마이그레이션' },
|
||||
{ label: '로그/이벤트' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '테일스케일',
|
||||
icon: Network,
|
||||
children: [
|
||||
{ label: '노드 현황' },
|
||||
{ label: '라우트' },
|
||||
{ label: 'ACL' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '에이전트',
|
||||
icon: MonitorUp,
|
||||
children: [
|
||||
{ label: '장비 등록' },
|
||||
{ label: '장비 제어' },
|
||||
{ label: '설치 상태' },
|
||||
{ label: 'SSH 키 배포' },
|
||||
{ label: '원격 명령' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '인프라',
|
||||
icon: HardDrive,
|
||||
children: [
|
||||
{ label: '클러스터', view: 'infraCluster' },
|
||||
{ label: '워크로드', view: 'infraWorkloads' },
|
||||
{ label: '네트워크', view: 'infraNetwork' },
|
||||
{ label: '스토리지', view: 'infraStorage' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '운영',
|
||||
items: [
|
||||
{ label: '관리자 관리', icon: ShieldCheck },
|
||||
{ label: '조직 관리', icon: Boxes },
|
||||
{ label: '사용자 관리', icon: Users },
|
||||
{ label: '라이센스', icon: FileKey },
|
||||
{ label: '공지사항', icon: Megaphone },
|
||||
{ label: '약관', icon: ScrollText },
|
||||
{ label: '매뉴얼', icon: BookOpen }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '시스템',
|
||||
items: [
|
||||
{ label: '모니터링', icon: ChartNoAxesColumnIncreasing },
|
||||
{ label: '정책', icon: ShieldCheck }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const metrics = [
|
||||
{ label: '활성 앱', value: '10', delta: '+0', hint: '런처 등록 앱 10개', tone: 'emerald' },
|
||||
{ label: '접속 사용자', value: '1,284', delta: '+9.8%', hint: '활성 세션 176개', tone: 'sky' },
|
||||
{ label: '라이센스 사용률', value: '72%', delta: '+4%', hint: '468 / 650 시트', tone: 'amber' },
|
||||
{ label: '런타임 정상률', value: '96.4%', delta: '-1.2%', hint: '주의 워크로드 3개', tone: 'rose' }
|
||||
];
|
||||
|
||||
export const launcherVersions = [
|
||||
{
|
||||
name: 'Autonomy Studio Launcher',
|
||||
appKey: 'launcher',
|
||||
latestVersion: '1.0.1',
|
||||
minimumSupportedVersion: '1.0.0',
|
||||
status: '활성',
|
||||
artifact: 'autonomy-studio-launcher-1.0.1.deb',
|
||||
downloadUrl: 'https://testfile.autonomystudio.net/media/platform/autonomy-studio-launcher-1.0.1.deb',
|
||||
releaseNotes: '런처 업데이트 확인 및 앱별 설치 상태 판단 기능이 추가되었습니다.'
|
||||
}
|
||||
];
|
||||
|
||||
export const authApiEndpoints = [
|
||||
{ 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/refresh', purpose: 'refresh token 회전 및 access token 재발급', auth: '쿠키/토큰' },
|
||||
{ group: '인증', method: 'POST', path: '/auth/logout', purpose: 'refresh token 폐기 및 쿠키 제거', auth: '쿠키/토큰' },
|
||||
{ group: '사용자', method: 'GET', path: '/auth/me', purpose: '현재 사용자, 앱 권한, 라이센스, 소속 조회', auth: 'Bearer' },
|
||||
{ group: '앱 연결', method: 'POST', path: '/auth/handoff', purpose: '런처에서 앱으로 넘길 handoff token 생성', auth: 'Bearer' },
|
||||
{ group: '앱 연결', method: 'POST', path: '/auth/handoff/exchange', purpose: 'handoff token을 앱용 token pair로 교환', auth: '없음' },
|
||||
{ group: '관리자', method: 'POST', path: '/auth/admin/users', purpose: '관리자 사용자 생성, 소속, 라이센스 할당', auth: 'ADMIN' },
|
||||
{ group: '관리자', method: 'GET', path: '/auth/admin/organizations', purpose: '조직 목록 조회', auth: 'ADMIN' }
|
||||
];
|
||||
|
||||
export const apps = [
|
||||
{
|
||||
name: 'ADS IDE',
|
||||
key: 'ADS IDE',
|
||||
status: '활성',
|
||||
version: '1.1.0',
|
||||
containers: 2,
|
||||
cpu: 52,
|
||||
memory: 68,
|
||||
gitea: './apps/ads-ide/ads-ide-fe',
|
||||
release: 'ADS-IDE-1.1.0.AppImage',
|
||||
secrets: 8,
|
||||
launcherLinked: true,
|
||||
launcherOrder: 1,
|
||||
launchUrl: 'Ingress 예정 / localhost:5175'
|
||||
},
|
||||
{
|
||||
name: 'LMS',
|
||||
key: 'LMS',
|
||||
status: '활성',
|
||||
version: '1.0.0',
|
||||
containers: 3,
|
||||
cpu: 74,
|
||||
memory: 81,
|
||||
gitea: './apps/lms/lms-fe',
|
||||
release: 'LMS-1.0.0.AppImage',
|
||||
secrets: 11,
|
||||
launcherLinked: true,
|
||||
launcherOrder: 2,
|
||||
launchUrl: 'Ingress 예정 / localhost:5176'
|
||||
},
|
||||
{
|
||||
name: 'Data Management',
|
||||
key: 'Data Management',
|
||||
status: '준비중',
|
||||
version: '1.0.0',
|
||||
containers: 0,
|
||||
cpu: 31,
|
||||
memory: 48,
|
||||
gitea: '미연결',
|
||||
release: 'data-management',
|
||||
secrets: 0,
|
||||
launcherLinked: false,
|
||||
launcherOrder: null,
|
||||
launchUrl: '미설정'
|
||||
},
|
||||
{
|
||||
name: 'FMS',
|
||||
key: 'FMS',
|
||||
status: '활성',
|
||||
version: '2.3.1',
|
||||
containers: 0,
|
||||
cpu: 63,
|
||||
memory: 57,
|
||||
gitea: '미연결',
|
||||
release: 'fms',
|
||||
secrets: 0,
|
||||
launcherLinked: true,
|
||||
launcherOrder: 3,
|
||||
launchUrl: '미설정'
|
||||
},
|
||||
{
|
||||
name: 'ADS Education',
|
||||
key: 'ADS Education',
|
||||
status: '활성',
|
||||
version: '1.0.0',
|
||||
containers: 0,
|
||||
cpu: 18,
|
||||
memory: 22,
|
||||
gitea: '미연결',
|
||||
release: 'ads-education',
|
||||
secrets: 0,
|
||||
launcherLinked: true,
|
||||
launcherOrder: 4,
|
||||
launchUrl: '미설정'
|
||||
},
|
||||
{
|
||||
name: 'Calibration',
|
||||
key: 'Calibration',
|
||||
status: '활성',
|
||||
version: '1.0.4',
|
||||
containers: 0,
|
||||
cpu: 21,
|
||||
memory: 26,
|
||||
gitea: '미연결',
|
||||
release: 'calibration',
|
||||
secrets: 0,
|
||||
launcherLinked: false,
|
||||
launcherOrder: null,
|
||||
launchUrl: '미설정'
|
||||
},
|
||||
{
|
||||
name: 'HDMAP Builder',
|
||||
key: 'HDMAP Builder',
|
||||
status: '활성',
|
||||
version: '1.0.0',
|
||||
containers: 0,
|
||||
cpu: 15,
|
||||
memory: 19,
|
||||
gitea: '미연결',
|
||||
release: 'hdmap-builder',
|
||||
secrets: 0,
|
||||
launcherLinked: false,
|
||||
launcherOrder: null,
|
||||
launchUrl: '미설정'
|
||||
}
|
||||
];
|
||||
|
||||
export const sessions = [
|
||||
{ user: 'kim.admin', role: '소유자', location: '서울', device: 'Chrome macOS', state: '활성', lastSeen: '2분 전' },
|
||||
{ user: 'park.ops', role: '운영자', location: '판교', device: 'Edge Windows', state: '활성', lastSeen: '7분 전' },
|
||||
{ user: 'lee.viewer', role: '조회자', location: '부산', device: 'Safari iOS', state: '대기', lastSeen: '31분 전' },
|
||||
{ user: 'choi.dev', role: '개발자', location: '서울', device: 'Firefox Linux', state: '잠김', lastSeen: '1시간 전' },
|
||||
{ user: 'han.pm', role: '관리자', location: '대전', device: 'Chrome Windows', state: '활성', lastSeen: '12분 전' },
|
||||
{ user: 'jung.viewer', role: '조회자', location: '광주', device: 'Safari macOS', state: '대기', lastSeen: '46분 전' },
|
||||
{ user: 'yoon.ops', role: '운영자', location: '서울', device: 'Edge Windows', state: '활성', lastSeen: '5분 전' },
|
||||
{ user: 'seo.admin', role: '관리자', location: '판교', device: 'Chrome Linux', state: '활성', lastSeen: '1분 전' },
|
||||
{ user: 'moon.dev', role: '개발자', location: '부산', device: 'Firefox Linux', state: '잠김', lastSeen: '2시간 전' },
|
||||
{ user: 'lim.user', role: '사용자', location: '인천', device: 'Chrome Android', state: '대기', lastSeen: '28분 전' },
|
||||
{ user: 'oh.instructor', role: '강사', location: '서울', device: 'Safari iPadOS', state: '활성', lastSeen: '9분 전' }
|
||||
];
|
||||
|
||||
export const licensePlans = [
|
||||
{
|
||||
code: 'FREE',
|
||||
name: 'Free',
|
||||
description: '기본 무료 라이선스',
|
||||
defaultDurationDays: 365,
|
||||
assigned: 182,
|
||||
activeUsers: 164,
|
||||
expiringSoon: 18,
|
||||
status: '활성'
|
||||
},
|
||||
{
|
||||
code: 'EDU',
|
||||
name: 'Education',
|
||||
description: '교육기관용 라이선스',
|
||||
defaultDurationDays: 365,
|
||||
assigned: 296,
|
||||
activeUsers: 271,
|
||||
expiringSoon: 21,
|
||||
status: '활성'
|
||||
},
|
||||
{
|
||||
code: 'PRO',
|
||||
name: 'Professional',
|
||||
description: '전문가용 라이선스',
|
||||
defaultDurationDays: 365,
|
||||
assigned: 172,
|
||||
activeUsers: 158,
|
||||
expiringSoon: 3,
|
||||
status: '활성'
|
||||
}
|
||||
];
|
||||
|
||||
export const userLicenseAssignments = [
|
||||
{ user: 'admin@futuredrive.net', name: 'ATNMY Studio Admin', code: 'PRO', licenseName: 'Professional', startsAt: '06.01.', expiresAt: '무기한', assignedBy: 'system', status: '활성' },
|
||||
{ user: 'teacher@futuredrive.net', name: 'Future Drive Teacher', code: 'EDU', licenseName: 'Education', startsAt: '06.02.', expiresAt: '06.02.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
||||
{ user: 'teacher@yu.ac.kr', name: 'YU Instructor', code: 'EDU', licenseName: 'Education', startsAt: '06.02.', expiresAt: '06.02.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
||||
{ user: 'student01@example.com', name: 'student01', code: 'FREE', licenseName: 'Free', startsAt: '06.05.', expiresAt: '06.05.', assignedBy: 'system', status: '활성' },
|
||||
{ user: 'student03@example.com', name: 'student03', code: 'FREE', licenseName: 'Free', startsAt: '06.05.', expiresAt: '06.05.', assignedBy: 'system', status: '활성' },
|
||||
{ user: 'ops@autonomystudio.local', name: 'ops', code: 'PRO', licenseName: 'Professional', startsAt: '06.10.', expiresAt: '06.10.', assignedBy: 'admin@futuredrive.net', status: '활성' },
|
||||
{ user: 'viewer@example.com', name: 'viewer', code: 'FREE', licenseName: 'Free', startsAt: '07.01.', expiresAt: '07.01.', assignedBy: 'system', status: '주의' }
|
||||
];
|
||||
|
||||
export const appBundles = [
|
||||
{
|
||||
name: 'LMS',
|
||||
key: 'LMS',
|
||||
status: '활성',
|
||||
namespace: 'stdo-lms',
|
||||
launcherTarget: 'lms-fe',
|
||||
launcherArtifact: 'LMS-1.0.0.AppImage',
|
||||
ingress: 'https://lms.apps.autonomystudio.local',
|
||||
release: 'lms-1.0.0',
|
||||
components: [
|
||||
{
|
||||
name: 'lms-fe',
|
||||
role: '런처 다운로드 앱',
|
||||
type: 'electron',
|
||||
workload: 'artifact',
|
||||
image: 'LMS-1.0.0.AppImage',
|
||||
replicas: '-',
|
||||
status: '배포됨',
|
||||
repository: './apps/lms/lms-fe'
|
||||
},
|
||||
{
|
||||
name: 'lms-be',
|
||||
role: 'API 서버',
|
||||
type: 'deployment',
|
||||
workload: 'Deployment/lms-api',
|
||||
image: 'registry.local/lms-be:1.0.0',
|
||||
replicas: '2/2',
|
||||
status: '실행 중',
|
||||
repository: './apps/lms/lms-be'
|
||||
},
|
||||
{
|
||||
name: 'lms-adm-fe',
|
||||
role: '강사용 웹 어드민',
|
||||
type: 'deployment',
|
||||
workload: 'Deployment/lms-adm-web',
|
||||
image: 'registry.local/lms-adm-fe:0.1.0',
|
||||
replicas: '1/1',
|
||||
status: '실행 중',
|
||||
repository: './apps/lms/lms-adm-fe'
|
||||
},
|
||||
{
|
||||
name: 'lms-db',
|
||||
role: 'PostgreSQL',
|
||||
type: 'statefulset',
|
||||
workload: 'StatefulSet/lms-postgres',
|
||||
image: 'postgres:16',
|
||||
replicas: '1/1',
|
||||
status: '실행 중',
|
||||
repository: 'PVC/lms-postgres-data'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'ADS IDE',
|
||||
key: 'ADS IDE',
|
||||
status: '활성',
|
||||
namespace: 'stdo-ads-ide',
|
||||
launcherTarget: 'ads-ide-fe',
|
||||
launcherArtifact: 'ADS-IDE-1.1.0.AppImage',
|
||||
ingress: 'https://ads-ide.apps.autonomystudio.local',
|
||||
release: 'ads-ide-1.1.0',
|
||||
components: [
|
||||
{
|
||||
name: 'ads-ide-fe',
|
||||
role: '런처 다운로드 앱',
|
||||
type: 'electron',
|
||||
workload: 'artifact',
|
||||
image: 'ADS-IDE-1.1.0.AppImage',
|
||||
replicas: '-',
|
||||
status: '배포됨',
|
||||
repository: './apps/ads-ide/ads-ide-fe'
|
||||
},
|
||||
{
|
||||
name: 'ads-ide-be',
|
||||
role: 'API 서버',
|
||||
type: 'deployment',
|
||||
workload: 'Deployment/ads-ide-api',
|
||||
image: 'registry.local/ads-ide-be:0.1.0',
|
||||
replicas: '1/1',
|
||||
status: '주의',
|
||||
repository: './apps/ads-ide/ads-ide-be'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Data Management',
|
||||
key: 'Data Management',
|
||||
status: '준비중',
|
||||
namespace: 'stdo-data',
|
||||
launcherTarget: 'data-management-fe',
|
||||
launcherArtifact: '미등록',
|
||||
ingress: '미설정',
|
||||
release: 'data-management-1.0.0',
|
||||
components: [
|
||||
{
|
||||
name: 'data-management-fe',
|
||||
role: '런처 다운로드 앱',
|
||||
type: 'electron',
|
||||
workload: 'artifact',
|
||||
image: '미등록',
|
||||
replicas: '-',
|
||||
status: '준비중',
|
||||
repository: '미연결'
|
||||
},
|
||||
{
|
||||
name: 'data-api',
|
||||
role: 'API 서버',
|
||||
type: 'deployment',
|
||||
workload: 'Deployment/data-api',
|
||||
image: 'registry.local/data-api:1.0.0',
|
||||
replicas: '0/1',
|
||||
status: '준비중',
|
||||
repository: '미연결'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const appSourceConfigs = [
|
||||
{
|
||||
app: 'LMS',
|
||||
component: 'lms-fe',
|
||||
repository: 'platform/lms-fe',
|
||||
branch: 'release',
|
||||
commit: '8d41c2a',
|
||||
sourceType: 'electron',
|
||||
target: 'LMS-1.0.0.AppImage',
|
||||
status: '활성'
|
||||
},
|
||||
{
|
||||
app: 'LMS',
|
||||
component: 'lms-be',
|
||||
repository: 'platform/lms-be',
|
||||
branch: 'release',
|
||||
commit: '3ac92ef',
|
||||
sourceType: 'container',
|
||||
target: 'registry.local/lms-be:1.0.0',
|
||||
status: '활성'
|
||||
},
|
||||
{
|
||||
app: 'LMS',
|
||||
component: 'lms-adm-fe',
|
||||
repository: 'platform/lms-adm-fe',
|
||||
branch: 'release',
|
||||
commit: 'f17ab31',
|
||||
sourceType: 'static-web',
|
||||
target: 'registry.local/lms-adm-fe:0.1.0',
|
||||
status: '활성'
|
||||
},
|
||||
{
|
||||
app: 'LMS',
|
||||
component: 'lms-db',
|
||||
repository: 'platform/lms-be',
|
||||
branch: 'release',
|
||||
commit: '3ac92ef',
|
||||
sourceType: 'migration',
|
||||
target: 'prisma/migrations',
|
||||
status: '활성'
|
||||
},
|
||||
{
|
||||
app: 'ADS IDE',
|
||||
component: 'ads-ide-fe',
|
||||
repository: 'platform/ads-ide-fe',
|
||||
branch: 'develop',
|
||||
commit: 'b902a11',
|
||||
sourceType: 'electron',
|
||||
target: 'ADS-IDE-1.1.0.AppImage',
|
||||
status: '활성'
|
||||
},
|
||||
{
|
||||
app: 'ADS IDE',
|
||||
component: 'ads-ide-be',
|
||||
repository: 'platform/ads-ide-be',
|
||||
branch: 'develop',
|
||||
commit: '10fd8b0',
|
||||
sourceType: 'container',
|
||||
target: 'registry.local/ads-ide-be:0.1.0',
|
||||
status: '주의'
|
||||
}
|
||||
];
|
||||
|
||||
export const appBuildJobs = [
|
||||
{ app: 'LMS', component: 'lms-fe', branch: 'release', job: 'electron build', artifact: 'LMS-1.0.0.AppImage', duration: '6분 12초', status: '완료', time: '12분 전' },
|
||||
{ app: 'LMS', component: 'lms-be', branch: 'release', job: 'image build', artifact: 'registry.local/lms-be:1.0.0', duration: '4분 31초', status: '완료', time: '10분 전' },
|
||||
{ app: 'LMS', component: 'lms-adm-fe', branch: 'release', job: 'web image build', artifact: 'registry.local/lms-adm-fe:0.1.0', duration: '3분 18초', status: '완료', time: '9분 전' },
|
||||
{ app: 'ADS IDE', component: 'ads-ide-be', branch: 'develop', job: 'image build', artifact: 'registry.local/ads-ide-be:0.1.0', duration: '진행 중', status: '실행 중', time: '3분 전' },
|
||||
{ app: 'FMS', component: 'fms-api', branch: 'develop', job: 'image build', artifact: '미생성', duration: '1분 44초', status: '주의', time: '24분 전' }
|
||||
];
|
||||
|
||||
export const appReleasePipelines = [
|
||||
{ app: 'LMS', version: '1.0.0', sourceBranch: 'release', releaseBranch: 'main', commit: '3ac92ef', artifacts: 'electron 1, images 2, migrations 4', status: '배포됨', createdAt: '06.18.' },
|
||||
{ app: 'ADS IDE', version: '1.1.0', sourceBranch: 'develop', releaseBranch: 'release', commit: 'b902a11', artifacts: 'electron 1, images 1', status: '카나리', createdAt: '06.16.' },
|
||||
{ app: 'FMS', version: '2.3.1', sourceBranch: 'develop', releaseBranch: 'release', commit: 'c82f09d', artifacts: 'images 1', status: '초안', createdAt: '06.14.' }
|
||||
];
|
||||
|
||||
export const appDeploymentJobs = [
|
||||
{ app: 'LMS', environment: '운영', release: '1.0.0', strategy: 'rolling update', target: 'stdo-lms', status: '완료', requestedBy: 'kim.admin', time: '8분 전' },
|
||||
{ app: 'ADS IDE', environment: '개발', release: '1.1.0-canary', strategy: 'redeploy', target: 'stdo-ads-ide', status: '실행 중', requestedBy: 'park.ops', time: '2분 전' },
|
||||
{ app: 'FMS', environment: '개발', release: '2.3.1-dev', strategy: 'deploy', target: 'stdo-fms', status: '주의', requestedBy: 'choi.dev', time: '27분 전' }
|
||||
];
|
||||
|
||||
export const appMigrationJobs = [
|
||||
{ app: 'LMS', component: 'lms-db', branch: 'release', path: 'prisma/migrations', version: '20260618091000_add_course_indexes', status: '완료', time: '7분 전' },
|
||||
{ app: 'LMS', component: 'lms-db', branch: 'release', path: 'prisma/migrations', version: '20260617083000_add_assignment_status', status: '완료', time: '1일 전' },
|
||||
{ app: 'ADS IDE', component: 'ads-ide-db', branch: 'develop', path: 'migrations', version: '20260616070000_workspace_schema', status: '대기', time: '대기 중' }
|
||||
];
|
||||
|
||||
export const containers = [
|
||||
{ name: 'launcher-api', app: 'Autonomy Studio Launcher', image: 'atnmy-stdo-v2-be:local', cpu: 43, memory: 62, status: '실행 중' },
|
||||
{ name: 'launcher-postgres', app: 'Autonomy Studio Launcher', image: 'postgres:launcher', cpu: 24, memory: 39, status: '실행 중' },
|
||||
{ name: 'lms-api', app: 'LMS', image: 'lms-be:local', cpu: 67, memory: 59, status: '실행 중' },
|
||||
{ name: 'ads-ide-api', app: 'ADS IDE', image: 'ads-ide-be:local', cpu: 89, memory: 84, status: '주의' }
|
||||
];
|
||||
|
||||
export const k3sWorkloads = [
|
||||
{
|
||||
namespace: 'stdo-launcher',
|
||||
app: 'Autonomy Studio Launcher',
|
||||
workload: 'Deployment/launcher-api',
|
||||
pod: 'launcher-api-7c9d85f9b7-q2x4m',
|
||||
image: 'registry.local/atnmy-stdo-v2-be:1.0.1',
|
||||
replicas: '2/2',
|
||||
node: 'cluster-master-01',
|
||||
cpu: 43,
|
||||
memory: 62,
|
||||
restarts: 0,
|
||||
age: '3d',
|
||||
status: '실행 중'
|
||||
},
|
||||
{
|
||||
namespace: 'stdo-launcher',
|
||||
app: 'Autonomy Studio Launcher',
|
||||
workload: 'StatefulSet/launcher-postgres',
|
||||
pod: 'launcher-postgres-0',
|
||||
image: 'postgres:16',
|
||||
replicas: '1/1',
|
||||
node: 'cluster-master-01',
|
||||
cpu: 24,
|
||||
memory: 39,
|
||||
restarts: 0,
|
||||
age: '3d',
|
||||
status: '실행 중'
|
||||
},
|
||||
{
|
||||
namespace: 'stdo-lms',
|
||||
app: 'LMS',
|
||||
workload: 'Deployment/lms-api',
|
||||
pod: 'lms-api-5f6db8d675-jt2xk',
|
||||
image: 'registry.local/lms-be:1.0.0',
|
||||
replicas: '2/2',
|
||||
node: 'cluster-worker-01',
|
||||
cpu: 67,
|
||||
memory: 59,
|
||||
restarts: 1,
|
||||
age: '18h',
|
||||
status: '실행 중'
|
||||
},
|
||||
{
|
||||
namespace: 'stdo-lms',
|
||||
app: 'LMS',
|
||||
workload: 'Deployment/lms-adm-web',
|
||||
pod: 'lms-adm-web-778b4d4f9b-bxz92',
|
||||
image: 'registry.local/lms-adm-fe:0.1.0',
|
||||
replicas: '1/1',
|
||||
node: 'cluster-worker-02',
|
||||
cpu: 31,
|
||||
memory: 44,
|
||||
restarts: 0,
|
||||
age: '18h',
|
||||
status: '실행 중'
|
||||
},
|
||||
{
|
||||
namespace: 'stdo-lms',
|
||||
app: 'LMS',
|
||||
workload: 'StatefulSet/lms-postgres',
|
||||
pod: 'lms-postgres-0',
|
||||
image: 'postgres:16',
|
||||
replicas: '1/1',
|
||||
node: 'cluster-worker-01',
|
||||
cpu: 18,
|
||||
memory: 71,
|
||||
restarts: 0,
|
||||
age: '18h',
|
||||
status: '실행 중'
|
||||
},
|
||||
{
|
||||
namespace: 'stdo-ads-ide',
|
||||
app: 'ADS IDE',
|
||||
workload: 'Deployment/ads-ide-api',
|
||||
pod: 'ads-ide-api-84b8787d7d-r8qkp',
|
||||
image: 'registry.local/ads-ide-be:0.1.0',
|
||||
replicas: '1/1',
|
||||
node: 'cluster-worker-02',
|
||||
cpu: 89,
|
||||
memory: 84,
|
||||
restarts: 4,
|
||||
age: '7h',
|
||||
status: '주의'
|
||||
}
|
||||
];
|
||||
|
||||
export const containerBlueprints = [
|
||||
{
|
||||
app: 'LMS',
|
||||
namespace: 'stdo-lms',
|
||||
release: 'lms-1.0.0',
|
||||
resources: [
|
||||
{
|
||||
name: 'lms-api',
|
||||
kind: 'Deployment',
|
||||
image: 'registry.local/lms-be:1.0.0',
|
||||
replicas: 2,
|
||||
ports: '4000/TCP',
|
||||
env: 14,
|
||||
secrets: 5,
|
||||
storage: '-',
|
||||
ingress: 'api.lms.apps.local',
|
||||
status: '구성완료'
|
||||
},
|
||||
{
|
||||
name: 'lms-adm-web',
|
||||
kind: 'Deployment',
|
||||
image: 'registry.local/lms-adm-fe:0.1.0',
|
||||
replicas: 1,
|
||||
ports: '80/TCP',
|
||||
env: 6,
|
||||
secrets: 2,
|
||||
storage: '-',
|
||||
ingress: 'admin.lms.apps.local',
|
||||
status: '구성완료'
|
||||
},
|
||||
{
|
||||
name: 'lms-postgres',
|
||||
kind: 'StatefulSet',
|
||||
image: 'postgres:16',
|
||||
replicas: 1,
|
||||
ports: '5432/TCP',
|
||||
env: 8,
|
||||
secrets: 3,
|
||||
storage: '20Gi / RWO',
|
||||
ingress: '-',
|
||||
status: '구성완료'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
app: 'ADS IDE',
|
||||
namespace: 'stdo-ads-ide',
|
||||
release: 'ads-ide-1.1.0',
|
||||
resources: [
|
||||
{
|
||||
name: 'ads-ide-api',
|
||||
kind: 'Deployment',
|
||||
image: 'registry.local/ads-ide-be:0.1.0',
|
||||
replicas: 1,
|
||||
ports: '4300/TCP',
|
||||
env: 9,
|
||||
secrets: 4,
|
||||
storage: '-',
|
||||
ingress: 'api.ads-ide.apps.local',
|
||||
status: '검토필요'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const tailscaleNodes = [
|
||||
{
|
||||
name: 'cluster-master-01',
|
||||
ip: '100.64.0.10',
|
||||
role: 'control-plane',
|
||||
user: 'ops@autonomystudio.local',
|
||||
routes: '10.42.0.0/16, 10.43.0.0/16',
|
||||
lastSeen: '1분 전',
|
||||
status: '활성'
|
||||
},
|
||||
{
|
||||
name: 'cluster-worker-01',
|
||||
ip: '100.64.0.11',
|
||||
role: 'worker',
|
||||
user: 'ops@autonomystudio.local',
|
||||
routes: '-',
|
||||
lastSeen: '3분 전',
|
||||
status: '활성'
|
||||
},
|
||||
{
|
||||
name: 'gpu-runner-02',
|
||||
ip: '100.64.20.2',
|
||||
role: 'gpu-runner',
|
||||
user: 'mlops@autonomystudio.local',
|
||||
routes: '-',
|
||||
lastSeen: '오프라인',
|
||||
status: '주의'
|
||||
}
|
||||
];
|
||||
|
||||
export const tailscaleSettings = [
|
||||
{
|
||||
name: 'Tailnet',
|
||||
key: 'TAILSCALE_TAILNET',
|
||||
value: 'autonomy-studio.ts.net',
|
||||
status: '활성',
|
||||
description: '런처와 클러스터 운영 노드가 연결되는 tailnet'
|
||||
},
|
||||
{
|
||||
name: 'Subnet Routes',
|
||||
key: 'TAILSCALE_ADVERTISE_ROUTES',
|
||||
value: '10.42.0.0/16,10.43.0.0/16',
|
||||
status: '활성',
|
||||
description: 'Pod/Service CIDR 접근용 광고 라우트'
|
||||
},
|
||||
{
|
||||
name: 'Platform Gateway 연동',
|
||||
key: 'TAILSCALE_GATEWAY_PROVIDER',
|
||||
value: 'platform-gateway',
|
||||
status: '활성',
|
||||
description: '런처에는 연결 상태만 제공하고 세부 조회는 플랫폼 게이트웨이가 담당'
|
||||
}
|
||||
];
|
||||
|
||||
export const gatewayConnections = [
|
||||
{ name: 'Headscale Server', endpoint: 'https://headscale.autonomystudio.local', role: '노드/ACL 동기화', status: '활성', lastSync: '1분 전' },
|
||||
{ name: 'Autonomy Agent', endpoint: 'agent heartbeat stream', role: 'Agent 등록/상태 집계', status: '활성', lastSync: '30초 전' },
|
||||
{ name: 'Platform Gitea', endpoint: 'https://gitea.autonomystudio.local', role: '런처/앱 소스 버전 저장소', status: '활성', lastSync: '5분 전' },
|
||||
{ name: 'Platform Runtime', endpoint: 'runtime command bus', role: '원격 명령/로그/이벤트', status: '주의', lastSync: '12분 전' }
|
||||
];
|
||||
|
||||
export const agentDevices = [
|
||||
{ name: 'lab-mkmini-01', account: 'student01@example.com', status: '활성', workspace: '~/autonomy-studio/workspaces', sshKey: '배포됨', lastSeen: '2분 전' },
|
||||
{ name: 'lab-f1tenth-03', account: 'student03@example.com', status: '활성', workspace: '~/autonomy-studio/workspaces', sshKey: '배포됨', lastSeen: '4분 전' },
|
||||
{ name: 'sim-runner-02', account: 'instructor@example.com', status: '주의', workspace: '/opt/studio/workspaces', sshKey: '대기', lastSeen: '35분 전' }
|
||||
];
|
||||
|
||||
export const runtimeCommands = [
|
||||
{ target: 'lab-mkmini-01', command: 'workspace sync', status: '완료', requestedBy: 'park.ops', time: '8분 전' },
|
||||
{ target: 'lab-f1tenth-03', command: 'build module', status: '실행 중', requestedBy: 'kim.admin', time: '3분 전' },
|
||||
{ target: 'sim-runner-02', command: 'collect logs', status: '주의', requestedBy: 'choi.dev', time: '22분 전' }
|
||||
];
|
||||
|
||||
export const notices = [
|
||||
{ scope: '런처', title: 'v1.0.1 업데이트 안내', audience: '전체 사용자', status: '게시됨', date: '04.23.' },
|
||||
{ scope: 'ADS IDE', title: '정기 점검 안내', audience: 'ADS IDE 사용자', status: '게시됨', date: '04.10.' },
|
||||
{ scope: 'LMS', title: '신규 과제 검토 요청', audience: '강사', status: '게시됨', date: '06.02.' },
|
||||
{ scope: '런처', title: '로그인 화면 점검 배너', audience: '전체 사용자', status: '예약됨', date: '06.24.' },
|
||||
{ scope: 'FMS', title: 'FMS 릴리즈 사전 안내', audience: 'FMS 사용자', status: '초안', date: '06.25.' },
|
||||
{ scope: 'ADS Education', title: '교육 콘텐츠 업데이트', audience: 'ADS Education 사용자', status: '게시됨', date: '06.12.' },
|
||||
{ scope: 'Data Management', title: '베타 신청 접수', audience: '운영자', status: '초안', date: '06.18.' },
|
||||
{ scope: '런처', title: '구버전 런처 지원 종료 안내', audience: '전체 사용자', status: '예약됨', date: '07.01.' },
|
||||
{ scope: 'Calibration', title: '캘리브레이션 템플릿 변경', audience: 'Calibration 사용자', status: '게시됨', date: '06.09.' },
|
||||
{ scope: 'HDMAP Builder', title: 'HDMAP Builder 등록 예정', audience: '운영자', status: '초안', date: '06.28.' },
|
||||
{ scope: '런처', title: '파일 서버 점검 공지', audience: '전체 사용자', status: '게시됨', date: '06.15.' },
|
||||
{ scope: 'ADS IDE', title: 'IDE 확장 기능 업데이트', audience: 'ADS IDE 사용자', status: '예약됨', date: '06.30.' }
|
||||
];
|
||||
|
||||
export const alerts = [
|
||||
{ icon: Bell, label: '확인이 필요한 워크로드 3개' },
|
||||
{ icon: FileKey, label: '14일 내 만료되는 라이센스 42개' },
|
||||
{ icon: KeyRound, label: '이번 주 교체된 시크릿 5개' }
|
||||
];
|
||||
+24
-10
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Boxes,
|
||||
ChartNoAxesColumnIncreasing,
|
||||
FileKey,
|
||||
HardDrive,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
MonitorPlay,
|
||||
MonitorUp,
|
||||
Network,
|
||||
Rocket,
|
||||
ScrollText,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
@@ -34,9 +38,19 @@ export const navGroups: NavGroup[] = [
|
||||
label: '플랫폼',
|
||||
items: [
|
||||
{ label: '대시보드', path: '/dashboard', icon: LayoutDashboard },
|
||||
{
|
||||
label: '공통앱',
|
||||
icon: ServerCog,
|
||||
children: [
|
||||
{ label: '소스 관리', path: '/common-apps/sources' },
|
||||
{ label: '빌드 관리', path: '/common-apps/builds' },
|
||||
{ label: '구성요소', path: '/common-apps/components' },
|
||||
{ label: '로그/이벤트', path: '/common-apps/logs' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '런처',
|
||||
icon: Rocket,
|
||||
icon: MonitorPlay,
|
||||
children: [
|
||||
{ label: '소스 관리', path: '/launcher/sources' },
|
||||
{ label: '빌드 관리', path: '/launcher/builds' },
|
||||
@@ -68,17 +82,15 @@ export const navGroups: NavGroup[] = [
|
||||
{ label: '노드 현황', path: '/tailscale/status' },
|
||||
{ label: '라우트', path: '/tailscale/routes' },
|
||||
{ label: 'ACL', path: '/tailscale/acl' },
|
||||
{ label: '연결 진단', path: '/tailscale/diagnostics' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '에이전트',
|
||||
icon: ShieldCheck,
|
||||
label: '플랫폼 관리',
|
||||
icon: MonitorUp,
|
||||
children: [
|
||||
{ label: '장비 등록', path: '/agents/registration' },
|
||||
{ label: '장비 제어', path: '/runtime/devices' },
|
||||
{ label: '설치 상태', path: '/agents/install-status' },
|
||||
{ label: 'SSH 키 배포', path: '/agents/ssh-keys' },
|
||||
{ label: '플랫폼 등록', path: '/agents/registration' },
|
||||
{ label: '연결 제어', path: '/runtime/devices' },
|
||||
{ label: 'SSH 키', path: '/agents/ssh-keys' },
|
||||
{ label: '원격 명령', path: '/agents/commands' }
|
||||
]
|
||||
},
|
||||
@@ -101,7 +113,9 @@ export const navGroups: NavGroup[] = [
|
||||
{ label: '조직 관리', path: '/operations/organizations', icon: Boxes },
|
||||
{ label: '사용자 관리', path: '/operations/users', icon: Users },
|
||||
{ label: '라이센스', path: '/operations/licenses', icon: FileKey },
|
||||
{ label: '공지사항', path: '/operations/notices', icon: Megaphone }
|
||||
{ label: '공지사항', path: '/operations/notices', icon: Megaphone },
|
||||
{ label: '약관', path: '/operations/terms', icon: ScrollText },
|
||||
{ label: '매뉴얼', path: '/operations/manuals', icon: BookOpen }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+284
-90
@@ -1,18 +1,31 @@
|
||||
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, SlidersHorizontal } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
||||
import {
|
||||
agentDevices,
|
||||
alerts,
|
||||
gatewayConnections,
|
||||
k3sWorkloads,
|
||||
launcherVersions,
|
||||
runtimeCommands,
|
||||
tailscaleSettings
|
||||
} from '../data/mock';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { PageTitle } from '../layout/AppLayout';
|
||||
import {
|
||||
cancelDeviceAuthorization,
|
||||
createDeviceAuthorization,
|
||||
loadDeviceAuthorizations,
|
||||
loadPlatformDevices,
|
||||
loadPlatformSshKeys,
|
||||
searchPlatformRegistrationUsers,
|
||||
type DeviceAuthorization,
|
||||
type PlatformDevice,
|
||||
type PlatformRegistrationUser,
|
||||
type PlatformSshKey,
|
||||
} from './agents/platform-api';
|
||||
|
||||
const launcherVersions: Array<{
|
||||
name: string; status: string; latestVersion: string; minimumSupportedVersion?: string;
|
||||
artifact: string; downloadUrl: string; releaseNotes: string;
|
||||
}> = [];
|
||||
const tailscaleSettings: Array<{ key: string; name: string; description: string; value: string; status: string }> = [];
|
||||
const gatewayConnections: Array<{ name: string; status: string; role: string; endpoint: string; lastSync: string }> = [];
|
||||
const runtimeCommands: Array<{ target: string; command: string; requestedBy: string; time: string; status: string }> = [];
|
||||
const k3sWorkloads: Array<{ workload: string; namespace: string; pod: string; replicas: string; cpu: number; memory: number; status: string }> = [];
|
||||
const alerts: Array<{ label: string; icon: typeof Settings2 }> = [];
|
||||
|
||||
type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
||||
|
||||
@@ -782,81 +795,222 @@ export function RuntimeDevicesPage() {
|
||||
<AgentTable
|
||||
title="장비 목록"
|
||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||
withCommands
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentTable({ title, description, withCommands = false }: { title: string; description: string; withCommands?: boolean }) {
|
||||
function AgentTable({ title, description }: { title: string; description: string }) {
|
||||
const [devices, setDevices] = useState<PlatformDevice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadPlatformDevices()
|
||||
.then(setDevices)
|
||||
.catch(() => setDevices([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<PlatformDevice>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'platform',
|
||||
header: '플랫폼',
|
||||
flexible: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{row.hostname}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground" title={row.tailscaleHostname || '-'}>{row.tailscaleHostname || '-'}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'account', header: '계정', width: 210, cell: (row) => <span className="block truncate text-muted-foreground" title={row.accountEmail}>{row.accountEmail}</span> },
|
||||
{ key: 'workspace', header: 'Workspace', width: 280, cell: (row) => <span className="block truncate text-muted-foreground" title={row.workspaceRoot || '-'}>{row.workspaceRoot || '-'}</span> },
|
||||
{ key: 'ssh', header: 'SSH 사용자', width: 130, cell: (row) => row.sshUser || '-' },
|
||||
{ key: 'lastSeen', header: '최근 연결', width: 180, cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatTailscaleLastSeen(row.lastProvisionedAt)}</span> },
|
||||
{ key: 'status', header: '상태', width: 110, cell: (row) => <StatusBadge status={row.status} /> }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
withCommands ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
상태 필터
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<RefreshCcw className="size-4" />
|
||||
상태 갱신
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[1040px] 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">계정</th>
|
||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
{withCommands ? <th className="px-5 py-3 text-right font-medium">명령</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{agentDevices.map((device) => (
|
||||
<tr className="hover:bg-muted/20" key={device.name}>
|
||||
<td className="px-5 py-3 font-medium">{device.name}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
||||
<td className="px-5 py-3">{device.sshKey}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
||||
<td className="px-5 py-3"><StatusBadge status={device.status} /></td>
|
||||
{withCommands ? (
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
||||
<Button variant="ghost" className="size-8 px-0" aria-label="상세">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataTableCard
|
||||
title={title}
|
||||
description={description}
|
||||
rows={devices}
|
||||
columns={columns}
|
||||
searchPlaceholder="플랫폼명, 계정, 워크스페이스 검색"
|
||||
getSearchText={(row) => `${row.hostname} ${row.tailscaleHostname ?? ''} ${row.accountEmail} ${row.workspaceRoot ?? ''} ${row.sshUser ?? ''}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1080}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 플랫폼이 없습니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentRegistrationPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [users, setUsers] = useState<PlatformRegistrationUser[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<PlatformRegistrationUser | null>(null);
|
||||
const [authorizations, setAuthorizations] = useState<DeviceAuthorization[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [issuing, setIssuing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [cancelTarget, setCancelTarget] = useState<DeviceAuthorization | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadDeviceAuthorizations()
|
||||
.then(setAuthorizations)
|
||||
.catch(() => setAuthorizations([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const value = keyword.trim();
|
||||
if (value.length < 2 || selectedUser) {
|
||||
setUsers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
searchPlatformRegistrationUsers(value).then(setUsers).catch(() => setUsers([]));
|
||||
}, 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [keyword, selectedUser]);
|
||||
|
||||
const issue = async () => {
|
||||
if (!selectedUser || issuing) return;
|
||||
setIssuing(true);
|
||||
setError('');
|
||||
try {
|
||||
await createDeviceAuthorization(selectedUser.id);
|
||||
await refresh();
|
||||
} catch (issueError) {
|
||||
setError(issueError instanceof Error ? issueError.message : '플랫폼 등록 키를 발급하지 못했습니다.');
|
||||
} finally {
|
||||
setIssuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
if (!cancelTarget) return;
|
||||
setError('');
|
||||
try {
|
||||
await cancelDeviceAuthorization(cancelTarget.id);
|
||||
setCancelTarget(null);
|
||||
await refresh();
|
||||
} catch (cancelError) {
|
||||
setError(cancelError instanceof Error ? cancelError.message : '플랫폼 등록 키를 취소하지 못했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<DeviceAuthorization>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'user',
|
||||
header: '대상 사용자',
|
||||
flexible: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{row.authUserName || row.accountEmail || '-'}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{row.accountEmail || '-'}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'code', header: '등록 키', width: 150, cell: (row) => <span className="font-mono font-semibold tracking-[0.12em]">{row.userCode}</span> },
|
||||
{ key: 'expires', header: '만료', width: 190, cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatTailscaleLastSeen(row.expiresAt)}</span> },
|
||||
{
|
||||
key: 'status',
|
||||
header: '상태',
|
||||
width: 120,
|
||||
cell: (row) => <StatusBadge status={row.status === 'APPROVED' ? '발급됨' : row.status === 'CANCELLED' ? '취소됨' : row.status === 'CONSUMED' ? '사용됨' : row.status === 'EXPIRED' ? '만료됨' : row.status} badgeTone={row.status === 'APPROVED' ? 'green' : row.status === 'CANCELLED' || row.status === 'EXPIRED' ? 'neutral' : undefined} />
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => row.status === 'APPROVED' ? <div className="flex justify-end"><Button variant="ghost" className="h-8 px-2 text-[13px] text-rose-300 hover:text-rose-200" onClick={() => setCancelTarget(row)}>취소</Button></div> : null,
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="장비 등록" description="새 장비에 설치할 Agent 등록 토큰과 연결 정보를 발급합니다." />
|
||||
<AgentPlaceholder title="장비 등록 요청" description="Agent 등록 요청과 토큰 발급 상태를 확인합니다." />
|
||||
<PageTitle eyebrow="플랫폼 관리" title="플랫폼 등록" description="에이전트 설치에 사용할 사용자별 6자리 등록 키를 발급하고 관리합니다." />
|
||||
<Card className="mb-4">
|
||||
<CardHeader title="플랫폼 등록 키" description="대상 사용자를 선택한 뒤 발급한 키와 계정 이메일을 에이전트 설치 화면에 입력합니다." />
|
||||
<div className="border-t border-border p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<label className="relative min-w-0 flex-1">
|
||||
<span className="mb-2 block text-xs font-medium text-muted-foreground">대상 사용자</span>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="이름 또는 이메일 검색"
|
||||
value={selectedUser ? `${selectedUser.name || selectedUser.email} · ${selectedUser.email}` : keyword}
|
||||
onChange={(event) => {
|
||||
setSelectedUser(null);
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{users.length ? (
|
||||
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border border-border bg-card py-1 shadow-xl">
|
||||
{users.map((user) => (
|
||||
<button key={user.id} type="button" className="block w-full px-3 py-2 text-left hover:bg-muted" onClick={() => { setSelectedUser(user); setKeyword(''); setUsers([]); }}>
|
||||
<span className="block text-sm font-medium">{user.name || user.email}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{user.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
{selectedUser ? <Button variant="outline" className="h-9 px-3 text-[13px]" onClick={() => { setSelectedUser(null); setKeyword(''); }}>선택 해제</Button> : null}
|
||||
<Button className="h-9 px-3 text-[13px]" disabled={!selectedUser || issuing} onClick={() => void issue()}>
|
||||
키 발급
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <p className="mt-3 text-sm text-rose-300">{error}</p> : null}
|
||||
</div>
|
||||
</Card>
|
||||
<DataTableCard
|
||||
title="등록 키 목록"
|
||||
description="활성 키와 최근 24시간 내 발급된 키를 확인합니다."
|
||||
rows={authorizations}
|
||||
columns={columns}
|
||||
searchPlaceholder="사용자명, 이메일, 등록 키 검색"
|
||||
getSearchText={(row) => `${row.authUserName ?? ''} ${row.accountEmail ?? ''} ${row.userCode} ${row.status}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={920}
|
||||
loading={loading}
|
||||
emptyMessage="발급된 플랫폼 등록 키가 없습니다."
|
||||
/>
|
||||
<Modal
|
||||
open={Boolean(cancelTarget)}
|
||||
title="등록 키 취소"
|
||||
onClose={() => setCancelTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCancelTarget(null)}>닫기</Button>
|
||||
<Button className="bg-rose-600 text-white hover:bg-rose-500" onClick={() => void cancel()}>취소</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">선택한 등록 키를 취소하면 에이전트 설치에 더 이상 사용할 수 없습니다.</p>
|
||||
<p className="mt-3 font-mono text-sm font-semibold tracking-[0.12em] text-foreground">{cancelTarget?.userCode}</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -864,17 +1018,68 @@ export function AgentRegistrationPage() {
|
||||
export function AgentInstallStatusPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="설치 상태" description="장비별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
||||
<AgentPlaceholder title="설치 상태" description="설치 완료, 대기, 실패 상태를 확인합니다." />
|
||||
<PageTitle eyebrow="플랫폼 관리" title="연결 상태" description="플랫폼별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
||||
<AgentTable title="연결 상태" description="플랫폼별 Agent 연결 상태를 확인합니다." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSshKeysPage() {
|
||||
const [keys, setKeys] = useState<PlatformSshKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedKey, setSelectedKey] = useState<PlatformSshKey | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlatformSshKeys()
|
||||
.then(setKeys)
|
||||
.catch(() => setKeys([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="SSH 키 배포" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
||||
<AgentPlaceholder title="SSH 키 배포" description="장비별 SSH 키 배포 상태입니다." />
|
||||
<PageTitle eyebrow="플랫폼 관리" title="SSH 키" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
||||
<DataTableCard
|
||||
title="SSH 키 목록"
|
||||
description="등록 플랫폼 계정에 연결된 SSH 키입니다."
|
||||
rows={keys}
|
||||
columns={[
|
||||
{ key: 'account', header: '계정', flexible: true, cell: (row) => row.accountEmail },
|
||||
{ key: 'label', header: '이름', width: 180, cell: (row) => row.label || '-' },
|
||||
{ key: 'type', header: '유형', width: 140, cell: (row) => row.keyType || '-' },
|
||||
{ key: 'updated', header: '최근 등록', width: 180, cell: (row) => formatTailscaleLastSeen(row.lastProvisionedAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={() => setSelectedKey(row)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
getRowKey={(row) => row.id}
|
||||
getSearchText={(row) => `${row.accountEmail} ${row.label ?? ''} ${row.publicKeyHash}`}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 SSH 키가 없습니다."
|
||||
/>
|
||||
<Modal open={Boolean(selectedKey)} title="SSH 키 지문" onClose={() => setSelectedKey(null)}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">{selectedKey?.label || selectedKey?.accountEmail}</p>
|
||||
<p className="break-all rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs text-foreground">
|
||||
{selectedKey?.publicKeyHash}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -893,7 +1098,7 @@ export function AgentCommandsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
||||
<PageTitle eyebrow="플랫폼 관리" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
||||
<DataTableCard
|
||||
title="최근 제어 작업"
|
||||
rows={runtimeCommands}
|
||||
@@ -906,17 +1111,6 @@ export function AgentCommandsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPlaceholder({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title={title} description={description} />
|
||||
<div className="p-5">
|
||||
<AgentTable title="Agent 장비" description="등록된 장비 목록입니다." />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitoringPage() {
|
||||
const columns = useMemo<Array<DataTableColumn<(typeof k3sWorkloads)[number]>>>(
|
||||
() => [
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { apiRequest } from '../../lib/api';
|
||||
|
||||
export type PlatformDevice = {
|
||||
id: string;
|
||||
accountEmail: string;
|
||||
hostname: string;
|
||||
tailscaleHostname: string;
|
||||
sshUser?: string | null;
|
||||
workspaceRoot?: string | null;
|
||||
usageType: string;
|
||||
os?: string | null;
|
||||
arch?: string | null;
|
||||
distro?: string | null;
|
||||
installedAt: string;
|
||||
lastProvisionedAt: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PlatformSshKey = {
|
||||
id: string;
|
||||
accountEmail: string;
|
||||
label?: string | null;
|
||||
publicKeyHash: string;
|
||||
keyType?: string | null;
|
||||
lastProvisionedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformRegistrationUser = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type DeviceAuthorization = {
|
||||
id: string;
|
||||
userCode: string;
|
||||
status: string;
|
||||
authUserId?: string | null;
|
||||
accountEmail?: string | null;
|
||||
authUserName?: string | null;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function loadPlatformDevices() {
|
||||
const response = await apiRequest<{ devices?: PlatformDevice[] }>('/agents/devices');
|
||||
return response.devices ?? [];
|
||||
}
|
||||
|
||||
export async function loadPlatformSshKeys() {
|
||||
const response = await apiRequest<{ sshKeys?: PlatformSshKey[] }>('/agents/ssh-keys');
|
||||
return response.sshKeys ?? [];
|
||||
}
|
||||
|
||||
export async function searchPlatformRegistrationUsers(keyword: string) {
|
||||
const response = await apiRequest<{ users?: PlatformRegistrationUser[] }>(`/agents/registration-users?keyword=${encodeURIComponent(keyword)}`);
|
||||
return response.users ?? [];
|
||||
}
|
||||
|
||||
export async function loadDeviceAuthorizations() {
|
||||
const response = await apiRequest<{ authorizations?: DeviceAuthorization[] }>('/agents/device-authorizations');
|
||||
return response.authorizations ?? [];
|
||||
}
|
||||
|
||||
export async function createDeviceAuthorization(userId: string) {
|
||||
const response = await apiRequest<{ authorization: DeviceAuthorization }>('/agents/device-authorizations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
return response.authorization;
|
||||
}
|
||||
|
||||
export async function cancelDeviceAuthorization(id: string) {
|
||||
return apiRequest<{ ok: boolean }>(`/agents/device-authorizations/${id}/cancel`, { method: 'POST' });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Plus, RotateCcw, Server, Settings, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
@@ -57,6 +57,8 @@ export function CommonAppComponentsPage() {
|
||||
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
|
||||
const [envLoading, setEnvLoading] = useState(false);
|
||||
const [envSaving, setEnvSaving] = useState(false);
|
||||
const envTableBodyRef = useRef<HTMLDivElement>(null);
|
||||
const newEnvKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
const loadingVisible = useMinimumLoading(loading || envLoading);
|
||||
|
||||
const refresh = () => {
|
||||
@@ -78,7 +80,9 @@ export function CommonAppComponentsPage() {
|
||||
setEnvVariables([]);
|
||||
setEnvLoading(true);
|
||||
loadRuntimeWorkloadEnv(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||
.then((result) => setEnvVariables(result.variables ?? []))
|
||||
.then((result) => {
|
||||
setEnvVariables([...(result.variables ?? [])].sort((left, right) => left.key.localeCompare(right.key)));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
||||
})
|
||||
@@ -95,6 +99,14 @@ export function CommonAppComponentsPage() {
|
||||
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
||||
};
|
||||
|
||||
const addEnvVariable = () => {
|
||||
setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }]);
|
||||
window.requestAnimationFrame(() => {
|
||||
envTableBodyRef.current?.scrollTo({ top: envTableBodyRef.current.scrollHeight, behavior: 'smooth' });
|
||||
newEnvKeyInputRef.current?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const saveEnv = () => {
|
||||
if (!envTarget) return;
|
||||
setEnvSaving(true);
|
||||
@@ -222,9 +234,15 @@ export function CommonAppComponentsPage() {
|
||||
open={Boolean(envTarget)}
|
||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||
onClose={() => setEnvTarget(null)}
|
||||
className="!max-w-4xl"
|
||||
className="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col"
|
||||
bodyClassName="!flex !min-h-0 !max-h-none !flex-1 !overflow-hidden"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={addEnvVariable} disabled={envLoading}>
|
||||
<Plus className="size-4" />
|
||||
설정값 추가
|
||||
</Button>
|
||||
<span className="flex-1" />
|
||||
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
||||
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
||||
{envSaving ? '저장 중' : '저장'}
|
||||
@@ -232,8 +250,8 @@ export function CommonAppComponentsPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border">
|
||||
<table className="w-full table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[26%]" />
|
||||
@@ -266,6 +284,7 @@ export function CommonAppComponentsPage() {
|
||||
<tr key={`${variable.key}-${index}`}>
|
||||
<td className="min-w-0 px-3 py-2.5">
|
||||
<input
|
||||
ref={index === envVariables.length - 1 && variable.editable ? newEnvKeyInputRef : undefined}
|
||||
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}
|
||||
@@ -312,14 +331,6 @@ export function CommonAppComponentsPage() {
|
||||
</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>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Braces, RotateCcw } from 'lucide-react';
|
||||
import { FileCode2, RotateCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
@@ -230,7 +230,7 @@ function ResourceTable({
|
||||
aria-label="YAML 보기"
|
||||
onClick={() => openYaml(row)}
|
||||
>
|
||||
<Braces className="size-4" />
|
||||
<FileCode2 className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Database, FileArchive, Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
@@ -142,6 +142,8 @@ export function LauncherComponentsPage() {
|
||||
const [envVariables, setEnvVariables] = useState<Array<{ key: string; value: string; isSecret: boolean; editable: boolean; source: string }>>([]);
|
||||
const [envLoading, setEnvLoading] = useState(false);
|
||||
const [envSaving, setEnvSaving] = useState(false);
|
||||
const envTableBodyRef = useRef<HTMLDivElement>(null);
|
||||
const newEnvKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
const loadingVisible = useMinimumLoading(loading || logLoading);
|
||||
|
||||
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
|
||||
@@ -201,7 +203,7 @@ export function LauncherComponentsPage() {
|
||||
setEnvLoading(true);
|
||||
loadRuntimeWorkloadEnv(LAUNCHER_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||
.then((result) => {
|
||||
setEnvVariables(result.variables ?? []);
|
||||
setEnvVariables([...(result.variables ?? [])].sort((left, right) => left.key.localeCompare(right.key)));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '설정값 조회 실패', message: error.message });
|
||||
@@ -219,6 +221,14 @@ export function LauncherComponentsPage() {
|
||||
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
|
||||
};
|
||||
|
||||
const addEnvVariable = () => {
|
||||
setEnvVariables((prev) => [...prev, { key: '', value: '', isSecret: false, editable: true, source: 'literal' }]);
|
||||
window.requestAnimationFrame(() => {
|
||||
envTableBodyRef.current?.scrollTo({ top: envTableBodyRef.current.scrollHeight, behavior: 'smooth' });
|
||||
newEnvKeyInputRef.current?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const saveEnv = () => {
|
||||
if (!envTarget?.kind || !envTarget.workloadName) return;
|
||||
setEnvSaving(true);
|
||||
@@ -417,9 +427,15 @@ export function LauncherComponentsPage() {
|
||||
open={Boolean(envTarget)}
|
||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||
onClose={() => setEnvTarget(null)}
|
||||
className="!max-w-4xl"
|
||||
className="!flex !h-[min(760px,calc(100vh-4rem))] !max-w-4xl !flex-col"
|
||||
bodyClassName="!flex !min-h-0 !max-h-none !flex-1 !overflow-hidden"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={addEnvVariable} disabled={envLoading}>
|
||||
<Plus className="size-4" />
|
||||
설정값 추가
|
||||
</Button>
|
||||
<span className="flex-1" />
|
||||
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
|
||||
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
|
||||
{envSaving ? '저장 중' : '저장'}
|
||||
@@ -427,11 +443,11 @@ export function LauncherComponentsPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Secret을 체크하면 Kubernetes Secret에 저장하고 secretKeyRef로 연결합니다. 체크하지 않은 값은 Deployment 환경변수로 저장합니다.
|
||||
</p>
|
||||
<div className="rounded-md border border-border">
|
||||
<div ref={envTableBodyRef} className="min-h-0 flex-1 overflow-y-auto rounded-md border border-border">
|
||||
<table className="w-full table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[26%]" />
|
||||
@@ -464,6 +480,7 @@ export function LauncherComponentsPage() {
|
||||
<tr key={`${variable.key}-${index}`}>
|
||||
<td className="min-w-0 px-3 py-2.5">
|
||||
<input
|
||||
ref={index === envVariables.length - 1 && variable.editable ? newEnvKeyInputRef : undefined}
|
||||
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}
|
||||
@@ -510,14 +527,6 @@ export function LauncherComponentsPage() {
|
||||
</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>
|
||||
|
||||
|
||||
@@ -1,86 +1,159 @@
|
||||
import { Activity, MoreHorizontal, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { MoreHorizontal, RotateCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn } from '../../components/data-table';
|
||||
import { Badge, Button, Modal } from '../../components/ui';
|
||||
import { apiRequest } from '../../lib/api';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { agentDevices } from '../../data/mock';
|
||||
import { loadPlatformDevices, type PlatformDevice } from '../agents/platform-api';
|
||||
|
||||
function getTone(status: string) {
|
||||
if (['정상', '활성', '성공', '운영', 'Running'].includes(status)) return 'green';
|
||||
if (['주의', '예약됨', '대기', '준비중', '카나리', 'Pending'].includes(status)) return 'amber';
|
||||
if (['오류', '실패', '비활성', '만료', 'Error', 'Failed'].includes(status)) return 'red';
|
||||
if (['검수', '동기화'].includes(status)) return 'blue';
|
||||
return 'neutral';
|
||||
type HeadscaleNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
ips?: string[];
|
||||
role: string;
|
||||
user: string;
|
||||
routes: string;
|
||||
lastSeen: string | null;
|
||||
status: string;
|
||||
online?: boolean;
|
||||
};
|
||||
|
||||
type AclRule = { id: string; action: string; sources: string[]; destinations: string[] };
|
||||
type PlatformRow = HeadscaleNode & { device?: PlatformDevice; controllers: string[] };
|
||||
|
||||
function normalize(value?: string | null) {
|
||||
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function findDevice(node: HeadscaleNode, devices: PlatformDevice[]) {
|
||||
const nodeValues = [node.name, node.user].map(normalize).filter(Boolean);
|
||||
return devices.find((device) => {
|
||||
const deviceValues = [device.hostname, device.tailscaleHostname, device.accountEmail].map(normalize).filter(Boolean);
|
||||
return nodeValues.some((nodeValue) => deviceValues.some((deviceValue) => nodeValue.includes(deviceValue) || deviceValue.includes(nodeValue)));
|
||||
});
|
||||
}
|
||||
|
||||
function findControllers(node: HeadscaleNode, rules: AclRule[]) {
|
||||
const destinations = new Set([node.ip, ...(node.ips ?? [])].filter(Boolean).map((ip) => `${ip}:*`));
|
||||
return [...new Set(rules
|
||||
.filter((rule) => rule.action === 'accept' && rule.destinations.some((destination) => destinations.has(destination)))
|
||||
.flatMap((rule) => rule.sources))];
|
||||
}
|
||||
|
||||
export function RuntimeDeviceControlPage() {
|
||||
const [rows, setRows] = useState<PlatformRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<PlatformRow | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
apiRequest<{ nodes?: HeadscaleNode[] }>('/tailscale/nodes'),
|
||||
apiRequest<{ policy?: { acls?: AclRule[] } }>('/tailscale/acl'),
|
||||
loadPlatformDevices(),
|
||||
])
|
||||
.then(([nodeResponse, aclResponse, devices]) => {
|
||||
const rules = aclResponse.policy?.acls ?? [];
|
||||
setRows((nodeResponse.nodes ?? []).map((node) => ({
|
||||
...node,
|
||||
device: findDevice(node, devices),
|
||||
controllers: findControllers(node, rules),
|
||||
})));
|
||||
})
|
||||
.catch(() => setRows([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<PlatformRow>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'platform',
|
||||
header: '플랫폼',
|
||||
flexible: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold" title={row.name}>{row.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground" title={row.user}>{row.user || '-'}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'connection',
|
||||
header: '연결 대상',
|
||||
width: 180,
|
||||
cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.ip || '-'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'controllers',
|
||||
header: '제어 사용자',
|
||||
width: 280,
|
||||
cell: (row) => (
|
||||
<span className="block truncate text-muted-foreground" title={row.controllers.length ? row.controllers.join(', ') : row.user || '-'}>
|
||||
{row.controllers.length ? row.controllers.join(', ') : row.user || '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'routes', header: '연결 경로', width: 190, cell: (row) => <span className="block truncate text-muted-foreground" title={row.routes || '-'}>{row.routes || '-'}</span> },
|
||||
{ key: 'status', header: '상태', width: 110, cell: (row) => <Badge tone={row.online || row.status === '활성' ? 'green' : 'neutral'}>{row.online || row.status === '활성' ? '연결됨' : '오프라인'}</Badge> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" className="size-8 px-0" title="상세" aria-label="상세" onClick={() => setSelected(row)}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="장비 제어" description="운영 장비의 런타임 시작, 중지, 재시작, 상태 조회 명령을 관리합니다." />
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title="장비 목록"
|
||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
상태 필터
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<Activity className="size-4" />
|
||||
상태 갱신
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="border-b border-border px-5 pb-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="장비명, 계정, 워크스페이스 검색"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[1040px] 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">계정</th>
|
||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
<th className="px-5 py-3 text-right font-medium">명령</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{agentDevices.map((device) => (
|
||||
<tr key={device.name} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3 font-medium">{device.name}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
||||
<td className="px-5 py-3">{device.sshKey}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={getTone(device.status)}>{device.status}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
||||
<Button variant="ghost" className="size-8 px-0">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<PageTitle
|
||||
eyebrow="플랫폼 관리"
|
||||
title="연결 제어"
|
||||
description="Headscale 노드 연결 상태와 ACL 기반 제어 권한을 확인합니다."
|
||||
actions={
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
||||
새로고침
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DataTableCard
|
||||
title="연결 플랫폼"
|
||||
description="Headscale에 등록된 노드와 해당 노드에 적용된 ACL 제어 사용자를 표시합니다."
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
searchPlaceholder="플랫폼, 사용자, IP, ACL 검색"
|
||||
getSearchText={(row) => `${row.name} ${row.user} ${row.ip} ${row.controllers.join(' ')} ${row.routes}`}
|
||||
getRowKey={(row) => row.id || row.name}
|
||||
pageSize={6}
|
||||
minWidth={1100}
|
||||
loading={loading}
|
||||
emptyMessage="Headscale에 연결된 플랫폼이 없습니다."
|
||||
/>
|
||||
<Modal open={Boolean(selected)} title="플랫폼 연결 상세" onClose={() => setSelected(null)}>
|
||||
<dl className="grid grid-cols-[140px_minmax(0,1fr)] gap-x-4 gap-y-4 text-sm">
|
||||
<dt className="text-muted-foreground">플랫폼</dt><dd className="min-w-0 break-all font-medium">{selected?.name}</dd>
|
||||
<dt className="text-muted-foreground">Headscale 사용자</dt><dd className="min-w-0 break-all">{selected?.user || '-'}</dd>
|
||||
<dt className="text-muted-foreground">연결 IP</dt><dd className="font-mono text-xs">{selected?.ips?.join(', ') || selected?.ip || '-'}</dd>
|
||||
<dt className="text-muted-foreground">ACL 제어 사용자</dt><dd className="min-w-0 break-all">{selected?.controllers.length ? selected.controllers.join(', ') : '개별 ACL 규칙 없음'}</dd>
|
||||
<dt className="text-muted-foreground">연결 경로</dt><dd className="min-w-0 break-all">{selected?.routes || '-'}</dd>
|
||||
<dt className="text-muted-foreground">Workspace</dt><dd className="min-w-0 break-all">{selected?.device?.workspaceRoot || '-'}</dd>
|
||||
<dt className="text-muted-foreground">SSH 사용자</dt><dd>{selected?.device?.sshUser || '-'}</dd>
|
||||
<dt className="text-muted-foreground">사용 유형</dt><dd>{selected?.device?.usageType || '-'}</dd>
|
||||
</dl>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user