show woodpecker common apps on dashboard
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 18:20:35 +09:00
parent 762cd77539
commit 1f716c7719
4 changed files with 179 additions and 17 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal, Play } from 'lucide-react';
import { Button, Card, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
@@ -42,6 +42,18 @@ function migrationTime(job: RuntimeControlJob) {
return Number.isNaN(value) ? 0 : value;
}
function sanitizeK8sName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function uniqueValues(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
const migrationPageSize = 5;
export function MigrationManagementPage() {
@@ -64,6 +76,18 @@ export function MigrationManagementPage() {
const selectedRelease = releases.find((release) => release.id === releaseId);
const migrationComponents = selectedApp?.components?.filter((component) => component.type === 'MIGRATION') ?? [];
const images = imageEntries(selectedRelease?.imageTags);
const namespaceOptions = useMemo(
() => uniqueValues([selectedApp?.namespace || '', namespace]),
[namespace, selectedApp?.namespace]
);
const envSecretOptions = useMemo(
() => uniqueValues([
...images.map(([name]) => `${sanitizeK8sName(name)}-secret`),
`${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`,
envSecretName
]),
[envSecretName, images, selectedApp?.key]
);
const selectedImage = selectedImageTag(selectedRelease, imageComponentName);
const sortedJobs = [...jobs].sort((a, b) => migrationTime(b) - migrationTime(a));
const totalPages = Math.max(1, Math.ceil(sortedJobs.length / migrationPageSize));
@@ -83,7 +107,7 @@ export function MigrationManagementPage() {
setMigrationComponentId((prev) => (
migrationComponents.some((component) => component.id === prev) ? prev : migrationComponents[0]?.id || ''
));
setEnvSecretName((prev) => prev || `${selectedApp?.key ?? 'app'}-be-secret`);
setEnvSecretName((prev) => prev || `${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`);
} finally {
setLoading(false);
}
@@ -98,10 +122,14 @@ export function MigrationManagementPage() {
setImageComponentName((prev) => (nextImages.some(([name]) => name === prev) ? prev : nextImages[0]?.[0] || ''));
}, [selectedRelease?.id]);
useEffect(() => {
if (imageComponentName) setEnvSecretName(`${sanitizeK8sName(imageComponentName)}-secret`);
}, [imageComponentName]);
useEffect(() => {
setNamespace(selectedApp?.namespace || '');
setMigrationComponentId(migrationComponents[0]?.id || '');
setEnvSecretName(`${selectedApp?.key ?? 'app'}-be-secret`);
setEnvSecretName(`${sanitizeK8sName(selectedApp?.key ?? 'app')}-be-secret`);
setPage(1);
}, [selectedAppId]);
@@ -117,7 +145,7 @@ export function MigrationManagementPage() {
}
};
const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && command.trim());
const canRun = Boolean(releaseId && migrationComponentId && imageComponentName && namespace && envSecretName && command.trim());
return (
<>
@@ -182,19 +210,25 @@ export function MigrationManagementPage() {
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">네임스페이스</span>
<input
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={namespace}
onChange={(event) => setNamespace(event.target.value)}
/>
>
{namespaceOptions.length === 0 ? <option value="">네임스페이스 없음</option> : null}
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label className="block">
<span className="text-xs font-medium text-muted-foreground">envFrom Secret</span>
<input
<select
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
value={envSecretName}
onChange={(event) => setEnvSecretName(event.target.value)}
/>
>
{envSecretOptions.length === 0 ? <option value="">Secret 없음</option> : null}
{envSecretOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label className="block lg:col-span-3">
<span className="text-xs font-medium text-muted-foreground">명령</span>