Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
-22
@@ -1932,28 +1932,7 @@ function ServerResourceDetail({ label, value }: { label: string; value: string }
|
||||
function formatResourceDetail(metric?: ClusterMetricValue | null) {
|
||||
if (!metric) return '-';
|
||||
if (!metric.usage && !metric.capacity) return '-';
|
||||
return `${formatBinaryQuantity(metric.usage)} / ${formatBinaryQuantity(metric.capacity)}`;
|
||||
}
|
||||
|
||||
function formatBinaryQuantity(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
const matched = value.match(/^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)$/);
|
||||
if (!matched) return value;
|
||||
|
||||
const numeric = Number(matched[1]);
|
||||
const mi = matched[2] === 'Ki'
|
||||
? numeric / 1024
|
||||
: matched[2] === 'Mi'
|
||||
? numeric
|
||||
: matched[2] === 'Gi'
|
||||
? numeric * 1024
|
||||
: numeric * 1024 * 1024;
|
||||
const format = (amount: number) => Number(amount.toFixed(1)).toLocaleString('ko-KR');
|
||||
|
||||
if (mi >= 1024 * 1024) return `${format(mi / 1024 / 1024)} TiB`;
|
||||
if (mi >= 1024) return `${format(mi / 1024)} GiB`;
|
||||
if (mi >= 1) return `${format(mi)} MiB`;
|
||||
return `${format(mi * 1024)} KiB`;
|
||||
return `${metric.usage ?? '-'} / ${metric.capacity ?? '-'}`;
|
||||
}
|
||||
|
||||
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
|
||||
|
||||
+10
-77
@@ -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 { DataTableCard, type DataTableColumn } from '../components/data-table';
|
||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
||||
@@ -331,8 +331,6 @@ export function TailscaleStatusPage() {
|
||||
const [source, setSource] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
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 subnetRouters = nodes.filter((node) => node.routes && node.routes !== '-').length;
|
||||
|
||||
@@ -355,20 +353,6 @@ 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 (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -401,30 +385,13 @@ export function TailscaleStatusPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<TailscaleNodeTable nodes={nodes} loading={loading} onDelete={setDeleteTarget} />
|
||||
<TailscaleNodeTable nodes={nodes} loading={loading} />
|
||||
</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 [page, setPage] = useState(1);
|
||||
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">Last Seen</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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && nodes.length === 0 ? (
|
||||
<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>
|
||||
) : null}
|
||||
{!loading && nodes.length === 0 ? (
|
||||
<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>
|
||||
) : null}
|
||||
{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">{formatTailscaleLastSeen(node.lastSeen)}</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>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -845,10 +800,6 @@ export function RuntimeDevicesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
type NumberedPlatformDevice = PlatformDevice & { no: number };
|
||||
type NumberedDeviceAuthorization = DeviceAuthorization & { no: number };
|
||||
type NumberedPlatformSshKey = PlatformSshKey & { no: number };
|
||||
|
||||
function AgentTable({ title, description }: { title: string; description: string }) {
|
||||
const [devices, setDevices] = useState<PlatformDevice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -865,14 +816,8 @@ function AgentTable({ title, description }: { title: string; description: string
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const rows = useMemo<NumberedPlatformDevice[]>(
|
||||
() => devices.map((device, index) => ({ ...device, no: devices.length - index })),
|
||||
[devices]
|
||||
);
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<NumberedPlatformDevice>>>(
|
||||
const columns = useMemo<Array<DataTableColumn<PlatformDevice>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
||||
{
|
||||
key: 'platform',
|
||||
header: '플랫폼',
|
||||
@@ -897,7 +842,7 @@ function AgentTable({ title, description }: { title: string; description: string
|
||||
<DataTableCard
|
||||
title={title}
|
||||
description={description}
|
||||
rows={rows}
|
||||
rows={devices}
|
||||
columns={columns}
|
||||
searchPlaceholder="플랫폼명, 계정, 워크스페이스 검색"
|
||||
getSearchText={(row) => `${row.hostname} ${row.tailscaleHostname ?? ''} ${row.accountEmail} ${row.workspaceRoot ?? ''} ${row.sshUser ?? ''}`}
|
||||
@@ -971,14 +916,8 @@ export function AgentRegistrationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const rows = useMemo<NumberedDeviceAuthorization[]>(
|
||||
() => authorizations.map((authorization, index) => ({ ...authorization, no: authorizations.length - index })),
|
||||
[authorizations]
|
||||
);
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<NumberedDeviceAuthorization>>>(
|
||||
const columns = useMemo<Array<DataTableColumn<DeviceAuthorization>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
||||
{
|
||||
key: 'user',
|
||||
header: '대상 사용자',
|
||||
@@ -1048,7 +987,7 @@ export function AgentRegistrationPage() {
|
||||
<DataTableCard
|
||||
title="등록 키 목록"
|
||||
description="활성 키와 최근 24시간 내 발급된 키를 확인합니다."
|
||||
rows={rows}
|
||||
rows={authorizations}
|
||||
columns={columns}
|
||||
searchPlaceholder="사용자명, 이메일, 등록 키 검색"
|
||||
getSearchText={(row) => `${row.authUserName ?? ''} ${row.accountEmail ?? ''} ${row.userCode} ${row.status}`}
|
||||
@@ -1097,20 +1036,14 @@ export function AgentSshKeysPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const rows = useMemo<NumberedPlatformSshKey[]>(
|
||||
() => keys.map((key, index) => ({ ...key, no: keys.length - index })),
|
||||
[keys]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="플랫폼 관리" title="SSH 키" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
||||
<DataTableCard
|
||||
title="SSH 키 목록"
|
||||
description="등록 플랫폼 계정에 연결된 SSH 키입니다."
|
||||
rows={rows}
|
||||
rows={keys}
|
||||
columns={[
|
||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
||||
{ 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 || '-' },
|
||||
|
||||
@@ -11,9 +11,6 @@ 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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2, Upload } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
|
||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { Requester } from '../../components/requester';
|
||||
import { useToast } from '../../components/toast';
|
||||
@@ -7,7 +7,7 @@ import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { BuildJob, SourceComponent } from '../../types/application';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
||||
import { cancelBuildJob, createBuildJob, createQuickDeployment, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
||||
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
||||
import { getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedComponentId } from '../../lib/selected-app';
|
||||
|
||||
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
||||
@@ -29,7 +29,7 @@ function buildTargetForType(type?: string | null) {
|
||||
|
||||
function predictedArtifact(component: SourceComponent | undefined, branch: string, commitSha: string) {
|
||||
if (!component) return '-';
|
||||
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : '<commit-sha>';
|
||||
const suffix = commitSha.trim() ? commitSha.trim().slice(0, 7) : branch;
|
||||
if (component.type === 'ELECTRON') return `${component.name}-${suffix}.AppImage`;
|
||||
if (component.type === 'DATABASE' || component.type === 'MIGRATION') return `${component.name}:${branch}:migration-check`;
|
||||
return `localhost:5000/${component.name}:${suffix}`;
|
||||
@@ -64,12 +64,6 @@ function resultArtifact(job: BuildJob) {
|
||||
return job.image || job.artifact || '-';
|
||||
}
|
||||
|
||||
function isQuickDeployable(job: BuildJob) {
|
||||
return job.status === 'SUCCEEDED'
|
||||
&& Boolean(job.image)
|
||||
&& ['CONTAINER', 'STATIC_WEB'].includes(job.component.type);
|
||||
}
|
||||
|
||||
function jobTime(job: BuildJob) {
|
||||
const value = new Date(job.createdAt || job.startedAt || job.finishedAt || 0).getTime();
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
@@ -101,12 +95,9 @@ export function BuildManagementPage() {
|
||||
const [commitSha, setCommitSha] = useState('');
|
||||
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<BuildJob | null>(null);
|
||||
const [quickDeployTarget, setQuickDeployTarget] = useState<BuildJob | null>(null);
|
||||
const [quickDeployNamespace, setQuickDeployNamespace] = useState('');
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deletingJob, setDeletingJob] = useState(false);
|
||||
const [quickDeploying, setQuickDeploying] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [range, setRange] = useState<BuildRange>('week');
|
||||
const [totalJobs, setTotalJobs] = useState(0);
|
||||
@@ -285,29 +276,6 @@ export function BuildManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openQuickDeploy = (job: BuildJob) => {
|
||||
setQuickDeployTarget(job);
|
||||
setQuickDeployNamespace(job.component.app.namespace || '');
|
||||
};
|
||||
|
||||
const quickDeploy = async () => {
|
||||
if (!quickDeployTarget || !quickDeployNamespace.trim() || quickDeploying) return;
|
||||
setQuickDeploying(true);
|
||||
try {
|
||||
await createQuickDeployment({
|
||||
buildJobId: quickDeployTarget.id,
|
||||
environment: 'prod',
|
||||
namespace: quickDeployNamespace.trim()
|
||||
});
|
||||
toast({ type: 'success', message: '빠른 배포 작업이 실행되었습니다.' });
|
||||
setQuickDeployTarget(null);
|
||||
} catch (error) {
|
||||
toast({ type: 'error', message: error instanceof Error ? error.message : '빠른 배포에 실패했습니다.' });
|
||||
} finally {
|
||||
setQuickDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -436,11 +404,6 @@ export function BuildManagementPage() {
|
||||
<RotateCcw className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isQuickDeployable(job) ? (
|
||||
<Button variant="outline" className="size-8 px-0" title="빠른 배포" aria-label="빠른 배포" onClick={() => openQuickDeploy(job)}>
|
||||
<Upload className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{isBuildDone(job.status) ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -543,34 +506,6 @@ export function BuildManagementPage() {
|
||||
<p className="text-muted-foreground">삭제된 빌드 작업 이력과 로그는 목록에서 사라집니다.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(quickDeployTarget)}
|
||||
title="빠른 배포"
|
||||
onClose={() => {
|
||||
if (!quickDeploying) setQuickDeployTarget(null);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" disabled={quickDeploying} onClick={() => setQuickDeployTarget(null)}>취소</Button>
|
||||
<Button disabled={quickDeploying || !quickDeployNamespace.trim()} onClick={quickDeploy}>
|
||||
<Upload className="size-4" />
|
||||
{quickDeploying ? '배포 중' : '배포'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">릴리즈를 만들지 않고 선택한 빌드 이미지로 현재 워크로드를 교체합니다. 런처 앱 버전은 갱신하지 않습니다.</p>
|
||||
<div className="grid gap-2 rounded-md border border-border bg-muted/20 p-4 text-xs">
|
||||
<div className="flex items-center justify-between gap-4"><span className="text-muted-foreground">구성요소</span><span className="font-medium">{quickDeployTarget?.component.name ?? '-'}</span></div>
|
||||
<div className="min-w-0"><p className="text-muted-foreground">이미지</p><p className="mt-1 truncate font-mono font-medium">{quickDeployTarget?.image ?? '-'}</p></div>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">네임스페이스 <b className="text-foreground">*</b></span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none" value={quickDeployNamespace} disabled={quickDeploying} onChange={(event) => setQuickDeployNamespace(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
title="빌드 실행 확인"
|
||||
|
||||
@@ -36,13 +36,11 @@ type ImageTags = {
|
||||
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 entries = Object.entries(imageTags?.images ?? {}).filter(([, image]) => (
|
||||
!/^(postgres|mysql|mariadb|mongo)(:|@)/i.test(image.trim())
|
||||
));
|
||||
const entries = Object.entries(imageTags?.images ?? {});
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -263,7 +261,7 @@ export function DeploymentManagementPage() {
|
||||
</div>
|
||||
) : null}
|
||||
{pagedDeployments.map((deployment, index) => {
|
||||
const artifacts = releaseArtifacts(deployment.release, deployment.app?.name);
|
||||
const artifacts = releaseArtifacts(deployment.release);
|
||||
return (
|
||||
<div key={deployment.id} className="rounded-md border border-border bg-background/70 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -272,7 +270,7 @@ export function DeploymentManagementPage() {
|
||||
<span className="text-xs font-medium text-muted-foreground">No. {totalDeployments - ((currentPage - 1) * deploymentPageSize + index)}</span>
|
||||
<Badge tone={statusTone(deployment.status)}>{statusLabel(deployment.status)}</Badge>
|
||||
<span className="text-base font-medium">{deployment.app.name}</span>
|
||||
<span className="text-base font-medium">{deployment.release?.version || '빠른 배포'}</span>
|
||||
<span className="text-base font-medium">{deployment.release?.version || '-'}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span className="text-muted-foreground">환경 <b className="text-foreground">{environmentLabel(deployment.environment)}</b></span>
|
||||
|
||||
@@ -251,17 +251,6 @@ export function createDeployment(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function createQuickDeployment(input: {
|
||||
buildJobId: string;
|
||||
environment: string;
|
||||
namespace: string;
|
||||
}) {
|
||||
return apiRequest<{ deploymentJob: DeploymentJob }>('/apps/deployments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listMigrations() {
|
||||
return apiRequest<RuntimeControlJob[]>('/apps/migrations');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
||||
import { Plus, RotateCcw, Server, Settings, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
@@ -8,9 +8,7 @@ import type { RuntimeResource } from '../../types/runtime';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
loadLauncherRuntimeResources,
|
||||
loadLauncherComponentLogs,
|
||||
loadRuntimeWorkloadEnv,
|
||||
restartLauncherComponent,
|
||||
saveRuntimeWorkloadEnv
|
||||
} from '../launcher/launcher-api';
|
||||
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
|
||||
@@ -26,10 +24,6 @@ function apiKind(kind: string) {
|
||||
return kind.toLowerCase();
|
||||
}
|
||||
|
||||
function stripAnsi(value: string) {
|
||||
return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, '');
|
||||
}
|
||||
|
||||
function findResource(resources: RuntimeResource[], kind: string, name: string) {
|
||||
return resources.find((resource) => resource.kind === kind && resource.name === name);
|
||||
}
|
||||
@@ -59,12 +53,7 @@ export function CommonAppComponentsPage() {
|
||||
const { toast } = useToast();
|
||||
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [logTarget, setLogTarget] = useState<CommonAppComponent | null>(null);
|
||||
const [logs, setLogs] = useState('');
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [envTarget, setEnvTarget] = useState<CommonAppComponent | null>(null);
|
||||
const [restartTarget, setRestartTarget] = useState<CommonAppComponent | null>(null);
|
||||
const [restartKey, setRestartKey] = useState('');
|
||||
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
|
||||
const [envLoading, setEnvLoading] = useState(false);
|
||||
const [envSaving, setEnvSaving] = useState(false);
|
||||
@@ -100,16 +89,6 @@ export function CommonAppComponentsPage() {
|
||||
.finally(() => setEnvLoading(false));
|
||||
};
|
||||
|
||||
const openLogs = (component: CommonAppComponent) => {
|
||||
setLogTarget(component);
|
||||
setLogs('');
|
||||
setLogLoading(true);
|
||||
loadLauncherComponentLogs(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||
.then((result) => setLogs(stripAnsi(result.logs || '로그가 없습니다.')))
|
||||
.catch((error: Error) => setLogs(`[warn] 로그 조회 실패\n${error.message}`))
|
||||
.finally(() => setLogLoading(false));
|
||||
};
|
||||
|
||||
const updateEnvVariable = (index: number, patch: Partial<Pick<EnvVariable, 'key' | 'value' | 'isSecret'>>) => {
|
||||
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
||||
variableIndex === index ? { ...variable, ...patch } : variable
|
||||
@@ -128,19 +107,6 @@ export function CommonAppComponentsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const restart = (component: CommonAppComponent) => {
|
||||
setRestartKey(component.key);
|
||||
restartLauncherComponent(COMMON_APP_NAMESPACE, apiKind(component.kind), component.workloadName)
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: `${component.name} 재시작을 요청했습니다.` });
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '재시작 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setRestartKey(''));
|
||||
};
|
||||
|
||||
const saveEnv = () => {
|
||||
if (!envTarget) return;
|
||||
setEnvSaving(true);
|
||||
@@ -219,15 +185,7 @@ export function CommonAppComponentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => openLogs(component)} disabled={!workload}>
|
||||
<TerminalSquare className="size-4" />
|
||||
로그
|
||||
</Button>
|
||||
<Button onClick={() => setRestartTarget(component)} disabled={!workload || restartKey === component.key}>
|
||||
<RotateCcw className={cn('size-4', restartKey === component.key && 'animate-spin')} />
|
||||
재시작
|
||||
</Button>
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
||||
<Settings className="size-4" />
|
||||
설정값
|
||||
@@ -272,46 +230,6 @@ export function CommonAppComponentsPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={Boolean(logTarget)}
|
||||
title={logTarget ? `${logTarget.name} 로그` : '로그'}
|
||||
onClose={() => setLogTarget(null)}
|
||||
className="!max-w-5xl"
|
||||
>
|
||||
<pre className="max-h-[62vh] overflow-auto rounded-md bg-background p-4 font-mono text-xs leading-6 text-muted-foreground">
|
||||
{logs || (logLoading ? '로그를 불러오는 중...' : '로그가 없습니다.')}
|
||||
</pre>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(restartTarget)}
|
||||
title={restartTarget ? `${restartTarget.name} 재시작` : '구성요소 재시작'}
|
||||
onClose={() => {
|
||||
if (!restartTarget || restartKey !== restartTarget.key) setRestartTarget(null);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRestartTarget(null)} disabled={Boolean(restartTarget && restartKey === restartTarget.key)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!restartTarget) return;
|
||||
restart(restartTarget);
|
||||
setRestartTarget(null);
|
||||
}}
|
||||
disabled={Boolean(restartTarget && restartKey === restartTarget.key)}
|
||||
>
|
||||
재시작
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{restartTarget?.name}을 재시작합니다. 실행 중인 요청은 잠시 영향을 받을 수 있습니다.
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(envTarget)}
|
||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
@@ -7,8 +7,6 @@ import { formatDateTime } from '../../lib/date-format';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
createLauncherBuildJob,
|
||||
deployLauncherBackend,
|
||||
getLauncherBackendDeployment,
|
||||
getLauncherSource,
|
||||
listLauncherBuildEnv,
|
||||
listLauncherBuildJobs,
|
||||
@@ -18,7 +16,6 @@ import {
|
||||
type LauncherSource,
|
||||
type LauncherBuildJob,
|
||||
type LauncherBuildEnvVariable,
|
||||
type LauncherBackendDeployment,
|
||||
type LauncherVersion
|
||||
} from './launcher-api';
|
||||
|
||||
@@ -135,9 +132,6 @@ export function LauncherBuildPage() {
|
||||
const [buildEnv, setBuildEnv] = useState<LauncherBuildEnvVariable[]>([]);
|
||||
const [buildEnvSaving, setBuildEnvSaving] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState<LauncherBuildJob | null>(null);
|
||||
const [quickDeployJob, setQuickDeployJob] = useState<LauncherBuildJob | null>(null);
|
||||
const [backendDeployment, setBackendDeployment] = useState<LauncherBackendDeployment | null>(null);
|
||||
const [quickDeploying, setQuickDeploying] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -159,13 +153,6 @@ export function LauncherBuildPage() {
|
||||
const pagedJobs = jobs;
|
||||
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
||||
|
||||
const isQuickDeployable = (job: LauncherBuildJob) => (
|
||||
target === 'launcher-backend'
|
||||
&& job.target === 'launcher-backend'
|
||||
&& job.status === 'SUCCEEDED'
|
||||
&& Boolean(job.downloadUrl)
|
||||
);
|
||||
|
||||
const loadBuildJobs = (nextPage = page, nextTarget = target, nextRange = range) => (
|
||||
listLauncherBuildJobsPage({
|
||||
target: nextTarget,
|
||||
@@ -282,31 +269,6 @@ export function LauncherBuildPage() {
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const openQuickDeploy = (job: LauncherBuildJob) => {
|
||||
setQuickDeployJob(job);
|
||||
setBackendDeployment(null);
|
||||
getLauncherBackendDeployment()
|
||||
.then(setBackendDeployment)
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '배포 대상 조회 실패', message: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
const quickDeploy = () => {
|
||||
if (!quickDeployJob?.downloadUrl) return;
|
||||
setQuickDeploying(true);
|
||||
deployLauncherBackend(quickDeployJob.downloadUrl)
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '런처 백엔드를 배포했습니다.' });
|
||||
setQuickDeployJob(null);
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '백엔드 배포 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setQuickDeploying(false));
|
||||
};
|
||||
|
||||
const updateBuildEnv = (index: number, patch: Partial<LauncherBuildEnvVariable>) => {
|
||||
setBuildEnv((prev) => prev.map((variable, variableIndex) => (
|
||||
variableIndex === index ? { ...variable, ...patch } : variable
|
||||
@@ -478,22 +440,9 @@ export function LauncherBuildPage() {
|
||||
<span>생성 <b className="text-foreground">{formatDate(job.createdAt)}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isQuickDeployable(job) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8 px-0"
|
||||
title="빠른 배포"
|
||||
aria-label="빠른 배포"
|
||||
onClick={() => openQuickDeploy(job)}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" className="size-8 px-0" title="로그" aria-label="로그" onClick={() => setSelectedJob(job)}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 rounded-md border border-border bg-card px-3 py-2 text-xs md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="min-w-0">
|
||||
@@ -551,51 +500,6 @@ export function LauncherBuildPage() {
|
||||
{selectedJob?.logs || '작업을 선택하면 로그가 표시됩니다.'}
|
||||
</pre>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={Boolean(quickDeployJob)}
|
||||
title="빠른 배포"
|
||||
onClose={() => !quickDeploying && setQuickDeployJob(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setQuickDeployJob(null)} disabled={quickDeploying}>취소</Button>
|
||||
<Button onClick={quickDeploy} disabled={quickDeploying || !quickDeployJob?.downloadUrl}>
|
||||
<Upload className="size-4" />
|
||||
{quickDeploying ? '배포 중' : '배포'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-md border border-border bg-muted/20 px-4 py-3">
|
||||
<p className="font-medium">성공한 백엔드 빌드 이미지를 바로 배포합니다.</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
현재 설정된 복제 수와 CPU, 메모리 제한은 변경하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 rounded-md border border-border bg-background p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">대상</span>
|
||||
<span className="font-mono text-xs font-medium">
|
||||
{backendDeployment ? `${backendDeployment.namespace}/deployment/${backendDeployment.deployment}` : '배포 대상 확인 중'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">빌드</span>
|
||||
<span className="font-medium">{quickDeployJob?.version || '-'}</span>
|
||||
</div>
|
||||
<div className="grid gap-1 pt-1">
|
||||
<span className="text-muted-foreground">이미지</span>
|
||||
<span className="break-all font-mono text-xs font-medium">{quickDeployJob?.downloadUrl || '-'}</span>
|
||||
</div>
|
||||
{backendDeployment?.currentImage ? (
|
||||
<div className="grid gap-1 border-t border-border pt-3">
|
||||
<span className="text-muted-foreground">현재 이미지</span>
|
||||
<span className="break-all font-mono text-xs text-muted-foreground">{backendDeployment.currentImage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
title="빌드 실행 확인"
|
||||
|
||||
@@ -139,7 +139,6 @@ export function LauncherComponentsPage() {
|
||||
const [logs, setLogs] = useState('');
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [envTarget, setEnvTarget] = useState<LauncherComponentDefinition | null>(null);
|
||||
const [restartTarget, setRestartTarget] = useState<LauncherComponentDefinition | null>(null);
|
||||
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);
|
||||
@@ -352,7 +351,7 @@ export function LauncherComponentsPage() {
|
||||
<TerminalSquare className="size-4" />
|
||||
로그
|
||||
</Button>
|
||||
<Button onClick={() => setRestartTarget(component)} disabled={!workload || actionKey === component.key}>
|
||||
<Button onClick={() => restart(component)} disabled={!workload || actionKey === component.key}>
|
||||
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
|
||||
재시작
|
||||
</Button>
|
||||
@@ -424,35 +423,6 @@ export function LauncherComponentsPage() {
|
||||
</pre>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(restartTarget)}
|
||||
title={restartTarget ? `${restartTarget.name} 재시작` : '구성요소 재시작'}
|
||||
onClose={() => {
|
||||
if (!restartTarget || actionKey !== restartTarget.key) setRestartTarget(null);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRestartTarget(null)} disabled={Boolean(restartTarget && actionKey === restartTarget.key)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!restartTarget) return;
|
||||
restart(restartTarget);
|
||||
setRestartTarget(null);
|
||||
}}
|
||||
disabled={Boolean(restartTarget && actionKey === restartTarget.key)}
|
||||
>
|
||||
재시작
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{restartTarget?.name}을 재시작합니다. 실행 중인 요청은 잠시 영향을 받을 수 있습니다.
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(envTarget)}
|
||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||
|
||||
@@ -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: '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: 'createdAt', header: '생성', className: 'w-48 px-5 py-3', cell: (row) => <OrganizationCreator organization={row} /> },
|
||||
{
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
lockUser,
|
||||
terminateUserSession,
|
||||
unlockUser,
|
||||
updateLmsInstructor,
|
||||
type LicensePlan,
|
||||
type Organization,
|
||||
type UserSession
|
||||
@@ -46,7 +45,6 @@ export function UserSessionsPage() {
|
||||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [menuSession, setMenuSession] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [detailSession, setDetailSession] = useState<UserSession | null>(null);
|
||||
const [lmsRoleSaving, setLmsRoleSaving] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
@@ -185,24 +183,6 @@ 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 (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -309,20 +289,8 @@ export function UserSessionsPage() {
|
||||
</div>
|
||||
<StatusBadge status={detailSession.state} />
|
||||
</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 />
|
||||
<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={organizationTypeLabel(detailSession.primaryAffiliation?.organizationType)} />
|
||||
<DetailItem label="부서" value={detailSession.primaryAffiliation?.department ?? '-'} />
|
||||
|
||||
@@ -70,7 +70,6 @@ export type UserSession = {
|
||||
expiresAt?: string | null;
|
||||
lastSeen?: string | null;
|
||||
state: string;
|
||||
isLmsInstructor?: boolean;
|
||||
primaryAffiliation?: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -91,7 +90,6 @@ export type Organization = {
|
||||
domain?: string | null;
|
||||
defaultLicenseCode?: string | null;
|
||||
defaultLicenseName?: string | null;
|
||||
instructorInviteCode: string;
|
||||
createdById?: string | null;
|
||||
createdByName?: 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) {
|
||||
return apiRequest(`/users/sessions/${sessionId}`, {
|
||||
method: 'DELETE'
|
||||
|
||||
@@ -441,15 +441,15 @@ export function RuntimeConfigPage() {
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">구성요소</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{components.length}개</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">환경변수</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{envCount}개</p>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<p className="text-sm text-muted-foreground">시크릿</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}</p>
|
||||
<p className="mt-4 text-2xl font-medium text-foreground">{secretCount}개</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@ 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';
|
||||
type PlatformRow = HeadscaleNode & { device?: PlatformDevice; controllers: string[] };
|
||||
|
||||
function normalize(value?: string | null) {
|
||||
return (value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
@@ -43,17 +41,6 @@ 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<PlatformRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -68,10 +55,8 @@ export function RuntimeDeviceControlPage() {
|
||||
])
|
||||
.then(([nodeResponse, aclResponse, devices]) => {
|
||||
const rules = aclResponse.policy?.acls ?? [];
|
||||
const nodes = nodeResponse.nodes ?? [];
|
||||
setRows(nodes.map((node, index) => ({
|
||||
setRows((nodeResponse.nodes ?? []).map((node) => ({
|
||||
...node,
|
||||
no: nodes.length - index,
|
||||
device: findDevice(node, devices),
|
||||
controllers: findControllers(node, rules),
|
||||
})));
|
||||
@@ -86,7 +71,6 @@ export function RuntimeDeviceControlPage() {
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<PlatformRow>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-14 px-3 py-3 text-center font-medium', cell: (row) => <span>{row.no}</span> },
|
||||
{
|
||||
key: 'platform',
|
||||
header: '플랫폼',
|
||||
@@ -104,19 +88,6 @@ export function RuntimeDeviceControlPage() {
|
||||
width: 180,
|
||||
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',
|
||||
header: '제어 사용자',
|
||||
@@ -181,9 +152,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">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 ? ({ 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>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -21,7 +21,6 @@ export type BuildJob = {
|
||||
branch: string;
|
||||
commitSha?: string | null;
|
||||
artifact?: string | null;
|
||||
artifactSha256?: string | null;
|
||||
image?: string | null;
|
||||
status: JobStatus;
|
||||
logs?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user