From c7f2282b5b04d8c2701ce9d80e4dd39563dc804c Mon Sep 17 00:00:00 2001 From: wellCh4n Date: Tue, 9 Jun 2026 15:15:49 +0800 Subject: [PATCH 1/3] feat(events): add application Kubernetes events Expose live application Kubernetes events from the runtime gateway without persisting them. Show events on the application status page and pipeline detail runtime panel, with a collapsed latest-event summary and full table on expand. Co-authored-by: Codex --- .../application/dto/ApplicationEventView.java | 14 ++ .../port/ApplicationRuntimeGateway.java | 4 + .../service/ApplicationService.java | 29 ++- .../service/PodFileSystemService.java | 16 +- .../KubernetesApplicationRuntimeGateway.java | 140 +++++++++++-- .../rest/ApplicationController.java | 15 +- .../[name]/pipelines/[pipelineId]/page.tsx | 11 + .../apps/[namespace]/[name]/status/page.tsx | 7 + .../components/application-events-panel.tsx | 196 ++++++++++++++++++ web/lib/api/applications.ts | 19 +- web/lib/api/types.ts | 10 + web/locales/en-US/apps.ts | 9 + web/locales/ja-JP/apps.ts | 9 + web/locales/zh-CN/apps.ts | 9 + web/locales/zh-TW/apps.ts | 9 + 15 files changed, 466 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/github/wellch4n/oops/application/dto/ApplicationEventView.java create mode 100644 web/app/apps/components/application-events-panel.tsx diff --git a/src/main/java/com/github/wellch4n/oops/application/dto/ApplicationEventView.java b/src/main/java/com/github/wellch4n/oops/application/dto/ApplicationEventView.java new file mode 100644 index 0000000..7515066 --- /dev/null +++ b/src/main/java/com/github/wellch4n/oops/application/dto/ApplicationEventView.java @@ -0,0 +1,14 @@ +package com.github.wellch4n.oops.application.dto; + +import java.time.Instant; + +public record ApplicationEventView( + Instant time, + String type, + String resourceKind, + String resourceName, + String reason, + String message, + Integer count +) { +} diff --git a/src/main/java/com/github/wellch4n/oops/application/port/ApplicationRuntimeGateway.java b/src/main/java/com/github/wellch4n/oops/application/port/ApplicationRuntimeGateway.java index 5be9420..a74f4a1 100644 --- a/src/main/java/com/github/wellch4n/oops/application/port/ApplicationRuntimeGateway.java +++ b/src/main/java/com/github/wellch4n/oops/application/port/ApplicationRuntimeGateway.java @@ -2,8 +2,10 @@ import com.github.wellch4n.oops.domain.application.ApplicationRuntimeSpec; import com.github.wellch4n.oops.domain.environment.Environment; +import com.github.wellch4n.oops.application.dto.ApplicationEventView; import com.github.wellch4n.oops.application.dto.ApplicationPodStatusView; import com.github.wellch4n.oops.application.dto.DeploymentHealth; +import java.time.Instant; import java.util.List; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -17,6 +19,8 @@ void applyRuntimeSpec(Environment environment, List getPodStatuses(Environment environment, String namespace, String applicationName); + List getEvents(Environment environment, String namespace, String applicationName, Instant since, int limit); + SseEmitter watchPodStatuses(Environment environment, String namespace, String applicationName); void restartPod(Environment environment, String namespace, String podName); diff --git a/src/main/java/com/github/wellch4n/oops/application/service/ApplicationService.java b/src/main/java/com/github/wellch4n/oops/application/service/ApplicationService.java index 45f870e..f47e92a 100644 --- a/src/main/java/com/github/wellch4n/oops/application/service/ApplicationService.java +++ b/src/main/java/com/github/wellch4n/oops/application/service/ApplicationService.java @@ -17,6 +17,7 @@ import com.github.wellch4n.oops.domain.shared.ApplicationSourceType; import com.github.wellch4n.oops.domain.shared.UserRole; import com.github.wellch4n.oops.shared.exception.BizException; +import com.github.wellch4n.oops.application.dto.ApplicationEventView; import com.github.wellch4n.oops.application.dto.ApplicationPodStatusView; import com.github.wellch4n.oops.application.dto.ApplicationResourceView; import com.github.wellch4n.oops.application.dto.ApplicationDto; @@ -24,6 +25,7 @@ import com.github.wellch4n.oops.application.dto.ClusterDomainView; import com.github.wellch4n.oops.application.dto.ServiceHostConflictView; import com.github.wellch4n.oops.application.dto.Page; +import java.time.Instant; import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; import java.util.Collections; @@ -46,6 +48,8 @@ @Service public class ApplicationService { + private static final String ENVIRONMENT_NOT_FOUND = "Environment not found: "; + private final ApplicationRepository applicationRepository; private final EnvironmentRepository environmentRepository; private final UserService userService; @@ -426,7 +430,7 @@ private ApplicationExpertConfig defaultExpertConfig(String namespace, String nam public List getApplicationResources(String namespace, String name, String environmentName) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new IllegalArgumentException("Environment not found: " + environmentName); + throw new IllegalArgumentException(ENVIRONMENT_NOT_FOUND + environmentName); } return applicationExpertConfigGateway.getApplicationResources(environment, namespace, name); } @@ -506,15 +510,28 @@ public Boolean updateApplicationServiceConfig(String namespace, String name, App public List getApplicationStatus(String namespace, String name, String environmentName) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new IllegalArgumentException("Environment not found: " + environmentName); + throw new IllegalArgumentException(ENVIRONMENT_NOT_FOUND + environmentName); } return applicationRuntimeGateway.getPodStatuses(environment, namespace, name); } + public List getApplicationEvents(String namespace, + String name, + String environmentName, + Instant since, + Integer limit) { + Environment environment = environmentRepository.findFirstByName(environmentName); + if (environment == null) { + throw new IllegalArgumentException(ENVIRONMENT_NOT_FOUND + environmentName); + } + int effectiveLimit = limit == null ? 200 : Math.max(1, Math.min(limit, 500)); + return applicationRuntimeGateway.getEvents(environment, namespace, name, since, effectiveLimit); + } + public String getCurrentImage(String namespace, String name, String environmentName) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new IllegalArgumentException("Environment not found: " + environmentName); + throw new IllegalArgumentException(ENVIRONMENT_NOT_FOUND + environmentName); } return applicationRuntimeGateway.findCurrentImage(environment, namespace, name); } @@ -522,7 +539,7 @@ public String getCurrentImage(String namespace, String name, String environmentN public SseEmitter watchApplicationStatus(String namespace, String name, String environmentName) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new IllegalArgumentException("Environment not found: " + environmentName); + throw new IllegalArgumentException(ENVIRONMENT_NOT_FOUND + environmentName); } return applicationRuntimeGateway.watchPodStatuses(environment, namespace, name); } @@ -530,7 +547,7 @@ public SseEmitter watchApplicationStatus(String namespace, String name, String e public Boolean restartApplication(String namespace, String name, String podName, String environmentName) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new IllegalArgumentException("Environment not found: " + environmentName); + throw new IllegalArgumentException(ENVIRONMENT_NOT_FOUND + environmentName); } applicationRuntimeGateway.restartPod(environment, namespace, podName); return true; @@ -540,7 +557,7 @@ public ClusterDomainView getClusterDomain(String namespace, String name, String try { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new IllegalArgumentException("Environment not found: " + environmentName); + throw new IllegalArgumentException(ENVIRONMENT_NOT_FOUND + environmentName); } String internalDomain = applicationRuntimeGateway.findInternalServiceDomain(environment, namespace, name); diff --git a/src/main/java/com/github/wellch4n/oops/application/service/PodFileSystemService.java b/src/main/java/com/github/wellch4n/oops/application/service/PodFileSystemService.java index 52f13b7..5afc32f 100644 --- a/src/main/java/com/github/wellch4n/oops/application/service/PodFileSystemService.java +++ b/src/main/java/com/github/wellch4n/oops/application/service/PodFileSystemService.java @@ -17,6 +17,8 @@ @Service public class PodFileSystemService { + private static final String ENVIRONMENT_NOT_FOUND = "Environment not found: "; + private final EnvironmentRepository environmentRepository; private final PodFileSystemGateway podFileSystemGateway; private final PodFileSystemProperties podFileSystemProperties; @@ -32,7 +34,7 @@ public PodFileSystemService(EnvironmentRepository environmentRepository, public List listDirectory(String environmentName, String namespace, String podName, String container, String path) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new BizException("Environment not found: " + environmentName); + throw new BizException(ENVIRONMENT_NOT_FOUND + environmentName); } return listDirectory(environment, namespace, podName, container, path); } @@ -44,7 +46,7 @@ public List listDirectory(Environment environment, String namespac public long getFileSize(String environmentName, String namespace, String podName, String container, String path) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new BizException("Environment not found: " + environmentName); + throw new BizException(ENVIRONMENT_NOT_FOUND + environmentName); } return getFileSize(environment, namespace, podName, container, path); } @@ -56,7 +58,7 @@ public long getFileSize(Environment environment, String namespace, String podNam public void streamFile(String environmentName, String namespace, String podName, String container, String path, OutputStream outputStream) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new BizException("Environment not found: " + environmentName); + throw new BizException(ENVIRONMENT_NOT_FOUND + environmentName); } streamFile(environment, namespace, podName, container, path, outputStream); } @@ -68,7 +70,7 @@ public void streamFile(Environment environment, String namespace, String podName public void uploadFile(String environmentName, String namespace, String podName, String container, String path, InputStream inputStream) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new BizException("Environment not found: " + environmentName); + throw new BizException(ENVIRONMENT_NOT_FOUND + environmentName); } uploadFile(environment, namespace, podName, container, path, inputStream); } @@ -84,7 +86,7 @@ public void deletePath(Environment environment, String namespace, String podName public void deletePath(String environmentName, String namespace, String podName, String container, String path) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new BizException("Environment not found: " + environmentName); + throw new BizException(ENVIRONMENT_NOT_FOUND + environmentName); } deletePath(environment, namespace, podName, container, path); } @@ -96,7 +98,7 @@ public void renamePath(Environment environment, String namespace, String podName public void renamePath(String environmentName, String namespace, String podName, String container, String fromPath, String toPath) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new BizException("Environment not found: " + environmentName); + throw new BizException(ENVIRONMENT_NOT_FOUND + environmentName); } renamePath(environment, namespace, podName, container, fromPath, toPath); } @@ -108,7 +110,7 @@ public void createDirectory(Environment environment, String namespace, String po public void createDirectory(String environmentName, String namespace, String podName, String container, String path) { Environment environment = environmentRepository.findFirstByName(environmentName); if (environment == null) { - throw new BizException("Environment not found: " + environmentName); + throw new BizException(ENVIRONMENT_NOT_FOUND + environmentName); } createDirectory(environment, namespace, podName, container, path); } diff --git a/src/main/java/com/github/wellch4n/oops/infrastructure/kubernetes/KubernetesApplicationRuntimeGateway.java b/src/main/java/com/github/wellch4n/oops/infrastructure/kubernetes/KubernetesApplicationRuntimeGateway.java index 5cb700d..6941761 100644 --- a/src/main/java/com/github/wellch4n/oops/infrastructure/kubernetes/KubernetesApplicationRuntimeGateway.java +++ b/src/main/java/com/github/wellch4n/oops/infrastructure/kubernetes/KubernetesApplicationRuntimeGateway.java @@ -1,18 +1,23 @@ package com.github.wellch4n.oops.infrastructure.kubernetes; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.wellch4n.oops.application.dto.ApplicationEventView; +import com.github.wellch4n.oops.application.dto.ApplicationPodStatusView; +import com.github.wellch4n.oops.application.dto.DeploymentHealth; import com.github.wellch4n.oops.application.port.ApplicationRuntimeGateway; -import com.github.wellch4n.oops.domain.shared.OopsTypes; import com.github.wellch4n.oops.domain.application.ApplicationRuntimeSpec; import com.github.wellch4n.oops.domain.environment.Environment; -import com.github.wellch4n.oops.application.dto.ApplicationPodStatusView; -import com.github.wellch4n.oops.application.dto.DeploymentHealth; +import com.github.wellch4n.oops.domain.shared.OopsTypes; import com.github.wellch4n.oops.infrastructure.kubernetes.task.processor.StatefulSetProcessor; +import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.MicroTime; +import io.fabric8.kubernetes.api.model.ObjectReference; import io.fabric8.kubernetes.api.model.Pod; import io.fabric8.kubernetes.api.model.PodCondition; import io.fabric8.kubernetes.api.model.Quantity; import io.fabric8.kubernetes.api.model.ResourceRequirements; import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder; +import io.fabric8.kubernetes.api.model.events.v1.Event; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.Watch; import io.fabric8.kubernetes.client.Watcher; @@ -21,12 +26,15 @@ import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; @@ -38,6 +46,8 @@ public class KubernetesApplicationRuntimeGateway implements ApplicationRuntimeGa private static final String CLUSTER_SUFFIX = "cluster.local"; private static final String CLUSTER_DOMAIN_FORMAT = "%s.%s.svc.%s"; + private static final String APPLICATION_TYPE_LABEL = "oops.type"; + private static final String APPLICATION_NAME_LABEL = "oops.app.name"; private final KubernetesClientPool clientPool; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -108,12 +118,44 @@ public List getPodStatuses(Environment environment, St var client = clientPool.get(environment.getKubernetesApiServer()); var pods = client.pods() .inNamespace(namespace) - .withLabel("oops.type", OopsTypes.APPLICATION.name()) - .withLabel("oops.app.name", applicationName) + .withLabel(APPLICATION_TYPE_LABEL, OopsTypes.APPLICATION.name()) + .withLabel(APPLICATION_NAME_LABEL, applicationName) .list(); return pods.getItems().stream().map(this::toView).toList(); } + @Override + public List getEvents(Environment environment, + String namespace, + String applicationName, + Instant since, + int limit) { + var client = clientPool.get(environment.getKubernetesApiServer()); + Set podNames = client.pods() + .inNamespace(namespace) + .withLabel(APPLICATION_TYPE_LABEL, OopsTypes.APPLICATION.name()) + .withLabel(APPLICATION_NAME_LABEL, applicationName) + .list() + .getItems() + .stream() + .map(pod -> pod.getMetadata() != null ? pod.getMetadata().getName() : null) + .filter(StringUtils::isNotBlank) + .collect(Collectors.toCollection(HashSet::new)); + + return client.events().v1().events() + .inNamespace(namespace) + .list() + .getItems() + .stream() + .filter(event -> isApplicationEvent(event, applicationName, podNames)) + .map(this::toEventView) + .filter(event -> event.time() != null) + .filter(event -> since == null || !event.time().isBefore(since)) + .sorted(Comparator.comparing(ApplicationEventView::time).reversed()) + .limit(limit) + .toList(); + } + @Override public SseEmitter watchPodStatuses(Environment environment, String namespace, String applicationName) { SseEmitter emitter = new SseEmitter(0L); @@ -159,8 +201,8 @@ public SseEmitter watchPodStatuses(Environment environment, String namespace, St try { var podsResource = client.pods() .inNamespace(namespace) - .withLabel("oops.type", OopsTypes.APPLICATION.name()) - .withLabel("oops.app.name", applicationName); + .withLabel(APPLICATION_TYPE_LABEL, OopsTypes.APPLICATION.name()) + .withLabel(APPLICATION_NAME_LABEL, applicationName); var initial = podsResource.list(); for (Pod pod : initial.getItems()) { @@ -251,6 +293,74 @@ private ApplicationPodStatusView toView(Pod pod) { return view; } + private boolean isApplicationEvent(Event event, String applicationName, Set podNames) { + return isApplicationObjectReference(event.getRegarding(), applicationName, podNames) + || isApplicationObjectReference(event.getRelated(), applicationName, podNames); + } + + private boolean isApplicationObjectReference(ObjectReference reference, String applicationName, Set podNames) { + if (reference == null || StringUtils.isBlank(reference.getKind()) || StringUtils.isBlank(reference.getName())) { + return false; + } + String kind = reference.getKind(); + String name = reference.getName(); + if ("Pod".equals(kind)) { + return podNames.contains(name) || name.startsWith(applicationName + "-"); + } + if ("StatefulSet".equals(kind) || "Service".equals(kind) || "ConfigMap".equals(kind)) { + return applicationName.equals(name); + } + if ("IngressRoute".equals(kind)) { + return applicationName.equals(name) || name.startsWith(applicationName + "-"); + } + return false; + } + + private ApplicationEventView toEventView(Event event) { + ObjectReference reference = event.getRegarding(); + String resourceKind = reference != null ? reference.getKind() : null; + String resourceName = reference != null ? reference.getName() : null; + return new ApplicationEventView( + eventInstant(event), + event.getType(), + resourceKind, + resourceName, + event.getReason(), + event.getNote(), + eventCount(event) + ); + } + + private Integer eventCount(Event event) { + if (event.getSeries() != null && event.getSeries().getCount() != null) { + return event.getSeries().getCount(); + } + if (event.getDeprecatedCount() != null) { + return event.getDeprecatedCount(); + } + return 1; + } + + private Instant eventInstant(Event event) { + Instant seriesTime = event.getSeries() != null ? parseMicroTime(event.getSeries().getLastObservedTime()) : null; + if (seriesTime != null) { + return seriesTime; + } + Instant eventTime = parseMicroTime(event.getEventTime()); + if (eventTime != null) { + return eventTime; + } + Instant deprecatedLastTimestamp = parseKubernetesInstant(event.getDeprecatedLastTimestamp()); + if (deprecatedLastTimestamp != null) { + return deprecatedLastTimestamp; + } + return event.getMetadata() != null ? parseKubernetesInstant(event.getMetadata().getCreationTimestamp()) : null; + } + + private Instant parseMicroTime(MicroTime microTime) { + return microTime != null ? parseKubernetesInstant(microTime.getTime()) : null; + } + @Override public void restartPod(Environment environment, String namespace, String podName) { clientPool.get(environment.getKubernetesApiServer()) @@ -260,7 +370,7 @@ public void restartPod(Environment environment, String namespace, String podName @Override public String findInternalServiceDomain(Environment environment, String namespace, String applicationName) { var client = clientPool.get(environment.getKubernetesApiServer()); - var services = client.services().inNamespace(namespace).withLabel("oops.app.name", applicationName).list().getItems(); + var services = client.services().inNamespace(namespace).withLabel(APPLICATION_NAME_LABEL, applicationName).list().getItems(); if (services.isEmpty()) { return null; } @@ -287,13 +397,13 @@ public String findCurrentImage(Environment environment, String namespace, String var containers = statefulSet.getSpec().getTemplate().getSpec().getContainers(); return containers.stream() .filter(container -> applicationName.equals(container.getName())) - .map(io.fabric8.kubernetes.api.model.Container::getImage) + .map(Container::getImage) .findFirst() .orElseGet(() -> containers.isEmpty() ? null : containers.getFirst().getImage()); } - private static final java.util.Set FATAL_WAITING_REASONS = - java.util.Set.of("ImagePullBackOff", "ErrImagePull", "CrashLoopBackOff"); + private static final Set FATAL_WAITING_REASONS = + Set.of("ImagePullBackOff", "ErrImagePull", "CrashLoopBackOff"); @Override public DeploymentHealth getDeploymentHealth(Environment environment, String namespace, String applicationName) { @@ -348,8 +458,8 @@ private Instant findRolloutNotReadySince( private Optional findOldestNotReadyPodTime(KubernetesClient client, String namespace, String applicationName) { var pods = client.pods() .inNamespace(namespace) - .withLabel("oops.type", OopsTypes.APPLICATION.name()) - .withLabel("oops.app.name", applicationName) + .withLabel(APPLICATION_TYPE_LABEL, OopsTypes.APPLICATION.name()) + .withLabel(APPLICATION_NAME_LABEL, applicationName) .list(); return pods.getItems().stream() .map(this::notReadySince) @@ -391,8 +501,8 @@ private Instant parseKubernetesInstant(String value) { private String findFatalPodWaitingReason(KubernetesClient client, String namespace, String applicationName) { var pods = client.pods() .inNamespace(namespace) - .withLabel("oops.type", OopsTypes.APPLICATION.name()) - .withLabel("oops.app.name", applicationName) + .withLabel(APPLICATION_TYPE_LABEL, OopsTypes.APPLICATION.name()) + .withLabel(APPLICATION_NAME_LABEL, applicationName) .list(); for (Pod pod : pods.getItems()) { if (pod.getStatus() == null || pod.getStatus().getContainerStatuses() == null) { diff --git a/src/main/java/com/github/wellch4n/oops/interfaces/rest/ApplicationController.java b/src/main/java/com/github/wellch4n/oops/interfaces/rest/ApplicationController.java index 039cdf6..ebcae60 100644 --- a/src/main/java/com/github/wellch4n/oops/interfaces/rest/ApplicationController.java +++ b/src/main/java/com/github/wellch4n/oops/interfaces/rest/ApplicationController.java @@ -2,6 +2,7 @@ import com.github.wellch4n.oops.interfaces.dto.AuthUserPrincipal; import com.github.wellch4n.oops.application.dto.ApplicationConfigDto; +import com.github.wellch4n.oops.application.dto.ApplicationEventView; import com.github.wellch4n.oops.application.dto.ApplicationPodStatusView; import com.github.wellch4n.oops.application.dto.ApplicationDto; import com.github.wellch4n.oops.application.dto.ClusterDomainView; @@ -13,6 +14,7 @@ import com.github.wellch4n.oops.application.service.ApplicationService; import com.github.wellch4n.oops.application.service.PipelineService; import com.github.wellch4n.oops.shared.util.ResourceNameChecker; +import java.time.Instant; import java.util.List; import org.springframework.security.access.prepost.PreAuthorize; @@ -217,11 +219,20 @@ public Result getClusterDomain(@PathVariable String namespace @GetMapping("/{name}/status") public Result> getApplicationStatus(@PathVariable String namespace, - @PathVariable String name, - @RequestParam String env) { + @PathVariable String name, + @RequestParam String env) { return Result.success(applicationService.getApplicationStatus(namespace, name, env)); } + @GetMapping("/{name}/events") + public Result> getApplicationEvents(@PathVariable String namespace, + @PathVariable String name, + @RequestParam String env, + @RequestParam(required = false) Instant since, + @RequestParam(required = false) Integer limit) { + return Result.success(applicationService.getApplicationEvents(namespace, name, env, since, limit)); + } + @GetMapping("/{name}/current-image") public Result getCurrentImage(@PathVariable String namespace, @PathVariable String name, diff --git a/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx b/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx index ca8114f..9f74e73 100644 --- a/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx +++ b/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx @@ -38,6 +38,7 @@ import { } from "@/components/ui/table" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { shortImageName } from "@/lib/utils" +import { ApplicationEventsPanel } from "@/app/apps/components/application-events-panel" // WebSocket message types interface StepsMessage { @@ -347,6 +348,7 @@ export default function PipelineDetailPage({ params }: PageProps) { const activeIndex = (pipeline?.status === "SUCCEEDED" || pipeline?.status === "BUILD_SUCCEEDED") ? steps.length : steps.indexOf(activeStep) + const applicationEventSince = pipeline?.createdTime ? dayjs(pipeline.createdTime).toISOString() : undefined return ( @@ -504,6 +506,15 @@ export default function PipelineDetailPage({ params }: PageProps) { )} row.name} renderExpandedRow={renderExpandedRow} /> + {pipeline?.environment && ( + + )} diff --git a/web/app/apps/[namespace]/[name]/status/page.tsx b/web/app/apps/[namespace]/[name]/status/page.tsx index 69fece3..d2d3484 100644 --- a/web/app/apps/[namespace]/[name]/status/page.tsx +++ b/web/app/apps/[namespace]/[name]/status/page.tsx @@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge" import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" import { ApplicationResourceViewer } from "@/app/apps/components/application-resource-viewer" +import { ApplicationEventsPanel } from "@/app/apps/components/application-events-panel" import { AlertDialog, AlertDialogAction, @@ -301,6 +302,12 @@ function ApplicationStatusContent() { + {selectedEnv && ( +
+ +
+ )} + diff --git a/web/app/apps/components/application-events-panel.tsx b/web/app/apps/components/application-events-panel.tsx new file mode 100644 index 0000000..9364f3f --- /dev/null +++ b/web/app/apps/components/application-events-panel.tsx @@ -0,0 +1,196 @@ +"use client" + +import { useEffect, useState } from "react" +import dayjs from "dayjs" +import { AlertCircle, ChevronRight, Info } from "lucide-react" +import { getApplicationEvents } from "@/lib/api/applications" +import { ApplicationEvent } from "@/lib/api/types" +import { Badge } from "@/components/ui/badge" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { useLanguage } from "@/contexts/language-context" + +interface ApplicationEventsPanelProps { + namespace: string + applicationName: string + environmentName: string + since?: string + limit?: number + refreshIntervalMs?: number +} + +export function ApplicationEventsPanel({ + namespace, + applicationName, + environmentName, + since, + limit = 200, + refreshIntervalMs = 5000, +}: ApplicationEventsPanelProps) { + const { t } = useLanguage() + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const effectiveLimit = open ? limit : 1 + + useEffect(() => { + setEvents([]) + setLoading(false) + }, [namespace, applicationName, environmentName, since, limit]) + + useEffect(() => { + if (!environmentName) return + + let cancelled = false + const loadEvents = async (showLoading = false) => { + if (showLoading) setLoading(true) + try { + const response = await getApplicationEvents(namespace, applicationName, environmentName, { since, limit: effectiveLimit }) + if (!cancelled) setEvents(response.data ?? []) + } catch { + if (!cancelled) setEvents([]) + } finally { + if (!cancelled && showLoading) setLoading(false) + } + } + + loadEvents(true) + const intervalId = setInterval(() => loadEvents(false), refreshIntervalMs) + return () => { + cancelled = true + clearInterval(intervalId) + } + }, [namespace, applicationName, environmentName, since, effectiveLimit, refreshIntervalMs]) + + const warningCount = events.filter((event) => event.type === "Warning").length + const latestEvent = events[0] + const latestResource = latestEvent ? [latestEvent.resourceKind, latestEvent.resourceName].filter(Boolean).join("/") : "" + + const renderTypeBadge = (event: ApplicationEvent) => { + const isWarning = event.type === "Warning" + return ( + + {isWarning ? : } + {event.type || "-"} + + ) + } + + return ( + + +
+ + {t("apps.events.title")} + {open && events.length > 0 && ( + + {events.length} + + )} + {open && warningCount > 0 && ( + + {warningCount} Warning + + )} + {!open && latestEvent?.type === "Warning" && ( + + Warning + + )} +
+ {since && ( + + {t("apps.events.since")} {dayjs(since).format("YYYY-MM-DD HH:mm:ss")} + + )} +
+ {!open && ( +
+ {loading && events.length === 0 ? ( +
{t("common.loading")}
+ ) : latestEvent ? ( + <> + + {dayjs(latestEvent.time).format("MM-DD HH:mm:ss")} + + {renderTypeBadge(latestEvent)} +
+ {latestEvent.reason || "-"} + + {latestResource ? ` · ${latestResource}` : ""} + {latestEvent.message ? ` · ${latestEvent.message}` : ""} + +
+ + ) : ( +
{t("apps.events.empty")}
+ )} +
+ )} + +
+ + + + {t("apps.events.time")} + {t("apps.events.type")} + {t("apps.events.resource")} + {t("apps.events.reason")} + {t("apps.events.message")} + {t("apps.events.count")} + + + + {loading && events.length === 0 && ( + + + {t("common.loading")} + + + )} + {!loading && events.length === 0 && ( + + + {t("apps.events.empty")} + + + )} + {events.map((event, index) => { + const resource = [event.resourceKind, event.resourceName].filter(Boolean).join("/") + return ( + + + {dayjs(event.time).format("MM-DD HH:mm:ss")} + + + {renderTypeBadge(event)} + + + {resource || "-"} + + + {event.reason || "-"} + + + {event.message || "-"} + + + {event.count ?? 1} + + + ) + })} + +
+
+
+
+ ) +} diff --git a/web/lib/api/applications.ts b/web/lib/api/applications.ts index 4a99333..e96b2f2 100644 --- a/web/lib/api/applications.ts +++ b/web/lib/api/applications.ts @@ -1,6 +1,6 @@ import { apiFetch } from "./client" import { watchSse, SseWatchOptions } from "./sse" -import { Application, ApiResponse, ApplicationBuildConfig, ApplicationBuildEnvironmentConfig, ApplicationRuntimeSpec, ApplicationExpertConfig, ApplicationResource, ApplicationEnvironment, ApplicationPodStatus, ConfigMap, ApplicationServiceConfig, ClusterDomainInfo, DeployRequest, Page, LastSuccessfulPipelineInfo } from "./types" +import { Application, ApiResponse, ApplicationBuildConfig, ApplicationBuildEnvironmentConfig, ApplicationRuntimeSpec, ApplicationExpertConfig, ApplicationResource, ApplicationEnvironment, ApplicationPodStatus, ApplicationEvent, ConfigMap, ApplicationServiceConfig, ClusterDomainInfo, DeployRequest, Page, LastSuccessfulPipelineInfo } from "./types" export interface BuildSourceUploadRequest { fileName: string @@ -285,6 +285,23 @@ export const getApplicationStatus = async (namespace: string, name: string, env: return response.json() as Promise> } +export const getApplicationEvents = async ( + namespace: string, + name: string, + env: string, + options?: { since?: string; limit?: number } +): Promise> => { + const params = new URLSearchParams({ env }) + if (options?.since) params.set("since", options.since) + if (options?.limit !== undefined) params.set("limit", String(options.limit)) + + const response = await apiFetch(`/api/namespaces/${namespace}/applications/${name}/events?${params.toString()}`) + if (!response.ok) { + throw new Error("Failed to fetch application events") + } + return response.json() as Promise> +} + // Events streamed by GET /api/namespaces/{ns}/applications/{name}/status/watch. // Backend emits `status` with the full pod list snapshot whenever any pod // in the application changes. diff --git a/web/lib/api/types.ts b/web/lib/api/types.ts index 2e6239b..a92af83 100644 --- a/web/lib/api/types.ts +++ b/web/lib/api/types.ts @@ -186,6 +186,16 @@ export interface ApplicationPodStatus { containers: ApplicationContainerStatus[] } +export interface ApplicationEvent { + time: string + type?: string | null + resourceKind?: string | null + resourceName?: string | null + reason?: string | null + message?: string | null + count?: number | null +} + interface ApplicationContainerStatus { name: string image: string diff --git a/web/locales/en-US/apps.ts b/web/locales/en-US/apps.ts index d19621d..70cd58d 100644 --- a/web/locales/en-US/apps.ts +++ b/web/locales/en-US/apps.ts @@ -206,6 +206,15 @@ const apps = { "apps.status.col.terminal": "Terminal", "apps.status.col.ready": "Ready", "apps.status.col.restarts": "Restarts", + "apps.events.title": "Events", + "apps.events.since": "Since", + "apps.events.empty": "No events", + "apps.events.time": "Time", + "apps.events.type": "Type", + "apps.events.resource": "Resource", + "apps.events.reason": "Reason", + "apps.events.message": "Message", + "apps.events.count": "Count", "apps.publish.appName": "Application", "apps.publish.namespace": "Namespace", "apps.publish.env": "Deploy Environment", diff --git a/web/locales/ja-JP/apps.ts b/web/locales/ja-JP/apps.ts index b8ae310..d7d9739 100644 --- a/web/locales/ja-JP/apps.ts +++ b/web/locales/ja-JP/apps.ts @@ -205,6 +205,15 @@ const apps = { "apps.status.col.terminal": "ターミナル", "apps.status.col.ready": "準備完了", "apps.status.col.restarts": "再起動回数", + "apps.events.title": "Events", + "apps.events.since": "開始", + "apps.events.empty": "events はありません", + "apps.events.time": "時刻", + "apps.events.type": "種類", + "apps.events.resource": "リソース", + "apps.events.reason": "理由", + "apps.events.message": "メッセージ", + "apps.events.count": "回数", "apps.publish.appName": "アプリケーション名", "apps.publish.namespace": "名前空間", "apps.publish.env": "公開環境", diff --git a/web/locales/zh-CN/apps.ts b/web/locales/zh-CN/apps.ts index d8dc2a2..4729648 100644 --- a/web/locales/zh-CN/apps.ts +++ b/web/locales/zh-CN/apps.ts @@ -206,6 +206,15 @@ const apps = { "apps.status.col.terminal": "终端", "apps.status.col.ready": "就绪", "apps.status.col.restarts": "重启", + "apps.events.title": "Events", + "apps.events.since": "自", + "apps.events.empty": "暂无 events", + "apps.events.time": "时间", + "apps.events.type": "类型", + "apps.events.resource": "资源", + "apps.events.reason": "原因", + "apps.events.message": "消息", + "apps.events.count": "次数", "apps.publish.appName": "应用名称", "apps.publish.namespace": "命名空间", "apps.publish.env": "发布环境", diff --git a/web/locales/zh-TW/apps.ts b/web/locales/zh-TW/apps.ts index a6ba922..1e0aed9 100644 --- a/web/locales/zh-TW/apps.ts +++ b/web/locales/zh-TW/apps.ts @@ -206,6 +206,15 @@ const apps = { "apps.status.col.terminal": "終端", "apps.status.col.ready": "就緒", "apps.status.col.restarts": "重啟", + "apps.events.title": "Events", + "apps.events.since": "自", + "apps.events.empty": "暫無 events", + "apps.events.time": "時間", + "apps.events.type": "類型", + "apps.events.resource": "資源", + "apps.events.reason": "原因", + "apps.events.message": "訊息", + "apps.events.count": "次數", "apps.publish.appName": "應用程式名稱", "apps.publish.namespace": "命名空間", "apps.publish.env": "發布環境", From 7e1c3042100a677c0825b6c32fc81f0681405f4c Mon Sep 17 00:00:00 2001 From: wellCh4n Date: Tue, 9 Jun 2026 15:31:06 +0800 Subject: [PATCH 2/3] fix(events): align event panel layout Localize event labels and make resource and event diagnostics use matching collapsible panels. Constrain the event table to the page width and wrap long messages instead of relying on horizontal scrolling. Co-authored-by: Codex --- .../apps/[namespace]/[name]/status/page.tsx | 14 ++- .../components/application-events-panel.tsx | 108 ++++++++++-------- web/locales/en-US/apps.ts | 6 +- web/locales/ja-JP/apps.ts | 6 +- web/locales/zh-CN/apps.ts | 6 +- web/locales/zh-TW/apps.ts | 6 +- 6 files changed, 82 insertions(+), 64 deletions(-) diff --git a/web/app/apps/[namespace]/[name]/status/page.tsx b/web/app/apps/[namespace]/[name]/status/page.tsx index d2d3484..486e4d1 100644 --- a/web/app/apps/[namespace]/[name]/status/page.tsx +++ b/web/app/apps/[namespace]/[name]/status/page.tsx @@ -292,13 +292,15 @@ function ApplicationStatusContent() { } /> - - - - {t("apps.status.resources")} + + + + {t("apps.status.resources")} - - + +
+ +
diff --git a/web/app/apps/components/application-events-panel.tsx b/web/app/apps/components/application-events-panel.tsx index 9364f3f..85da344 100644 --- a/web/app/apps/components/application-events-panel.tsx +++ b/web/app/apps/components/application-events-panel.tsx @@ -7,14 +7,6 @@ import { getApplicationEvents } from "@/lib/api/applications" import { ApplicationEvent } from "@/lib/api/types" import { Badge } from "@/components/ui/badge" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" import { useLanguage } from "@/contexts/language-context" interface ApplicationEventsPanelProps { @@ -72,20 +64,25 @@ export function ApplicationEventsPanel({ const warningCount = events.filter((event) => event.type === "Warning").length const latestEvent = events[0] const latestResource = latestEvent ? [latestEvent.resourceKind, latestEvent.resourceName].filter(Boolean).join("/") : "" + const eventTypeLabel = (type?: string | null) => { + if (type === "Warning") return t("apps.events.type.warning") + if (type === "Normal") return t("apps.events.type.normal") + return type || "-" + } const renderTypeBadge = (event: ApplicationEvent) => { const isWarning = event.type === "Warning" return ( {isWarning ? : } - {event.type || "-"} + {eventTypeLabel(event.type)} ) } return ( - +
{t("apps.events.title")} @@ -96,12 +93,12 @@ export function ApplicationEventsPanel({ )} {open && warningCount > 0 && ( - {warningCount} Warning + {warningCount} {t("apps.events.type.warning")} )} {!open && latestEvent?.type === "Warning" && ( - Warning + {t("apps.events.type.warning")} )}
@@ -134,61 +131,72 @@ export function ApplicationEventsPanel({ )} )} - -
- - - - {t("apps.events.time")} - {t("apps.events.type")} - {t("apps.events.resource")} - {t("apps.events.reason")} - {t("apps.events.message")} - {t("apps.events.count")} - - - + +
+
+ + + + + + + + + + + + + + + + + + + {loading && events.length === 0 && ( - - + + + )} {!loading && events.length === 0 && ( - - + + + )} {events.map((event, index) => { const resource = [event.resourceKind, event.resourceName].filter(Boolean).join("/") return ( - - + + + + + + + + ) })} - -
{t("apps.events.time")}{t("apps.events.type")}{t("apps.events.resource")}{t("apps.events.reason")}{t("apps.events.message")}{t("apps.events.count")}
{t("common.loading")} - - +
{t("apps.events.empty")} - - +
{dayjs(event.time).format("MM-DD HH:mm:ss")} - - + {renderTypeBadge(event)} - - + {resource || "-"} - - + {event.reason || "-"} - - + {event.message || "-"} - - + {event.count ?? 1} - - +
+ +
diff --git a/web/locales/en-US/apps.ts b/web/locales/en-US/apps.ts index 70cd58d..37f760b 100644 --- a/web/locales/en-US/apps.ts +++ b/web/locales/en-US/apps.ts @@ -206,11 +206,13 @@ const apps = { "apps.status.col.terminal": "Terminal", "apps.status.col.ready": "Ready", "apps.status.col.restarts": "Restarts", - "apps.events.title": "Events", + "apps.events.title": "Event", "apps.events.since": "Since", - "apps.events.empty": "No events", + "apps.events.empty": "No event", "apps.events.time": "Time", "apps.events.type": "Type", + "apps.events.type.normal": "Normal", + "apps.events.type.warning": "Warning", "apps.events.resource": "Resource", "apps.events.reason": "Reason", "apps.events.message": "Message", diff --git a/web/locales/ja-JP/apps.ts b/web/locales/ja-JP/apps.ts index d7d9739..1707925 100644 --- a/web/locales/ja-JP/apps.ts +++ b/web/locales/ja-JP/apps.ts @@ -205,11 +205,13 @@ const apps = { "apps.status.col.terminal": "ターミナル", "apps.status.col.ready": "準備完了", "apps.status.col.restarts": "再起動回数", - "apps.events.title": "Events", + "apps.events.title": "イベント", "apps.events.since": "開始", - "apps.events.empty": "events はありません", + "apps.events.empty": "イベントはありません", "apps.events.time": "時刻", "apps.events.type": "種類", + "apps.events.type.normal": "通常", + "apps.events.type.warning": "警告", "apps.events.resource": "リソース", "apps.events.reason": "理由", "apps.events.message": "メッセージ", diff --git a/web/locales/zh-CN/apps.ts b/web/locales/zh-CN/apps.ts index 4729648..0845316 100644 --- a/web/locales/zh-CN/apps.ts +++ b/web/locales/zh-CN/apps.ts @@ -206,11 +206,13 @@ const apps = { "apps.status.col.terminal": "终端", "apps.status.col.ready": "就绪", "apps.status.col.restarts": "重启", - "apps.events.title": "Events", + "apps.events.title": "事件", "apps.events.since": "自", - "apps.events.empty": "暂无 events", + "apps.events.empty": "暂无事件", "apps.events.time": "时间", "apps.events.type": "类型", + "apps.events.type.normal": "正常", + "apps.events.type.warning": "警告", "apps.events.resource": "资源", "apps.events.reason": "原因", "apps.events.message": "消息", diff --git a/web/locales/zh-TW/apps.ts b/web/locales/zh-TW/apps.ts index 1e0aed9..9d5b00f 100644 --- a/web/locales/zh-TW/apps.ts +++ b/web/locales/zh-TW/apps.ts @@ -206,11 +206,13 @@ const apps = { "apps.status.col.terminal": "終端", "apps.status.col.ready": "就緒", "apps.status.col.restarts": "重啟", - "apps.events.title": "Events", + "apps.events.title": "事件", "apps.events.since": "自", - "apps.events.empty": "暫無 events", + "apps.events.empty": "暫無事件", "apps.events.time": "時間", "apps.events.type": "類型", + "apps.events.type.normal": "正常", + "apps.events.type.warning": "警告", "apps.events.resource": "資源", "apps.events.reason": "原因", "apps.events.message": "訊息", From 651a8902ec8022e69b017bc2d212c49fcb2e4ef1 Mon Sep 17 00:00:00 2001 From: wellCh4n Date: Tue, 9 Jun 2026 16:03:59 +0800 Subject: [PATCH 3/3] fix(events): stabilize pipeline event panel Use a compact event list in the pipeline detail side panel so narrow layouts do not squeeze table columns. Clear the collapsed one-row preview when expanding, so the header does not briefly show 1 before the full event list loads. Co-authored-by: Codex --- .../[name]/pipelines/[pipelineId]/page.tsx | 1 + .../components/application-events-panel.tsx | 194 ++++++++++++------ 2 files changed, 128 insertions(+), 67 deletions(-) diff --git a/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx b/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx index 9f74e73..79f9fbc 100644 --- a/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx +++ b/web/app/apps/[namespace]/[name]/pipelines/[pipelineId]/page.tsx @@ -513,6 +513,7 @@ export default function PipelineDetailPage({ params }: PageProps) { environmentName={pipeline.environment} since={applicationEventSince} limit={100} + compact /> )} diff --git a/web/app/apps/components/application-events-panel.tsx b/web/app/apps/components/application-events-panel.tsx index 85da344..1904d1c 100644 --- a/web/app/apps/components/application-events-panel.tsx +++ b/web/app/apps/components/application-events-panel.tsx @@ -16,6 +16,7 @@ interface ApplicationEventsPanelProps { since?: string limit?: number refreshIntervalMs?: number + compact?: boolean } export function ApplicationEventsPanel({ @@ -25,6 +26,7 @@ export function ApplicationEventsPanel({ since, limit = 200, refreshIntervalMs = 5000, + compact = false, }: ApplicationEventsPanelProps) { const { t } = useLanguage() const [events, setEvents] = useState([]) @@ -70,6 +72,14 @@ export function ApplicationEventsPanel({ return type || "-" } + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (nextOpen) { + setEvents([]) + setLoading(true) + } + } + const renderTypeBadge = (event: ApplicationEvent) => { const isWarning = event.type === "Warning" return ( @@ -80,8 +90,123 @@ export function ApplicationEventsPanel({ ) } + const renderEmptyState = () => { + if (loading && events.length === 0) { + return
{t("common.loading")}
+ } + if (events.length === 0) { + return
{t("apps.events.empty")}
+ } + return null + } + + const renderCompactEvents = () => ( +
+ {renderEmptyState()} + {events.map((event, index) => { + const resource = [event.resourceKind, event.resourceName].filter(Boolean).join("/") + return ( +
+
+
+ {renderTypeBadge(event)} + + {dayjs(event.time).format("MM-DD HH:mm:ss")} + + + {resource || "-"} + +
+ + x{event.count ?? 1} + +
+
+
{event.reason || "-"}
+
+ {event.message || "-"} +
+
+
+ ) + })} +
+ ) + + const renderTableEvents = () => ( +
+ + + + + + + + + + + + + + + + + + + + + {loading && events.length === 0 && ( + + + + )} + {!loading && events.length === 0 && ( + + + + )} + {events.map((event, index) => { + const resource = [event.resourceKind, event.resourceName].filter(Boolean).join("/") + return ( + + + + + + + + + ) + })} + +
{t("apps.events.time")}{t("apps.events.type")}{t("apps.events.resource")}{t("apps.events.reason")}{t("apps.events.message")}{t("apps.events.count")}
+ {t("common.loading")} +
+ {t("apps.events.empty")} +
+ {dayjs(event.time).format("MM-DD HH:mm:ss")} + + {renderTypeBadge(event)} + + {resource || "-"} + + {event.reason || "-"} + + {event.message || "-"} + + {event.count ?? 1} +
+
+ ) + return ( - +
@@ -132,72 +257,7 @@ export function ApplicationEventsPanel({
)} -
- - - - - - - - - - - - - - - - - - - - - {loading && events.length === 0 && ( - - - - )} - {!loading && events.length === 0 && ( - - - - )} - {events.map((event, index) => { - const resource = [event.resourceKind, event.resourceName].filter(Boolean).join("/") - return ( - - - - - - - - - ) - })} - -
{t("apps.events.time")}{t("apps.events.type")}{t("apps.events.resource")}{t("apps.events.reason")}{t("apps.events.message")}{t("apps.events.count")}
- {t("common.loading")} -
- {t("apps.events.empty")} -
- {dayjs(event.time).format("MM-DD HH:mm:ss")} - - {renderTypeBadge(event)} - - {resource || "-"} - - {event.reason || "-"} - - {event.message || "-"} - - {event.count ?? 1} -
-
+ {compact ? renderCompactEvents() : renderTableEvents()}
)