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..79f9fbc 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,16 @@ 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..486e4d1 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, @@ -291,16 +292,24 @@ function ApplicationStatusContent() { } /> - - - - {t("apps.status.resources")} + + + + {t("apps.status.resources")} - - + +
+ +
+ {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..1904d1c --- /dev/null +++ b/web/app/apps/components/application-events-panel.tsx @@ -0,0 +1,264 @@ +"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 { useLanguage } from "@/contexts/language-context" + +interface ApplicationEventsPanelProps { + namespace: string + applicationName: string + environmentName: string + since?: string + limit?: number + refreshIntervalMs?: number + compact?: boolean +} + +export function ApplicationEventsPanel({ + namespace, + applicationName, + environmentName, + since, + limit = 200, + refreshIntervalMs = 5000, + compact = false, +}: 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 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 handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (nextOpen) { + setEvents([]) + setLoading(true) + } + } + + const renderTypeBadge = (event: ApplicationEvent) => { + const isWarning = event.type === "Warning" + return ( + + {isWarning ? : } + {eventTypeLabel(event.type)} + + ) + } + + 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 ( + + +
+ + {t("apps.events.title")} + {open && events.length > 0 && ( + + {events.length} + + )} + {open && warningCount > 0 && ( + + {warningCount} {t("apps.events.type.warning")} + + )} + {!open && latestEvent?.type === "Warning" && ( + + {t("apps.events.type.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")}
+ )} +
+ )} + + {compact ? renderCompactEvents() : renderTableEvents()} + +
+ ) +} 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..37f760b 100644 --- a/web/locales/en-US/apps.ts +++ b/web/locales/en-US/apps.ts @@ -206,6 +206,17 @@ const apps = { "apps.status.col.terminal": "Terminal", "apps.status.col.ready": "Ready", "apps.status.col.restarts": "Restarts", + "apps.events.title": "Event", + "apps.events.since": "Since", + "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", + "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..1707925 100644 --- a/web/locales/ja-JP/apps.ts +++ b/web/locales/ja-JP/apps.ts @@ -205,6 +205,17 @@ const apps = { "apps.status.col.terminal": "ターミナル", "apps.status.col.ready": "準備完了", "apps.status.col.restarts": "再起動回数", + "apps.events.title": "イベント", + "apps.events.since": "開始", + "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": "メッセージ", + "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..0845316 100644 --- a/web/locales/zh-CN/apps.ts +++ b/web/locales/zh-CN/apps.ts @@ -206,6 +206,17 @@ const apps = { "apps.status.col.terminal": "终端", "apps.status.col.ready": "就绪", "apps.status.col.restarts": "重启", + "apps.events.title": "事件", + "apps.events.since": "自", + "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": "消息", + "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..9d5b00f 100644 --- a/web/locales/zh-TW/apps.ts +++ b/web/locales/zh-TW/apps.ts @@ -206,6 +206,17 @@ const apps = { "apps.status.col.terminal": "終端", "apps.status.col.ready": "就緒", "apps.status.col.restarts": "重啟", + "apps.events.title": "事件", + "apps.events.since": "自", + "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": "訊息", + "apps.events.count": "次數", "apps.publish.appName": "應用程式名稱", "apps.publish.namespace": "命名空間", "apps.publish.env": "發布環境",