Merge branch 'develop' into release
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 10:25:15 +09:00
2 changed files with 184 additions and 1 deletions
+149 -1
View File
@@ -1,4 +1,4 @@
import { Database, FileArchive, RotateCcw, Server, TerminalSquare } from 'lucide-react';
import { Database, FileArchive, Plus, RotateCcw, Server, Settings, TerminalSquare, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
@@ -11,7 +11,9 @@ import {
listLauncherVersions,
loadLauncherComponentLogs,
loadLauncherRuntimeResources,
loadRuntimeWorkloadEnv,
restartLauncherComponent,
saveRuntimeWorkloadEnv,
type LauncherBuildJob,
type LauncherVersion
} from './launcher-api';
@@ -119,6 +121,10 @@ export function LauncherComponentsPage() {
const [logTarget, setLogTarget] = useState<LauncherComponentDefinition | null>(null);
const [logs, setLogs] = useState('');
const [logLoading, setLogLoading] = useState(false);
const [envTarget, setEnvTarget] = useState<LauncherComponentDefinition | null>(null);
const [envVariables, setEnvVariables] = useState<Array<{ key: string; value: string; editable: boolean; source: string }>>([]);
const [envLoading, setEnvLoading] = useState(false);
const [envSaving, setEnvSaving] = useState(false);
const loadingVisible = useMinimumLoading(loading || logLoading);
const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]);
@@ -171,6 +177,54 @@ export function LauncherComponentsPage() {
.finally(() => setActionKey(''));
};
const openEnv = (component: LauncherComponentDefinition) => {
if (!component.kind || !component.workloadName) return;
setEnvTarget(component);
setEnvVariables([]);
setEnvLoading(true);
loadRuntimeWorkloadEnv(LAUNCHER_NAMESPACE, apiKind(component.kind), component.workloadName)
.then((result) => {
setEnvVariables(result.variables ?? []);
})
.catch((error: Error) => {
toast({ type: 'error', title: '환경변수 조회 실패', message: error.message });
})
.finally(() => setEnvLoading(false));
};
const updateEnvVariable = (index: number, patch: Partial<{ key: string; value: string }>) => {
setEnvVariables((prev) => prev.map((variable, variableIndex) => (
variableIndex === index ? { ...variable, ...patch } : variable
)));
};
const removeEnvVariable = (index: number) => {
setEnvVariables((prev) => prev.filter((_, variableIndex) => variableIndex !== index));
};
const saveEnv = () => {
if (!envTarget?.kind || !envTarget.workloadName) return;
setEnvSaving(true);
saveRuntimeWorkloadEnv(
LAUNCHER_NAMESPACE,
apiKind(envTarget.kind),
envTarget.workloadName,
envVariables
.filter((variable) => variable.editable)
.map((variable) => ({ key: variable.key.trim(), value: variable.value }))
.filter((variable) => variable.key)
)
.then((result) => {
setEnvVariables(result.variables ?? []);
toast({ type: 'success', title: '환경변수 저장', message: `${envTarget.name} 환경변수를 반영했습니다.` });
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: '환경변수 저장 실패', message: error.message });
})
.finally(() => setEnvSaving(false));
};
return (
<>
<PageTitle
@@ -272,6 +326,12 @@ export function LauncherComponentsPage() {
<RotateCcw className={cn('size-4', actionKey === component.key && 'animate-spin')} />
재시작
</Button>
{component.type === 'backend' ? (
<Button variant="outline" onClick={() => openEnv(component)} disabled={!workload}>
<Settings className="size-4" />
환경변수
</Button>
) : null}
</div>
) : null}
</div>
@@ -334,6 +394,94 @@ export function LauncherComponentsPage() {
</pre>
</Modal>
<Modal
open={Boolean(envTarget)}
title={envTarget ? `${envTarget.name} 환경변수` : '환경변수'}
onClose={() => setEnvTarget(null)}
className="!max-w-4xl"
footer={
<>
<Button variant="ghost" onClick={() => setEnvTarget(null)}>닫기</Button>
<Button onClick={saveEnv} disabled={envLoading || envSaving}>
{envSaving ? '저장 중' : '저장'}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-xs leading-5 text-muted-foreground">
저장하면 Kubernetes Deployment 환경변수가 변경되고 새 Pod로 롤아웃됩니다. Secret/ConfigMap에서 주입된 값은 여기서 직접 수정하지 않습니다.
</p>
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="border-b border-border bg-muted/30 text-xs text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium">Key</th>
<th className="px-4 py-3 font-medium">Value</th>
<th className="w-24 px-4 py-3 font-medium">Source</th>
<th className="w-16 px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{envLoading ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">환경변수를 불러오는 중입니다.</td>
</tr>
) : null}
{!envLoading && envVariables.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">등록된 환경변수가 없습니다.</td>
</tr>
) : null}
{!envLoading && envVariables.map((variable, index) => (
<tr key={`${variable.key}-${index}`}>
<td className="px-4 py-3">
<input
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
value={variable.key}
disabled={!variable.editable}
onChange={(event) => updateEnvVariable(index, { key: event.target.value })}
/>
</td>
<td className="px-4 py-3">
<input
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
value={variable.value}
disabled={!variable.editable}
onChange={(event) => updateEnvVariable(index, { value: event.target.value })}
/>
</td>
<td className="px-4 py-3">
<Badge tone={variable.editable ? 'blue' : 'neutral'}>{variable.source}</Badge>
</td>
<td className="px-4 py-3">
<Button
variant="outline"
className="size-8 px-0"
disabled={!variable.editable}
onClick={() => removeEnvVariable(index)}
aria-label="환경변수 삭제"
title="환경변수 삭제"
>
<Trash2 className="size-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button
variant="outline"
onClick={() => setEnvVariables((prev) => [...prev, { key: '', value: '', editable: true, source: 'literal' }])}
disabled={envLoading}
>
<Plus className="size-4" />
환경변수 추가
</Button>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
+35
View File
@@ -314,10 +314,45 @@ export function deleteLauncherCommonSetting(id: string) {
return apiRequest<{ deleted: boolean }>(`/launcher/common-settings/${id}`, { method: 'DELETE' });
}
export type RuntimeWorkloadEnvVariable = {
key: string;
value: string;
editable: boolean;
source: string;
};
export function loadLauncherRuntimeResources(namespace = 'studio-system') {
return apiRequest<RuntimeResourceResponse>(`/apps/runtime-namespaces/${encodeURIComponent(namespace)}/resources`);
}
export function loadRuntimeWorkloadEnv(namespace: string, kind: string, name: string) {
return apiRequest<{
namespace: string;
target: string;
container: string | null;
variables: RuntimeWorkloadEnvVariable[];
}>(`/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind.toLowerCase())}/${encodeURIComponent(name)}/env`);
}
export function saveRuntimeWorkloadEnv(
namespace: string,
kind: string,
name: string,
variables: Array<{ key: string; value: string }>
) {
return apiRequest<{
namespace: string;
target: string;
container: string | null;
variables: RuntimeWorkloadEnvVariable[];
}>(`/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind.toLowerCase())}/${encodeURIComponent(name)}/env`, {
method: 'PATCH',
body: JSON.stringify({ variables })
});
}
export function loadLauncherComponentLogs(namespace: string, kind: string, name: string, tail = 120) {
return apiRequest<RuntimeWorkloadLogResponse>(
`/runtime/workloads/${encodeURIComponent(namespace)}/${encodeURIComponent(kind.toLowerCase())}/${encodeURIComponent(name)}/logs?tail=${tail}`