mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-09 21:19:07 -06:00
feat: introduce pod status indicators and global polling service for app monitoring
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
52
src/app/api/deployment-status/actions.ts
Normal file
52
src/app/api/deployment-status/actions.ts
Normal 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[]>>;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
97
src/components/custom/pod-status-indicator.tsx
Normal file
97
src/components/custom/pod-status-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/components/ui/spinner.tsx
Normal file
16
src/components/ui/spinner.tsx
Normal 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 }
|
||||
21
src/frontend/components/pods-status-polling-provider.tsx
Normal file
21
src/frontend/components/pods-status-polling-provider.tsx
Normal 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;
|
||||
}
|
||||
75
src/frontend/services/pods-status-polling.service.ts
Normal file
75
src/frontend/services/pods-status-polling.service.ts
Normal 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();
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user