Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a814716b7 | |||
| 37e0ac34c6 | |||
| 23e3d6e480 | |||
| 0d216e2f03 | |||
| 3a46232d4f | |||
| d37283093a | |||
| d897cba32c | |||
| b41941d425 | |||
| d87c92ff71 | |||
| ddf3aa9784 | |||
| e755f245f3 | |||
| 917b9b6071 | |||
| 35fe3c94b1 | |||
| 813fef4318 | |||
| af5fd60937 | |||
| f895fa8edc | |||
| 01c82ab17c | |||
| 994520b754 | |||
| 353bf8ea73 | |||
| a0cc8b9772 | |||
| c109f344d2 | |||
| 32412d922a | |||
| 4afc3fc05d | |||
| bf39f82698 | |||
| 384c66687e | |||
| a2952a44a7 | |||
| dc683fce42 | |||
| 2a540a48b0 | |||
| cad561dfd0 | |||
| dd2fc93a59 | |||
| e1a5fd54db | |||
| 6c14203e8f | |||
| be3f36f6d9 | |||
| f510b1957e | |||
| 5bb42621d0 | |||
| e3ae6e3f43 | |||
| 556f5866a7 | |||
| c7a6693ce4 | |||
| 15d993555f | |||
| 39b55ad479 | |||
| 1b49256af6 | |||
| 81fc8f842a | |||
| 44c6c08a02 | |||
| 38a13f29fd | |||
| 840fa0a604 | |||
| c594c1cb85 | |||
| e4b968e5dd | |||
| 48b81dd5cf | |||
| 3b471245c7 | |||
| 5ebab95a8b | |||
| 8acbb760c8 | |||
| 8d37cf86a2 | |||
| 310daaa424 | |||
| d08a1a1a79 | |||
| c4c2a845ea | |||
| e1d9d803d5 | |||
| 79e2e8443d | |||
| 86e4330066 | |||
| 5ec75d68ab | |||
| 5bae18678c | |||
| 641166ba27 | |||
| e6bbaf4971 | |||
| 039a2ca285 | |||
| c345dade10 | |||
| 50e9b1d5ee | |||
| e08ae4639b | |||
| 3bb54af459 | |||
| 99f362fda1 | |||
| 622fe6e4b3 | |||
| bbda7887ee | |||
| e2bb9075bd | |||
| f6284aef79 | |||
| a9c7a3654b | |||
| 8c7c26e9c0 | |||
| 070426620e | |||
| 7e56b93f01 | |||
| 369de72e98 | |||
| 052b87e4ff | |||
| 20d5ccef00 | |||
| b2cfd84f10 | |||
| 6da6e2a980 | |||
| 1118ff5663 | |||
| d529aa6f19 | |||
| e55d2f536a | |||
| f6d9c8d0bb | |||
| b6d1ba743b | |||
| d5516c6cf8 | |||
| 92c0cf414b | |||
| 718ce8d09f | |||
| a9a9024830 | |||
| f7dfce5918 | |||
| c6d3ff8f5c | |||
| ded18b44e5 |
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, Trash2 } from 'lucide-react';
|
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2 } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
||||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
||||||
@@ -331,8 +331,6 @@ export function TailscaleStatusPage() {
|
|||||||
const [source, setSource] = useState('');
|
const [source, setSource] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<TailscaleNode | null>(null);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const online = nodes.filter((node) => node.status === '활성' || node.online).length;
|
const online = nodes.filter((node) => node.status === '활성' || node.online).length;
|
||||||
const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
|
const subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
|
||||||
|
|
||||||
@@ -355,20 +353,6 @@ export function TailscaleStatusPage() {
|
|||||||
refresh();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -401,30 +385,13 @@ export function TailscaleStatusPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<TailscaleNodeTable nodes={nodes} loading={loading} onDelete={setDeleteTarget} />
|
<TailscaleNodeTable nodes={nodes} loading={loading} />
|
||||||
</div>
|
</div>
|
||||||
<Modal
|
|
||||||
open={Boolean(deleteTarget)}
|
|
||||||
title="노드 삭제"
|
|
||||||
onClose={() => !deleting && setDeleteTarget(null)}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>취소</Button>
|
|
||||||
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={deleting} onClick={deleteNode}>
|
|
||||||
{deleting ? '삭제 중...' : '삭제'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<strong className="text-foreground">{deleteTarget?.name}</strong> 노드를 Headscale에서 삭제합니다. 연결된 기기의 VPN 연결은 즉시 끊기며, 다시 사용하려면 재등록해야 합니다.
|
|
||||||
</p>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode[]; loading: boolean; onDelete: (node: TailscaleNode) => void }) {
|
function TailscaleNodeTable({ nodes, loading }: { nodes: TailscaleNode[]; loading: boolean }) {
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(nodes.length / pageSize));
|
||||||
@@ -453,18 +420,17 @@ function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode
|
|||||||
<th className="px-5 py-3 font-medium">Routes</th>
|
<th className="px-5 py-3 font-medium">Routes</th>
|
||||||
<th className="px-5 py-3 font-medium">Last Seen</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 font-medium">상태</th>
|
||||||
<th className="px-5 py-3 font-medium"><span className="sr-only">관리</span></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{loading && nodes.length === 0 ? (
|
{loading && nodes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td>
|
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">노드 정보를 불러오는 중입니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && nodes.length === 0 ? (
|
{!loading && nodes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
|
<td colSpan={7} className="px-5 py-10 text-center text-muted-foreground">표시할 노드가 없습니다.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
{pageNodes.map((node) => (
|
{pageNodes.map((node) => (
|
||||||
@@ -476,17 +442,6 @@ function TailscaleNodeTable({ nodes, loading, onDelete }: { nodes: TailscaleNode
|
|||||||
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
<td className="px-5 py-3 text-muted-foreground">{node.routes}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground">{formatTailscaleLastSeen(node.lastSeen)}</td>
|
<td className="px-5 py-3 text-muted-foreground">{formatTailscaleLastSeen(node.lastSeen)}</td>
|
||||||
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
<td className="px-5 py-3"><StatusBadge status={node.status} /></td>
|
||||||
<td className="px-5 py-3 text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="size-8 p-0 text-muted-foreground hover:text-rose-600"
|
|
||||||
title="노드 삭제"
|
|
||||||
aria-label={`${node.name} 노드 삭제`}
|
|
||||||
onClick={() => onDelete(node)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ export type PlatformDevice = {
|
|||||||
os?: string | null;
|
os?: string | null;
|
||||||
arch?: string | null;
|
arch?: string | null;
|
||||||
distro?: string | null;
|
distro?: string | null;
|
||||||
agentVersion?: string | null;
|
|
||||||
agentUpdatedAt?: string | null;
|
|
||||||
agentLastSeenAt?: string | null;
|
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
lastProvisionedAt: string;
|
lastProvisionedAt: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -36,13 +36,11 @@ type ImageTags = {
|
|||||||
artifacts?: Record<string, string>;
|
artifacts?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function releaseArtifacts(release?: AppRelease | null, appName?: string) {
|
function releaseArtifacts(release?: AppRelease | null) {
|
||||||
const imageTags = release?.imageTags as ImageTags | null | undefined;
|
const imageTags = release?.imageTags as ImageTags | null | undefined;
|
||||||
const entries = Object.entries(imageTags?.images ?? {}).filter(([, image]) => (
|
const entries = Object.entries(imageTags?.images ?? {});
|
||||||
!/^(postgres|mysql|mariadb|mongo)(:|@)/i.test(image.trim())
|
|
||||||
));
|
|
||||||
if (entries.length) return entries;
|
if (entries.length) return entries;
|
||||||
if (release?.artifact) return [[release.app?.name ?? appName ?? 'artifact', release.artifact]];
|
if (release?.artifact) return [[release.app.name, release.artifact]];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +261,7 @@ export function DeploymentManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{pagedDeployments.map((deployment, index) => {
|
{pagedDeployments.map((deployment, index) => {
|
||||||
const artifacts = releaseArtifacts(deployment.release, deployment.app?.name);
|
const artifacts = releaseArtifacts(deployment.release);
|
||||||
return (
|
return (
|
||||||
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ export function OrganizationsPage() {
|
|||||||
},
|
},
|
||||||
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
{ key: 'type', header: '유형', className: 'w-32 px-5 py-3', cell: (row) => <StatusBadge status={organizationTypeLabel(row.type)} tone="blue" /> },
|
||||||
{ key: 'license', header: '라이센스', className: 'w-36 px-5 py-3', cell: (row) => row.defaultLicenseCode ? <StatusBadge status={row.defaultLicenseName ?? row.defaultLicenseCode} tone="green" /> : <span className="text-xs text-muted-foreground">-</span> },
|
{ key: 'license', header: '라이센스', className: 'w-36 px-5 py-3', cell: (row) => row.defaultLicenseCode ? <StatusBadge status={row.defaultLicenseName ?? row.defaultLicenseCode} tone="green" /> : <span className="text-xs text-muted-foreground">-</span> },
|
||||||
{ key: 'instructorInviteCode', header: '강사 가입 코드', className: 'w-40 px-5 py-3', cell: (row) => <span className="font-mono text-sm font-semibold tracking-[0.08em] text-foreground">{row.instructorInviteCode}</span> },
|
|
||||||
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
{ key: 'domain', header: '도메인', className: 'w-56 px-5 py-3', cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.domain || '-'}</span> },
|
||||||
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <OrganizationCreator organization={row} /> },
|
{ key: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <OrganizationCreator organization={row} /> },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
lockUser,
|
lockUser,
|
||||||
terminateUserSession,
|
terminateUserSession,
|
||||||
unlockUser,
|
unlockUser,
|
||||||
updateLmsInstructor,
|
|
||||||
type LicensePlan,
|
type LicensePlan,
|
||||||
type Organization,
|
type Organization,
|
||||||
type UserSession
|
type UserSession
|
||||||
@@ -46,7 +45,6 @@ export function UserSessionsPage() {
|
|||||||
const [createSaving, setCreateSaving] = useState(false);
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||||
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
||||||
const [lmsRoleSaving, setLmsRoleSaving] = useState(false);
|
|
||||||
const loadingVisible = useMinimumLoading(loading);
|
const loadingVisible = useMinimumLoading(loading);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
@@ -185,24 +183,6 @@ export function UserSessionsPage() {
|
|||||||
.finally(() => setCreateSaving(false));
|
.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
@@ -309,20 +289,8 @@ export function UserSessionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<StatusBadge status={detailSession.state} />
|
<StatusBadge status={detailSession.state} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
<div className="grid gap-3 text-sm">
|
||||||
<DetailItem label="사용자 ID" value={detailSession.userId} mono />
|
<DetailItem label="사용자 ID" value={detailSession.userId} mono />
|
||||||
<label className="block min-w-0">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">LMS</span>
|
|
||||||
<select
|
|
||||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={detailSession.isLmsInstructor ? 'INSTRUCTOR' : 'STUDENT'}
|
|
||||||
disabled={lmsRoleSaving}
|
|
||||||
onChange={(event) => setLmsInstructor(event.target.value === 'INSTRUCTOR')}
|
|
||||||
>
|
|
||||||
<option value="STUDENT">수강생</option>
|
|
||||||
<option value="INSTRUCTOR">강사</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<DetailItem label="조직" value={detailSession.primaryAffiliation?.organizationName ?? '-'} />
|
<DetailItem label="조직" value={detailSession.primaryAffiliation?.organizationName ?? '-'} />
|
||||||
<DetailItem label="조직 유형" value={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
<DetailItem label="조직 유형" value={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
||||||
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export type UserSession = {
|
|||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
lastSeen?: string | null;
|
lastSeen?: string | null;
|
||||||
state: string;
|
state: string;
|
||||||
isLmsInstructor?: boolean;
|
|
||||||
primaryAffiliation?: {
|
primaryAffiliation?: {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -91,7 +90,6 @@ export type Organization = {
|
|||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
defaultLicenseCode?: string | null;
|
defaultLicenseCode?: string | null;
|
||||||
defaultLicenseName?: string | null;
|
defaultLicenseName?: string | null;
|
||||||
instructorInviteCode: string;
|
|
||||||
createdById?: string | null;
|
createdById?: string | null;
|
||||||
createdByName?: string | null;
|
createdByName?: string | null;
|
||||||
createdByEmail?: string | null;
|
createdByEmail?: string | null;
|
||||||
@@ -259,13 +257,6 @@ 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) {
|
export function terminateUserSession(sessionId: string) {
|
||||||
return apiRequest(`/users/sessions/${sessionId}`, {
|
return apiRequest(`/users/sessions/${sessionId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ type HeadscaleNode = {
|
|||||||
type AclRule = { id: string; action: string; sources: string[]; destinations: string[] };
|
type AclRule = { id: string; action: string; sources: string[]; destinations: string[] };
|
||||||
type PlatformRow = HeadscaleNode & { device?: PlatformDevice; controllers: string[]; no: number };
|
type PlatformRow = HeadscaleNode & { device?: PlatformDevice; controllers: string[]; no: number };
|
||||||
|
|
||||||
type DeviceKind = 'agent' | 'launcher' | 'unknown';
|
|
||||||
|
|
||||||
function normalize(value?: string | null) {
|
function normalize(value?: string | null) {
|
||||||
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
}
|
}
|
||||||
@@ -43,17 +41,6 @@ function findControllers(node: HeadscaleNode, rules: AclRule[]) {
|
|||||||
.flatMap((rule) => rule.sources))];
|
.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() {
|
export function RuntimeDeviceControlPage() {
|
||||||
const [rows, setRows] = useState<PlatformRow[]>([]);
|
const [rows, setRows] = useState<PlatformRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -104,19 +91,6 @@ export function RuntimeDeviceControlPage() {
|
|||||||
width: 180,
|
width: 180,
|
||||||
cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.ip || '-'}</span>,
|
cell: (row) => <span className="font-mono text-xs text-muted-foreground">{row.ip || '-'}</span>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'deviceKind',
|
|
||||||
header: '유형',
|
|
||||||
width: 150,
|
|
||||||
cell: (row) => {
|
|
||||||
const kind = getDeviceKind(row);
|
|
||||||
if (kind === 'agent') {
|
|
||||||
return <Badge tone="green">에이전트</Badge>;
|
|
||||||
}
|
|
||||||
if (kind === 'launcher') return <Badge tone="neutral">런처 클라이언트</Badge>;
|
|
||||||
return <Badge tone="neutral">미확인</Badge>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'controllers',
|
key: 'controllers',
|
||||||
header: '제어 사용자',
|
header: '제어 사용자',
|
||||||
@@ -181,9 +155,6 @@ export function RuntimeDeviceControlPage() {
|
|||||||
<dt className="text-muted-foreground">Workspace</dt><dd className="min-w-0 break-all">{selected?.device?.workspaceRoot || '-'}</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">SSH 사용자</dt><dd>{selected?.device?.sshUser || '-'}</dd>
|
||||||
<dt className="text-muted-foreground">사용 유형</dt><dd>{selected?.device?.usageType || '-'}</dd>
|
<dt className="text-muted-foreground">사용 유형</dt><dd>{selected?.device?.usageType || '-'}</dd>
|
||||||
<dt className="text-muted-foreground">장비 유형</dt><dd>{selected ? ({ agent: '에이전트', launcher: '런처 클라이언트', unknown: '미확인' }[getDeviceKind(selected)]) : '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">에이전트 버전</dt><dd>{selected?.device?.agentVersion || '-'}</dd>
|
|
||||||
<dt className="text-muted-foreground">마지막 에이전트 확인</dt><dd>{formatDateTime(selected?.device?.agentLastSeenAt)}</dd>
|
|
||||||
</dl>
|
</dl>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export type BuildJob = {
|
|||||||
branch: string;
|
branch: string;
|
||||||
commitSha?: string | null;
|
commitSha?: string | null;
|
||||||
artifact?: string | null;
|
artifact?: string | null;
|
||||||
artifactSha256?: string | null;
|
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
logs?: string | null;
|
logs?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user