improve runtime environment variable modal usability
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+284
-90
@@ -1,18 +1,31 @@
|
||||
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2, SlidersHorizontal } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, MoreHorizontal, Plus, RefreshCcw, Settings2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn } from '../components/data-table';
|
||||
import { Badge, Button, Card, CardHeader, Modal, Progress } from '../components/ui';
|
||||
import {
|
||||
agentDevices,
|
||||
alerts,
|
||||
gatewayConnections,
|
||||
k3sWorkloads,
|
||||
launcherVersions,
|
||||
runtimeCommands,
|
||||
tailscaleSettings
|
||||
} from '../data/mock';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { PageTitle } from '../layout/AppLayout';
|
||||
import {
|
||||
cancelDeviceAuthorization,
|
||||
createDeviceAuthorization,
|
||||
loadDeviceAuthorizations,
|
||||
loadPlatformDevices,
|
||||
loadPlatformSshKeys,
|
||||
searchPlatformRegistrationUsers,
|
||||
type DeviceAuthorization,
|
||||
type PlatformDevice,
|
||||
type PlatformRegistrationUser,
|
||||
type PlatformSshKey,
|
||||
} from './agents/platform-api';
|
||||
|
||||
const launcherVersions: Array<{
|
||||
name: string; status: string; latestVersion: string; minimumSupportedVersion?: string;
|
||||
artifact: string; downloadUrl: string; releaseNotes: string;
|
||||
}> = [];
|
||||
const tailscaleSettings: Array<{ key: string; name: string; description: string; value: string; status: string }> = [];
|
||||
const gatewayConnections: Array<{ name: string; status: string; role: string; endpoint: string; lastSync: string }> = [];
|
||||
const runtimeCommands: Array<{ target: string; command: string; requestedBy: string; time: string; status: string }> = [];
|
||||
const k3sWorkloads: Array<{ workload: string; namespace: string; pod: string; replicas: string; cpu: number; memory: number; status: string }> = [];
|
||||
const alerts: Array<{ label: string; icon: typeof Settings2 }> = [];
|
||||
|
||||
type Tone = 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
||||
|
||||
@@ -782,81 +795,222 @@ export function RuntimeDevicesPage() {
|
||||
<AgentTable
|
||||
title="장비 목록"
|
||||
description="Agent가 연결된 장비를 목록으로 확인하고 필요한 제어 명령을 실행합니다."
|
||||
withCommands
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentTable({ title, description, withCommands = false }: { title: string; description: string; withCommands?: boolean }) {
|
||||
function AgentTable({ title, description }: { title: string; description: string }) {
|
||||
const [devices, setDevices] = useState<PlatformDevice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadPlatformDevices()
|
||||
.then(setDevices)
|
||||
.catch(() => setDevices([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<PlatformDevice>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'platform',
|
||||
header: '플랫폼',
|
||||
flexible: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{row.hostname}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground" title={row.tailscaleHostname || '-'}>{row.tailscaleHostname || '-'}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'account', header: '계정', width: 210, cell: (row) => <span className="block truncate text-muted-foreground" title={row.accountEmail}>{row.accountEmail}</span> },
|
||||
{ key: 'workspace', header: 'Workspace', width: 280, cell: (row) => <span className="block truncate text-muted-foreground" title={row.workspaceRoot || '-'}>{row.workspaceRoot || '-'}</span> },
|
||||
{ key: 'ssh', header: 'SSH 사용자', width: 130, cell: (row) => row.sshUser || '-' },
|
||||
{ key: 'lastSeen', header: '최근 연결', width: 180, cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatTailscaleLastSeen(row.lastProvisionedAt)}</span> },
|
||||
{ key: 'status', header: '상태', width: 110, cell: (row) => <StatusBadge status={row.status} /> }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
withCommands ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
상태 필터
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-[13px]">
|
||||
<RefreshCcw className="size-4" />
|
||||
상태 갱신
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[1040px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">장비</th>
|
||||
<th className="px-5 py-3 font-medium">계정</th>
|
||||
<th className="px-5 py-3 font-medium">Workspace</th>
|
||||
<th className="px-5 py-3 font-medium">SSH 키</th>
|
||||
<th className="px-5 py-3 font-medium">Last Seen</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
{withCommands ? <th className="px-5 py-3 text-right font-medium">명령</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{agentDevices.map((device) => (
|
||||
<tr className="hover:bg-muted/20" key={device.name}>
|
||||
<td className="px-5 py-3 font-medium">{device.name}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.account}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.workspace}</td>
|
||||
<td className="px-5 py-3">{device.sshKey}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{device.lastSeen}</td>
|
||||
<td className="px-5 py-3"><StatusBadge status={device.status} /></td>
|
||||
{withCommands ? (
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">시작</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">중지</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]">재시작</Button>
|
||||
<Button variant="ghost" className="size-8 px-0" aria-label="상세">
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataTableCard
|
||||
title={title}
|
||||
description={description}
|
||||
rows={devices}
|
||||
columns={columns}
|
||||
searchPlaceholder="플랫폼명, 계정, 워크스페이스 검색"
|
||||
getSearchText={(row) => `${row.hostname} ${row.tailscaleHostname ?? ''} ${row.accountEmail} ${row.workspaceRoot ?? ''} ${row.sshUser ?? ''}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1080}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 플랫폼이 없습니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentRegistrationPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [users, setUsers] = useState<PlatformRegistrationUser[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<PlatformRegistrationUser | null>(null);
|
||||
const [authorizations, setAuthorizations] = useState<DeviceAuthorization[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [issuing, setIssuing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [cancelTarget, setCancelTarget] = useState<DeviceAuthorization | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadDeviceAuthorizations()
|
||||
.then(setAuthorizations)
|
||||
.catch(() => setAuthorizations([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const value = keyword.trim();
|
||||
if (value.length < 2 || selectedUser) {
|
||||
setUsers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
searchPlatformRegistrationUsers(value).then(setUsers).catch(() => setUsers([]));
|
||||
}, 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [keyword, selectedUser]);
|
||||
|
||||
const issue = async () => {
|
||||
if (!selectedUser || issuing) return;
|
||||
setIssuing(true);
|
||||
setError('');
|
||||
try {
|
||||
await createDeviceAuthorization(selectedUser.id);
|
||||
await refresh();
|
||||
} catch (issueError) {
|
||||
setError(issueError instanceof Error ? issueError.message : '플랫폼 등록 키를 발급하지 못했습니다.');
|
||||
} finally {
|
||||
setIssuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
if (!cancelTarget) return;
|
||||
setError('');
|
||||
try {
|
||||
await cancelDeviceAuthorization(cancelTarget.id);
|
||||
setCancelTarget(null);
|
||||
await refresh();
|
||||
} catch (cancelError) {
|
||||
setError(cancelError instanceof Error ? cancelError.message : '플랫폼 등록 키를 취소하지 못했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<DeviceAuthorization>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'user',
|
||||
header: '대상 사용자',
|
||||
flexible: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{row.authUserName || row.accountEmail || '-'}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{row.accountEmail || '-'}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'code', header: '등록 키', width: 150, cell: (row) => <span className="font-mono font-semibold tracking-[0.12em]">{row.userCode}</span> },
|
||||
{ key: 'expires', header: '만료', width: 190, cell: (row) => <span className="whitespace-nowrap text-muted-foreground">{formatTailscaleLastSeen(row.expiresAt)}</span> },
|
||||
{
|
||||
key: 'status',
|
||||
header: '상태',
|
||||
width: 120,
|
||||
cell: (row) => <StatusBadge status={row.status === 'APPROVED' ? '발급됨' : row.status === 'CANCELLED' ? '취소됨' : row.status === 'CONSUMED' ? '사용됨' : row.status === 'EXPIRED' ? '만료됨' : row.status} badgeTone={row.status === 'APPROVED' ? 'green' : row.status === 'CANCELLED' || row.status === 'EXPIRED' ? 'neutral' : undefined} />
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => row.status === 'APPROVED' ? <div className="flex justify-end"><Button variant="ghost" className="h-8 px-2 text-[13px] text-rose-300 hover:text-rose-200" onClick={() => setCancelTarget(row)}>취소</Button></div> : null,
|
||||
}
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="장비 등록" description="새 장비에 설치할 Agent 등록 토큰과 연결 정보를 발급합니다." />
|
||||
<AgentPlaceholder title="장비 등록 요청" description="Agent 등록 요청과 토큰 발급 상태를 확인합니다." />
|
||||
<PageTitle eyebrow="플랫폼 관리" title="플랫폼 등록" description="에이전트 설치에 사용할 사용자별 6자리 등록 키를 발급하고 관리합니다." />
|
||||
<Card className="mb-4">
|
||||
<CardHeader title="플랫폼 등록 키" description="대상 사용자를 선택한 뒤 발급한 키와 계정 이메일을 에이전트 설치 화면에 입력합니다." />
|
||||
<div className="border-t border-border p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<label className="relative min-w-0 flex-1">
|
||||
<span className="mb-2 block text-xs font-medium text-muted-foreground">대상 사용자</span>
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder="이름 또는 이메일 검색"
|
||||
value={selectedUser ? `${selectedUser.name || selectedUser.email} · ${selectedUser.email}` : keyword}
|
||||
onChange={(event) => {
|
||||
setSelectedUser(null);
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
{users.length ? (
|
||||
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border border-border bg-card py-1 shadow-xl">
|
||||
{users.map((user) => (
|
||||
<button key={user.id} type="button" className="block w-full px-3 py-2 text-left hover:bg-muted" onClick={() => { setSelectedUser(user); setKeyword(''); setUsers([]); }}>
|
||||
<span className="block text-sm font-medium">{user.name || user.email}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{user.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
{selectedUser ? <Button variant="outline" className="h-9 px-3 text-[13px]" onClick={() => { setSelectedUser(null); setKeyword(''); }}>선택 해제</Button> : null}
|
||||
<Button className="h-9 px-3 text-[13px]" disabled={!selectedUser || issuing} onClick={() => void issue()}>
|
||||
키 발급
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <p className="mt-3 text-sm text-rose-300">{error}</p> : null}
|
||||
</div>
|
||||
</Card>
|
||||
<DataTableCard
|
||||
title="등록 키 목록"
|
||||
description="활성 키와 최근 24시간 내 발급된 키를 확인합니다."
|
||||
rows={authorizations}
|
||||
columns={columns}
|
||||
searchPlaceholder="사용자명, 이메일, 등록 키 검색"
|
||||
getSearchText={(row) => `${row.authUserName ?? ''} ${row.accountEmail ?? ''} ${row.userCode} ${row.status}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={920}
|
||||
loading={loading}
|
||||
emptyMessage="발급된 플랫폼 등록 키가 없습니다."
|
||||
/>
|
||||
<Modal
|
||||
open={Boolean(cancelTarget)}
|
||||
title="등록 키 취소"
|
||||
onClose={() => setCancelTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCancelTarget(null)}>닫기</Button>
|
||||
<Button className="bg-rose-600 text-white hover:bg-rose-500" onClick={() => void cancel()}>취소</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">선택한 등록 키를 취소하면 에이전트 설치에 더 이상 사용할 수 없습니다.</p>
|
||||
<p className="mt-3 font-mono text-sm font-semibold tracking-[0.12em] text-foreground">{cancelTarget?.userCode}</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -864,17 +1018,68 @@ export function AgentRegistrationPage() {
|
||||
export function AgentInstallStatusPage() {
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="설치 상태" description="장비별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
||||
<AgentPlaceholder title="설치 상태" description="설치 완료, 대기, 실패 상태를 확인합니다." />
|
||||
<PageTitle eyebrow="플랫폼 관리" title="연결 상태" description="플랫폼별 Agent 설치와 heartbeat 상태를 확인합니다." />
|
||||
<AgentTable title="연결 상태" description="플랫폼별 Agent 연결 상태를 확인합니다." />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentSshKeysPage() {
|
||||
const [keys, setKeys] = useState<PlatformSshKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedKey, setSelectedKey] = useState<PlatformSshKey | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlatformSshKeys()
|
||||
.then(setKeys)
|
||||
.catch(() => setKeys([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="SSH 키 배포" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
||||
<AgentPlaceholder title="SSH 키 배포" description="장비별 SSH 키 배포 상태입니다." />
|
||||
<PageTitle eyebrow="플랫폼 관리" title="SSH 키" description="저장소와 원격 실행에 사용할 SSH 키 배포 상태를 관리합니다." />
|
||||
<DataTableCard
|
||||
title="SSH 키 목록"
|
||||
description="등록 플랫폼 계정에 연결된 SSH 키입니다."
|
||||
rows={keys}
|
||||
columns={[
|
||||
{ key: 'account', header: '계정', flexible: true, cell: (row) => row.accountEmail },
|
||||
{ key: 'label', header: '이름', width: 180, cell: (row) => row.label || '-' },
|
||||
{ key: 'type', header: '유형', width: 140, cell: (row) => row.keyType || '-' },
|
||||
{ key: 'updated', header: '최근 등록', width: 180, cell: (row) => formatTailscaleLastSeen(row.lastProvisionedAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 text-right font-medium',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={() => setSelectedKey(row)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
getRowKey={(row) => row.id}
|
||||
getSearchText={(row) => `${row.accountEmail} ${row.label ?? ''} ${row.publicKeyHash}`}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 SSH 키가 없습니다."
|
||||
/>
|
||||
<Modal open={Boolean(selectedKey)} title="SSH 키 지문" onClose={() => setSelectedKey(null)}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">{selectedKey?.label || selectedKey?.accountEmail}</p>
|
||||
<p className="break-all rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs text-foreground">
|
||||
{selectedKey?.publicKeyHash}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -893,7 +1098,7 @@ export function AgentCommandsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle eyebrow="Agent" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
||||
<PageTitle eyebrow="플랫폼 관리" title="원격 명령" description="Agent를 통해 실행한 원격 명령 이력과 결과를 확인합니다." />
|
||||
<DataTableCard
|
||||
title="최근 제어 작업"
|
||||
rows={runtimeCommands}
|
||||
@@ -906,17 +1111,6 @@ export function AgentCommandsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPlaceholder({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title={title} description={description} />
|
||||
<div className="p-5">
|
||||
<AgentTable title="Agent 장비" description="등록된 장비 목록입니다." />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitoringPage() {
|
||||
const columns = useMemo<Array<DataTableColumn<(typeof k3sWorkloads)[number]>>>(
|
||||
() => [
|
||||
|
||||
Reference in New Issue
Block a user