paginate app operations and refine runtime settings layout
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Search, Square, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
|
||||
import { Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import type { BuildJob, SourceComponent } from '../../types/application';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import { AppSelector, StatusBadge, formatDate, isBuildDone, useApps } from './ApplicationControls';
|
||||
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobs, listSources } from './application-api';
|
||||
import { cancelBuildJob, createBuildJob, deleteBuildJob, listBuildJobsPage, listSources } from './application-api';
|
||||
|
||||
function blockPageAgentDestructiveClick(event: MouseEvent<HTMLElement>) {
|
||||
if (event.nativeEvent.isTrusted) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildTargetForType(type?: string | null) {
|
||||
return {
|
||||
@@ -61,6 +68,20 @@ function jobTime(job: BuildJob) {
|
||||
}
|
||||
|
||||
const buildJobPageSize = 3;
|
||||
type BuildRange = 'week' | 'month' | 'all';
|
||||
|
||||
const buildRangeLabels: Record<BuildRange, string> = {
|
||||
week: '일주일',
|
||||
month: '한달',
|
||||
all: '전체'
|
||||
};
|
||||
|
||||
function buildRangeSince(range: BuildRange) {
|
||||
if (range === 'all') return undefined;
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function BuildManagementPage() {
|
||||
const { apps, selectedApp, selectedAppId, selectApp, loading: appsLoading } = useApps();
|
||||
@@ -72,9 +93,9 @@ export function BuildManagementPage() {
|
||||
const [commitSha, setCommitSha] = useState('');
|
||||
const [selectedJob, setSelectedJob] = useState<BuildJob | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [range, setRange] = useState<BuildRange>('week');
|
||||
const [totalJobs, setTotalJobs] = useState(0);
|
||||
const loadingVisible = useMinimumLoading(appsLoading || loading);
|
||||
|
||||
const componentOptions = useMemo(
|
||||
@@ -88,13 +109,36 @@ export function BuildManagementPage() {
|
||||
const selectedTarget = selectedComponent?.buildTarget || buildTargetForType(selectedComponent?.type);
|
||||
const predicted = predictedArtifact(selectedComponent, branch, commitSha);
|
||||
const hasActiveJob = jobs.some((job) => job.status === 'PENDING' || job.status === 'RUNNING');
|
||||
const totalPages = Math.max(1, Math.ceil(totalJobs / buildJobPageSize));
|
||||
const currentPage = page;
|
||||
const pagedJobs = jobs;
|
||||
|
||||
const loadJobs = async (nextPage = page, nextRange = range, appKey = selectedApp?.key) => {
|
||||
const result = await listBuildJobsPage({
|
||||
appKey,
|
||||
page: nextPage,
|
||||
limit: buildJobPageSize,
|
||||
since: buildRangeSince(nextRange)
|
||||
});
|
||||
setJobs(result.jobs);
|
||||
setTotalJobs(result.total);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextSources, nextJobs] = await Promise.all([listSources(), listBuildJobs(selectedApp?.key)]);
|
||||
const [nextSources, nextJobs] = await Promise.all([
|
||||
listSources(),
|
||||
listBuildJobsPage({
|
||||
appKey: selectedApp?.key,
|
||||
page,
|
||||
limit: buildJobPageSize,
|
||||
since: buildRangeSince(range)
|
||||
})
|
||||
]);
|
||||
setSources(nextSources);
|
||||
setJobs(nextJobs);
|
||||
setJobs(nextJobs.jobs);
|
||||
setTotalJobs(nextJobs.total);
|
||||
const filteredSources = (selectedAppId ? nextSources.filter((source) => source.appId === selectedAppId) : nextSources)
|
||||
.filter((source) => (source.imageSource ?? 'SOURCE') === 'SOURCE');
|
||||
setSelectedComponentId((prev) => {
|
||||
@@ -110,19 +154,27 @@ export function BuildManagementPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [selectedAppId, selectedApp?.key]);
|
||||
}, [selectedAppId, selectedApp?.key, page, range]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveJob) return;
|
||||
const timer = window.setInterval(() => {
|
||||
listBuildJobs(selectedApp?.key)
|
||||
.then((nextJobs) => setJobs(nextJobs))
|
||||
listBuildJobsPage({
|
||||
appKey: selectedApp?.key,
|
||||
page,
|
||||
limit: buildJobPageSize,
|
||||
since: buildRangeSince(range)
|
||||
})
|
||||
.then((nextJobs) => {
|
||||
setJobs(nextJobs.jobs);
|
||||
setTotalJobs(nextJobs.total);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', message: `빌드 작업 갱신 실패: ${error.message}` });
|
||||
});
|
||||
}, 2000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [hasActiveJob, selectedApp?.key, toast]);
|
||||
}, [hasActiveJob, selectedApp?.key, toast, page, range]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedJob) return;
|
||||
@@ -140,26 +192,9 @@ export function BuildManagementPage() {
|
||||
}
|
||||
}, [componentOptions, selectedComponentId]);
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return jobs
|
||||
.filter((job) => {
|
||||
const text = `${job.component.app.name} ${job.component.name} ${job.component.repository ?? ''} ${job.branch} ${job.commitSha ?? ''} ${resultArtifact(job)} ${job.status}`.toLowerCase();
|
||||
const matchesSearch = normalizedSearch ? text.includes(normalizedSearch) : true;
|
||||
const matchesStatus = statusFilter ? job.status === statusFilter : true;
|
||||
return matchesSearch && matchesStatus;
|
||||
})
|
||||
.sort((a, b) => jobTime(b) - jobTime(a));
|
||||
}, [jobs, search, statusFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredJobs.length / buildJobPageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageStart = (currentPage - 1) * buildJobPageSize;
|
||||
const pagedJobs = filteredJobs.slice(pageStart, pageStart + buildJobPageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, statusFilter, selectedAppId]);
|
||||
}, [selectedAppId, range]);
|
||||
|
||||
const startBuild = async () => {
|
||||
if (!selectedComponent) return;
|
||||
@@ -274,32 +309,26 @@ export function BuildManagementPage() {
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<h2 className="text-sm font-semibold text-foreground">빌드 작업 목록</h2>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<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="앱, 구성요소, 저장소 검색"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
{[...new Set(jobs.map((job) => job.status))].map((status) => (
|
||||
<option key={status} value={status}>{statusLabel(status)}</option>
|
||||
<CardHeader
|
||||
title="빌드 작업 목록"
|
||||
description="선택한 기간의 빌드 작업을 최신순으로 표시합니다."
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
{(Object.keys(buildRangeLabels) as BuildRange[]).map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={range === item ? 'default' : 'outline'}
|
||||
className="h-8 px-3 text-[13px]"
|
||||
onClick={() => setRange(item)}
|
||||
>
|
||||
{buildRangeLabels[item]}
|
||||
</Button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
{!loading && filteredJobs.length === 0 ? (
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="min-h-[410px] space-y-3 p-4">
|
||||
{!loading && jobs.length === 0 ? (
|
||||
<div className="flex min-h-44 items-center justify-center text-center text-sm text-muted-foreground">
|
||||
빌드 작업이 없습니다.
|
||||
</div>
|
||||
@@ -309,7 +338,7 @@ export function BuildManagementPage() {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">No. {filteredJobs.length - pageStart - index}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">No. {totalJobs - ((currentPage - 1) * buildJobPageSize + index)}</span>
|
||||
<StatusBadge status={statusLabel(job.status)} />
|
||||
<span className="font-medium">{job.component.app.name}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
@@ -336,7 +365,16 @@ export function BuildManagementPage() {
|
||||
</Button>
|
||||
)}
|
||||
{isBuildDone(job.status) ? (
|
||||
<Button variant="outline" className="size-8 px-0" title="삭제" aria-label="삭제" onClick={() => removeJob(job.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8 px-0"
|
||||
title="삭제"
|
||||
aria-label="삭제"
|
||||
onClick={(event) => {
|
||||
if (blockPageAgentDestructiveClick(event)) return;
|
||||
removeJob(job.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -361,7 +399,7 @@ export function BuildManagementPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loading ? '불러오는 중' : `총 ${filteredJobs.length}건 · ${currentPage} / ${totalPages}`}
|
||||
{loading ? '불러오는 중' : `총 ${totalJobs}건 · ${currentPage} / ${totalPages}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user