improve component runtime controls and resource formatting
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+22
-1
@@ -1932,7 +1932,28 @@ function ServerResourceDetail({ label, value }: { label: string; value: string }
|
|||||||
function formatResourceDetail(metric?: ClusterMetricValue | null) {
|
function formatResourceDetail(metric?: ClusterMetricValue | null) {
|
||||||
if (!metric) return '-';
|
if (!metric) return '-';
|
||||||
if (!metric.usage && !metric.capacity) return '-';
|
if (!metric.usage && !metric.capacity) return '-';
|
||||||
return `${metric.usage ?? '-'} / ${metric.capacity ?? '-'}`;
|
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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
|
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Plus, RotateCcw, Server, Settings, Trash2 } from 'lucide-react';
|
import { Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||||
import { useToast } from '../../components/toast';
|
import { useToast } from '../../components/toast';
|
||||||
@@ -8,7 +8,9 @@ import type { RuntimeResource } from '../../types/runtime';
|
|||||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||||
import {
|
import {
|
||||||
loadLauncherRuntimeResources,
|
loadLauncherRuntimeResources,
|
||||||
|
loadLauncherComponentLogs,
|
||||||
loadRuntimeWorkloadEnv,
|
loadRuntimeWorkloadEnv,
|
||||||
|
restartLauncherComponent,
|
||||||
saveRuntimeWorkloadEnv
|
saveRuntimeWorkloadEnv
|
||||||
} from '../launcher/launcher-api';
|
} from '../launcher/launcher-api';
|
||||||
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
|
import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils';
|
||||||
@@ -24,6 +26,10 @@ function apiKind(kind: string) {
|
|||||||
return kind.toLowerCase();
|
return kind.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripAnsi(value: string) {
|
||||||
|
return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
function findResource(resources: RuntimeResource[], kind: string, name: string) {
|
function findResource(resources: RuntimeResource[], kind: string, name: string) {
|
||||||
return resources.find((resource) => resource.kind === kind && resource.name === name);
|
return resources.find((resource) => resource.kind === kind && resource.name === name);
|
||||||
}
|
}
|
||||||
@@ -53,7 +59,12 @@ export function CommonAppComponentsPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
const [resources, setResources] = useState<RuntimeResource[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [envTarget, setEnvTarget] = useState<CommonAppComponent | null>(null);
|
||||||
|
const [restartTarget, setRestartTarget] = useState<CommonAppComponent | null>(null);
|
||||||
|
const [restartKey, setRestartKey] = useState('');
|
||||||
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
|
const [envVariables, setEnvVariables] = useState<EnvVariable[]>([]);
|
||||||
const [envLoading, setEnvLoading] = useState(false);
|
const [envLoading, setEnvLoading] = useState(false);
|
||||||
const [envSaving, setEnvSaving] = useState(false);
|
const [envSaving, setEnvSaving] = useState(false);
|
||||||
@@ -89,6 +100,16 @@ export function CommonAppComponentsPage() {
|
|||||||
.finally(() => setEnvLoading(false));
|
.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'>>) => {
|
const updateEnvVariable = (index: number, patch: Partial<Pick<EnvVariable, 'key' | 'value' | 'isSecret'>>) => {
|
||||||
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
|
||||||
variableIndex === index ? { ...variable, ...patch } : variable
|
variableIndex === index ? { ...variable, ...patch } : variable
|
||||||
@@ -107,6 +128,19 @@ 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 = () => {
|
const saveEnv = () => {
|
||||||
if (!envTarget) return;
|
if (!envTarget) return;
|
||||||
setEnvSaving(true);
|
setEnvSaving(true);
|
||||||
@@ -185,7 +219,15 @@ export function CommonAppComponentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<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>
|
||||||
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
설정값
|
설정값
|
||||||
@@ -230,6 +272,46 @@ export function CommonAppComponentsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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
|
<Modal
|
||||||
open={Boolean(envTarget)}
|
open={Boolean(envTarget)}
|
||||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export function LauncherComponentsPage() {
|
|||||||
const [logs, setLogs] = useState('');
|
const [logs, setLogs] = useState('');
|
||||||
const [logLoading, setLogLoading] = useState(false);
|
const [logLoading, setLogLoading] = useState(false);
|
||||||
const [envTarget, setEnvTarget] = useState<LauncherComponentDefinition | null>(null);
|
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 [envVariables, setEnvVariables] = useState<Array<{ key: string; value: string; isSecret: boolean; editable: boolean; source: string }>>([]);
|
||||||
const [envLoading, setEnvLoading] = useState(false);
|
const [envLoading, setEnvLoading] = useState(false);
|
||||||
const [envSaving, setEnvSaving] = useState(false);
|
const [envSaving, setEnvSaving] = useState(false);
|
||||||
@@ -351,7 +352,7 @@ export function LauncherComponentsPage() {
|
|||||||
<TerminalSquare className="size-4" />
|
<TerminalSquare className="size-4" />
|
||||||
로그
|
로그
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => restart(component)} disabled={!workload || actionKey === component.key}>
|
<Button onClick={() => setRestartTarget(component)} disabled={!workload || actionKey === component.key}>
|
||||||
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
|
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
|
||||||
재시작
|
재시작
|
||||||
</Button>
|
</Button>
|
||||||
@@ -423,6 +424,35 @@ export function LauncherComponentsPage() {
|
|||||||
</pre>
|
</pre>
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
open={Boolean(envTarget)}
|
open={Boolean(envTarget)}
|
||||||
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
title={envTarget ? `${envTarget.name} 설정값` : '설정값'}
|
||||||
|
|||||||
Reference in New Issue
Block a user