improve runtime environment variable modal usability
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-14 15:08:32 +09:00
parent 47dc9730e1
commit 45d05ba9fb
10 changed files with 591 additions and 5498 deletions
+284 -90
View File
@@ -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]>>>(
() => [
+75
View File
@@ -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>
)
}
+22 -13
View File
@@ -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>
+149 -76
View File
@@ -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>
</>
);
}