feat: introduce pod status indicators and global polling service for app monitoring

This commit is contained in:
biersoeckli
2025-12-15 08:38:14 +00:00
parent 32e61b82fd
commit 76f92f8a56
13 changed files with 310 additions and 4 deletions

View File

@@ -22,7 +22,8 @@
"donjayamanne.githistory",
"qwtel.sqlite-viewer",
"mindaro.mindaro",
"shd101wyy.markdown-preview-enhanced"
"shd101wyy.markdown-preview-enhanced",
"ms-kubernetes-tools.vscode-kubernetes-tools"
]
}
},

View File

@@ -1,7 +1,7 @@
import k3s from "@/server/adapter/kubernetes-api.adapter";
import appService from "@/server/services/app.service";
import deploymentService from "@/server/services/deployment.service";
import { getAuthUserSession, isAuthorizedReadForApp, simpleRoute } from "@/server/utils/action-wrapper.utils";
import { isAuthorizedReadForApp, simpleRoute } from "@/server/utils/action-wrapper.utils";
import { Informer, V1Pod } from "@kubernetes/client-node";
import { z } from "zod";
import * as k8s from '@kubernetes/client-node';

View File

@@ -0,0 +1,52 @@
'use server'
import { ServerActionResult } from "@/shared/model/server-action-error-return.model";
import projectService from "@/server/services/project.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import deploymentService from "@/server/services/deployment.service";
import { DeplyomentStatus } from "@/shared/model/deployment-info.model";
export interface AppPodsStatusModel {
appId: string;
appName: string;
projectId: string;
projectName: string;
replicas?: number;
readyReplicas?: number;
deploymentStatus: DeplyomentStatus;
}
export const getAllPodsStatus = async () =>
simpleAction(async () => {
await getAuthUserSession();
const allAppPods: AppPodsStatusModel[] = [];
const [projects, allDeployments] = await Promise.all([
projectService.getAllProjects(),
deploymentService.getAllDeployments()
]);
for (const project of projects) {
for (const app of project.apps) {
const deploymentInfo = allDeployments.find(dep =>
dep.metadata?.namespace === project.id &&
dep.metadata?.name === app.id
);
if (!deploymentInfo) {
continue;
}
const deploymentStatus = deploymentService.mapReplicasetToStatus(deploymentInfo);
allAppPods.push({
appId: app.id,
appName: app.name,
projectId: project.id,
projectName: project.name,
replicas: deploymentInfo.status?.replicas,
readyReplicas: deploymentInfo.status?.readyReplicas,
deploymentStatus
});
}
}
return allAppPods;
}) as Promise<ServerActionResult<unknown, AppPodsStatusModel[]>>;

View File

@@ -14,6 +14,7 @@ import { BreadcrumbsGenerator } from "../components/custom/breadcrumbs-generator
import { getUserSession } from "@/server/utils/action-wrapper.utils";
import { InputDialog } from "@/components/custom/input-dialog";
import userGroupService from "@/server/services/user-group.service";
import PodsStatusPollingProvider from "@/frontend/components/pods-status-polling-provider";
const inter = Inter({
subsets: ["latin"],
@@ -67,6 +68,7 @@ export default async function RootLayout({
<Toaster />
<ConfirmDialog />
<InputDialog />
{userIsLoggedIn && <PodsStatusPollingProvider />}
</body>
</html>
);

View File

@@ -18,6 +18,7 @@ import { ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { AppMonitoringUsageModel } from '@/shared/model/app-monitoring-usage.model';
import PodStatusIndicator from '@/components/custom/pod-status-indicator';
export default function AppRessourceMonitoring({
appsRessourceUsage
@@ -66,6 +67,7 @@ export default function AppRessourceMonitoring({
<TableCaption>{updatedAppUsage.length} Apps</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Project</TableHead>
<TableHead>App</TableHead>
<TableHead>CPU</TableHead>
@@ -76,6 +78,9 @@ export default function AppRessourceMonitoring({
<TableBody>
{updatedAppUsage.map((item, index) => (
<TableRow key={item.appId}>
<TableCell>
<PodStatusIndicator appId={item.appId} showLabel={true} />
</TableCell>
<TableCell>{item.projectName}</TableCell>
<TableCell>{item.appName}</TableCell>
<TableCell>

View File

@@ -15,6 +15,7 @@ import { useEffect } from "react";
import { EditAppDialog } from "./edit-app-dialog";
import { UserSession } from "@/shared/model/sim-session.model";
import { UserGroupUtils } from "@/shared/utils/role.utils";
import PodStatusIndicator from "@/components/custom/pod-status-indicator";
export default function AppTable({
@@ -41,6 +42,7 @@ export default function AppTable({
['cpuReservation', 'CPU Reservation', false],
["createdAt", "Created At", true, (item) => formatDateTime(item.createdAt)],
["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)],
['status', 'Status', true, (item) => <PodStatusIndicator appId={item.id} />],
]}
data={app}
onItemClickLink={(item) => `/project/app/${item.id}`}

View File

@@ -0,0 +1,97 @@
'use client'
import { usePodsStatus } from '@/frontend/states/zustand.states';
import { cn } from '@/frontend/utils/utils';
import { Spinner } from "@/components/ui/spinner"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface PodStatusIndicatorProps {
appId: string;
showLabel?: boolean;
}
export default function PodStatusIndicator({ appId, showLabel }: PodStatusIndicatorProps) {
const { getPodsForApp, isLoading } = usePodsStatus();
const appPods = getPodsForApp(appId);
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Spinner className="size-3" />
</div>
);
}
if (!appPods) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 w-fit">
<div className="w-3 h-3 rounded-full bg-red-400" />
{showLabel && <span className="text-xs text-gray-500">Unknown</span>}
</div>
</TooltipTrigger>
<TooltipContent>
<p>Could not retrieve deployment status</p>
</TooltipContent>
</Tooltip>
);
}
let statusColor = 'bg-gray-400';
const runningPods = appPods.readyReplicas ?? 0;
const expected = appPods.replicas ?? 0;
let statusLabel = `${runningPods}/${expected}`;
let tooltipText = `${runningPods} of ${expected} Pods running`;
if (appPods.deploymentStatus === 'SHUTDOWN') {
statusColor = 'bg-gray-400';
statusLabel = 'Off';
tooltipText = 'App is shut down';
}
if (appPods.deploymentStatus === 'DEPLOYING' || appPods.deploymentStatus === 'SHUTTING_DOWN') {
statusColor = 'bg-orange-500';
}
if (appPods.deploymentStatus === 'DEPLOYED') {
statusColor = 'bg-green-500';
statusLabel = 'Ok';
}
if (appPods.deploymentStatus === 'ERROR') {
statusColor = 'bg-red-500';
statusLabel = 'Fehler';
tooltipText = 'Error during deployment';
}
if (appPods.deploymentStatus === 'BUILDING') {
statusColor = 'bg-blue-500';
statusLabel = 'Build';
tooltipText = 'App is building';
}
if (appPods.deploymentStatus === 'UNKNOWN') {
statusColor = 'bg-gray-400';
statusLabel = 'Unknown';
tooltipText = 'Unknown deployment status';
}
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 w-fit">
<div className={cn("w-3 h-3 rounded-full", statusColor)} />
{showLabel && <span className="text-xs text-gray-700">{statusLabel}</span>}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,16 @@
import { cn } from "@/frontend/utils/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@@ -0,0 +1,21 @@
'use client'
import { useEffect } from 'react';
import { podsStatusPollingService } from '@/frontend/services/pods-status-polling.service';
/**
* Client component that initializes and manages the pods status polling service.
* This component should be mounted in the root layout to ensure polling is active
* across all pages of the application.
*/
export default function PodsStatusPollingProvider() {
useEffect(() => {
podsStatusPollingService.start();
return () => {
podsStatusPollingService.stop();
};
}, []);
return null;
}

View File

@@ -0,0 +1,75 @@
import { getAllPodsStatus } from '@/app/api/deployment-status/actions';
import { usePodsStatus } from '../states/zustand.states';
/**
* Singleton service that manages polling for all pods status.
* This service runs in the browser and updates the Zustand store with fresh data.
*/
class PodsStatusPollingService {
private static instance: PodsStatusPollingService;
private intervalId: NodeJS.Timeout | null = null;
private isPolling = false;
private readonly POLL_INTERVAL_MS = 20000;
private constructor() { }
public static getInstance(): PodsStatusPollingService {
if (!PodsStatusPollingService.instance) {
PodsStatusPollingService.instance = new PodsStatusPollingService();
}
return PodsStatusPollingService.instance;
}
public start(): void {
if (this.isPolling) {
console.log('[PodsStatusPolling] Already polling, skipping start');
return;
}
console.log('[PodsStatusPolling] Starting pod status polling');
this.isPolling = true;
// Fetch immediately on start
this.fetchPodsStatus();
this.intervalId = setInterval(() => {
this.fetchPodsStatus();
}, this.POLL_INTERVAL_MS);
}
public stop(): void {
if (this.intervalId) {
console.log('[PodsStatusPolling] Stopping pod status polling');
clearInterval(this.intervalId);
this.intervalId = null;
this.isPolling = false;
}
}
private async fetchPodsStatus(): Promise<void> {
try {
const { setPodsStatus } = usePodsStatus.getState();
const response = await getAllPodsStatus();
if (response.status === 'success' && response.data) {
console.log('Polles status', response.data)
setPodsStatus(response.data);
} else {
console.error('[PodsStatusPolling] Failed to fetch pods status:', response.message);
}
} catch (error) {
console.error('[PodsStatusPolling] Error fetching pods status:', error);
}
}
public async refresh(): Promise<void> {
await this.fetchPodsStatus();
}
public isActive(): boolean {
return this.isPolling;
}
}
export const podsStatusPollingService = PodsStatusPollingService.getInstance();

View File

@@ -1,5 +1,6 @@
import dataAccess from "@/server/adapter/db.client";
import { create } from "zustand"
import { AppPodsStatusModel } from "@/app/api/deployment-status/actions";
interface ZustandConfirmDialogProps {
isDialogOpen: boolean;
@@ -96,3 +97,32 @@ export const useInputDialog = create<ZustandInputDialogProps>((set) => ({
return { isDialogOpen: false, userInfo: null, resolvePromise: null };
}),
}));
/* Pod Status Store */
interface ZustandPodsStatusProps {
podsStatus: Map<string, AppPodsStatusModel>;
lastUpdate: Date | null;
isLoading: boolean;
setPodsStatus: (data: AppPodsStatusModel[]) => void;
setLoading: (loading: boolean) => void;
getPodsForApp: (appId: string) => AppPodsStatusModel | undefined;
}
export const usePodsStatus = create<ZustandPodsStatusProps>((set, get) => ({
podsStatus: new Map(),
lastUpdate: null,
isLoading: true,
setPodsStatus: (data) => {
set({
podsStatus: new Map(data.map(app => [app.appId, app])),
lastUpdate: new Date(),
isLoading: false,
});
},
setLoading: (loading) => {
set({ isLoading: loading });
},
getPodsForApp: (appId) => {
return get().podsStatus.get(appId);
},
}));

View File

@@ -1,7 +1,7 @@
class LonghornApiAdapter {
get longhornBaseUrl() {
return process.env.NODE_ENV === 'production' ? 'http://longhorn-frontend.longhorn-system.svc.cluster.local' : 'http://localhost:4000';
return process.env.NODE_ENV === 'production' ? 'http://longhorn-frontend.longhorn-system.svc.cluster.local' : 'http://localhost:8000';
}
async getLonghornVolume(pvcName: String) {

View File

@@ -29,6 +29,11 @@ class DeploymentService {
}
}
async getAllDeployments() {
const allDeployments = await k3s.apps.listDeploymentForAllNamespaces();
return allDeployments.body.items;
}
async applyDeployment(namespace: string, appName: string, body: V1Deployment) {
const existingDeployment = await this.getDeployment(namespace, appName);
if (existingDeployment) {
@@ -283,7 +288,7 @@ class DeploymentService {
return ListUtils.sortByDate(revisions, (i) => i.createdAt!, true);
}
private mapReplicasetToStatus(deployment: V1Deployment | V1ReplicaSet): DeplyomentStatus {
mapReplicasetToStatus(deployment: V1Deployment | V1ReplicaSet): DeplyomentStatus {
/*
Fields for Status:
availableReplicas: 1,