From 4a79dab2f674d42d31b039395b578622b9212a18 Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 6 Aug 2026 16:01:33 +0900 Subject: [PATCH 1/3] feat: display platform agent status and version --- src/pages/agents/platform-api.ts | 3 ++ .../applications/DeploymentManagementPage.tsx | 4 ++- .../runtime/RuntimeDeviceControlPage.tsx | 29 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/pages/agents/platform-api.ts b/src/pages/agents/platform-api.ts index f998411..3ba6175 100644 --- a/src/pages/agents/platform-api.ts +++ b/src/pages/agents/platform-api.ts @@ -11,6 +11,9 @@ export type PlatformDevice = { os?: string | null; arch?: string | null; distro?: string | null; + agentVersion?: string | null; + agentUpdatedAt?: string | null; + agentLastSeenAt?: string | null; installedAt: string; lastProvisionedAt: string; status: string; diff --git a/src/pages/applications/DeploymentManagementPage.tsx b/src/pages/applications/DeploymentManagementPage.tsx index 9969d03..accac08 100644 --- a/src/pages/applications/DeploymentManagementPage.tsx +++ b/src/pages/applications/DeploymentManagementPage.tsx @@ -38,7 +38,9 @@ type ImageTags = { function releaseArtifacts(release?: AppRelease | null) { const imageTags = release?.imageTags as ImageTags | null | undefined; - const entries = Object.entries(imageTags?.images ?? {}); + const entries = Object.entries(imageTags?.images ?? {}).filter(([, image]) => ( + !/^(postgres|mysql|mariadb|mongo)(:|@)/i.test(image.trim()) + )); if (entries.length) return entries; if (release?.artifact) return [[release.app.name, release.artifact]]; return []; diff --git a/src/pages/runtime/RuntimeDeviceControlPage.tsx b/src/pages/runtime/RuntimeDeviceControlPage.tsx index fc100b9..8971c8f 100644 --- a/src/pages/runtime/RuntimeDeviceControlPage.tsx +++ b/src/pages/runtime/RuntimeDeviceControlPage.tsx @@ -22,6 +22,8 @@ type HeadscaleNode = { type AclRule = { id: string; action: string; sources: string[]; destinations: string[] }; type PlatformRow = HeadscaleNode & { device?: PlatformDevice; controllers: string[]; no: number }; +type DeviceKind = 'agent' | 'launcher' | 'unknown'; + function normalize(value?: string | null) { return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, ''); } @@ -41,6 +43,17 @@ function findControllers(node: HeadscaleNode, rules: AclRule[]) { .flatMap((rule) => rule.sources))]; } +function getDeviceKind(row: PlatformRow): DeviceKind { + if (!row.device) return 'unknown'; + return row.device.agentVersion || row.device.agentLastSeenAt ? 'agent' : 'launcher'; +} + +function formatDateTime(value?: string | null) { + if (!value) return '-'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString('ko-KR', { hour12: false }); +} + export function RuntimeDeviceControlPage() { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); @@ -91,6 +104,19 @@ export function RuntimeDeviceControlPage() { width: 180, cell: (row) => {row.ip || '-'}, }, + { + key: 'deviceKind', + header: '유형', + width: 150, + cell: (row) => { + const kind = getDeviceKind(row); + if (kind === 'agent') { + return 에이전트; + } + if (kind === 'launcher') return 런처 클라이언트; + return 미확인; + }, + }, { key: 'controllers', header: '제어 사용자', @@ -155,6 +181,9 @@ export function RuntimeDeviceControlPage() {
Workspace
{selected?.device?.workspaceRoot || '-'}
SSH 사용자
{selected?.device?.sshUser || '-'}
사용 유형
{selected?.device?.usageType || '-'}
+
장비 유형
{selected ? ({ agent: '에이전트', launcher: '런처 클라이언트', unknown: '미확인' }[getDeviceKind(selected)]) : '-'}
+
에이전트 버전
{selected?.device?.agentVersion || '-'}
+
마지막 에이전트 확인
{formatDateTime(selected?.device?.agentLastSeenAt)}
From d746d9d0dc6bc4b2aaffcb6f475d13c1b55335a9 Mon Sep 17 00:00:00 2001 From: chyoo Date: Fri, 7 Aug 2026 10:24:27 +0900 Subject: [PATCH 2/3] add icon.. --- src/pages/RestoredPages.tsx | 55 +++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/pages/RestoredPages.tsx b/src/pages/RestoredPages.tsx index 43662d6..909e77d 100644 --- a/src/pages/RestoredPages.tsx +++ b/src/pages/RestoredPages.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2 } from 'lucide-react'; +import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, Trash2 } 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'; @@ -331,6 +331,8 @@ export function TailscaleStatusPage() { const [source, setSource] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); const online = nodes.filter((node) => node.status === '활성' || node.online).length; const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length; @@ -353,6 +355,20 @@ export function TailscaleStatusPage() { refresh(); }, []); + const deleteNode = () => { + if (!deleteTarget) return; + setDeleting(true); + setError(''); + apiRequest<{ deleted: boolean }>(`/tailscale/nodes/${encodeURIComponent(deleteTarget.id)}`, { method: 'DELETE' }) + .then(() => { + setNodes((current) => current.filter((node) => node.id !== deleteTarget.id)); + setDeleteTarget(null); + refresh(); + }) + .catch((requestError: Error) => setError(requestError.message)) + .finally(() => setDeleting(false)); + }; + return ( <> ) : null}
- +
+ !deleting && setDeleteTarget(null)} + footer={ + <> + + + + } + > +

+ {deleteTarget?.name} 노드를 Headscale에서 삭제합니다. 연결된 기기의 VPN 연결은 즉시 끊기며, 다시 사용하려면 재등록해야 합니다. +

+
); } -function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) { +function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode[]; loading: boolean; onDelete: (node: TailscaleNode) => void }) { const pageSize = 10; const [page, setPage] = useState(1); const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize)); @@ -420,17 +453,18 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin Routes Last Seen 상태 + 관리 {loading && nodes.length === 0 ? ( - 노드 정보를 불러오는 중입니다. + 노드 정보를 불러오는 중입니다. ) : null} {!loading && nodes.length === 0 ? ( - 표시할 노드가 없습니다. + 표시할 노드가 없습니다. ) : null} {pageNodes.map((node) => ( @@ -442,6 +476,17 @@ function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loadin {node.routes} {formatTailscaleLastSeen(node.lastSeen)} + + + ))} From b65781fd554c266c5277b1b798d9296159ecd13a Mon Sep 17 00:00:00 2001 From: chyoo Date: Fri, 7 Aug 2026 11:57:38 +0900 Subject: [PATCH 3/3] add instructor invite code.. --- src/pages/operations/OrganizationsPage.tsx | 1 + src/pages/operations/UserSessionsPage.tsx | 34 +++++++++++++++++++++- src/pages/operations/operations-api.ts | 9 ++++++ src/types/application.ts | 1 + 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/pages/operations/OrganizationsPage.tsx b/src/pages/operations/OrganizationsPage.tsx index 22149d1..f3694d1 100644 --- a/src/pages/operations/OrganizationsPage.tsx +++ b/src/pages/operations/OrganizationsPage.tsx @@ -136,6 +136,7 @@ export function OrganizationsPage() { }, { key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => }, { key: 'license', header: '라이센스', className: 'w-36 px-5 py-3', cell: (row) => row.defaultLicenseCode ? : - }, + { key: 'instructorInviteCode', header: '강사 가입 코드', className: 'w-40 px-5 py-3', cell: (row) => {row.instructorInviteCode} }, { key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => {row.domain || '-'} }, { key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => }, { diff --git a/src/pages/operations/UserSessionsPage.tsx b/src/pages/operations/UserSessionsPage.tsx index c0d3571..4ecfaf4 100644 --- a/src/pages/operations/UserSessionsPage.tsx +++ b/src/pages/operations/UserSessionsPage.tsx @@ -13,6 +13,7 @@ import { lockUser, terminateUserSession, unlockUser, + updateLmsInstructor, type LicensePlan, type Organization, type UserSession @@ -45,6 +46,7 @@ export function UserSessionsPage() { const [createSaving, setCreateSaving] = useState(false); const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null); const [detailSession, setDetailSession] = useState(null); + const [lmsRoleSaving, setLmsRoleSaving] = useState(false); const loadingVisible = useMinimumLoading(loading); const refresh = () => { @@ -183,6 +185,24 @@ export function UserSessionsPage() { .finally(() => setCreateSaving(false)); }; + const setLmsInstructor = (isInstructor: boolean) => { + if (!detailSession) return; + + setLmsRoleSaving(true); + updateLmsInstructor(detailSession.userId, isInstructor) + .then(() => { + setDetailSession((current) => current ? { ...current, isLmsInstructor: isInstructor } : current); + setSessions((current) => current.map((session) => ( + session.userId === detailSession.userId ? { ...session, isLmsInstructor: isInstructor } : session + ))); + toast({ type: 'success', title: 'LMS 역할 변경', message: isInstructor ? '강사 권한을 부여했습니다.' : '강사 권한을 해제했습니다.' }); + }) + .catch((error: Error) => { + toast({ type: 'error', title: 'LMS 역할 변경 실패', message: error.message }); + }) + .finally(() => setLmsRoleSaving(false)); + }; + return ( <> -
+
+ diff --git a/src/pages/operations/operations-api.ts b/src/pages/operations/operations-api.ts index c3c2623..5300c01 100644 --- a/src/pages/operations/operations-api.ts +++ b/src/pages/operations/operations-api.ts @@ -70,6 +70,7 @@ export type UserSession = { expiresAt?: string | null; lastSeen?: string | null; state: string; + isLmsInstructor?: boolean; primaryAffiliation?: { id: string; organizationId: string; @@ -90,6 +91,7 @@ export type Organization = { domain?: string | null; defaultLicenseCode?: string | null; defaultLicenseName?: string | null; + instructorInviteCode: string; createdById?: string | null; createdByName?: string | null; createdByEmail?: string | null; @@ -257,6 +259,13 @@ export function unlockUser(userId: string) { }); } +export function updateLmsInstructor(userId: string, isInstructor: boolean) { + return apiRequest<{ userId: string; isInstructor: boolean }>(`/users/${userId}/lms-instructor`, { + method: 'PATCH', + body: JSON.stringify({ isInstructor }) + }); +} + export function terminateUserSession(sessionId: string) { return apiRequest(`/users/sessions/${sessionId}`, { method: 'DELETE' diff --git a/src/types/application.ts b/src/types/application.ts index 1d3f9a7..69a7e82 100644 --- a/src/types/application.ts +++ b/src/types/application.ts @@ -21,6 +21,7 @@ export type BuildJob = { branch: string; commitSha?: string | null; artifact?: string | null; + artifactSha256?: string | null; image?: string | null; status: JobStatus; logs?: string | null;