feat: add event service and models for app event tracking

This commit is contained in:
biersoeckli
2024-12-23 13:23:25 +00:00
parent 039d28b612
commit 3ee7fe138e
8 changed files with 194 additions and 5 deletions

View File

@@ -4,6 +4,7 @@ import { SuccessActionResult } from "@/shared/model/server-action-error-return.m
import appService from "@/server/services/app.service";
import deploymentService from "@/server/services/deployment.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import eventService from "@/server/services/event.service";
export const deploy = async (appId: string, forceBuild = false) =>
@@ -28,3 +29,10 @@ export const startApp = async (appId: string) =>
await deploymentService.setReplicasForDeployment(app.projectId, app.id, app.replicas);
return new SuccessActionResult(undefined, 'Successfully started app.');
});
export const getLatestAppEvents = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();
const app = await appService.getById(appId);
return await eventService.getEventsForApp(app.projectId, app.id);
});

View File

@@ -2,12 +2,13 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { deploy, startApp, stopApp } from "./action";
import { deploy, startApp, stopApp } from "./actions";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { Toast } from "@/frontend/utils/toast.utils";
import AppStatus from "./app-status";
import { Hammer, Pause, Play, Rocket } from "lucide-react";
import { toast } from "sonner";
import { AppEventsDialog } from "./app-events-dialog";
export default function AppActionButtons({
app
@@ -16,7 +17,7 @@ export default function AppActionButtons({
}) {
return <Card>
<CardContent className="p-4 flex gap-4">
<div className="self-center"><AppStatus appId={app.id} /></div>
<div className="self-center"><AppEventsDialog app={app}><AppStatus appId={app.id} /></AppEventsDialog></div>
<Button onClick={() => Toast.fromAction(() => deploy(app.id))}><Rocket /> Deploy</Button>
<Button onClick={() => Toast.fromAction(() => deploy(app.id, true))} variant="secondary"><Hammer /> Rebuild</Button>
<Button onClick={() => Toast.fromAction(() => startApp(app.id))} variant="secondary"><Play />Start</Button>

View File

@@ -0,0 +1,100 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import React, { useEffect } from "react";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { getLatestAppEvents } from "./actions";
import { toast } from "sonner";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EventInfoModel } from "@/shared/model/event-info.model";
import { formatDateTime } from "@/frontend/utils/format.utils";
import { ScrollArea } from "@radix-ui/react-scroll-area";
import { cn } from "@/frontend/utils/utils";
export function AppEventsDialog({
app,
children
}: {
app: AppExtendedModel;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = React.useState(false);
const [events, setEvents] = React.useState<EventInfoModel[] | undefined>(undefined);
const loadEvents = async () => {
try {
const eventsResponse = await getLatestAppEvents(app.id);
if (eventsResponse.status === 'success') {
setEvents(eventsResponse.data);
} else {
toast.error(eventsResponse.message);
}
} catch (error) {
console.error(error);
toast.error('An error occured while loading events.');
}
}
useEffect(() => {
if (isOpen) {
loadEvents();
} else {
setEvents(undefined);
}
}, [isOpen]);
return (<>
<div onClick={() => setIsOpen(true)} className="cursor-pointer"> {children}</div>
<Dialog open={isOpen} onOpenChange={(isO) => {
setIsOpen(isO);
}}>
<DialogContent className="sm:max-w-[1000px]">
<DialogHeader>
<DialogTitle>App Events</DialogTitle>
<DialogDescription>
App events occur when changes are made to the deployment. For example, when a deployment is created, updated, or restarted.
Advanced users can read these events to understand what is happening in the background. Events are only available for a short period of time.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{!events && <FullLoadingSpinner />}
{events && <>
<Table>
<ScrollArea className="max-h-[70vh]">
<TableCaption>{events.length} recent Events</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Type</TableHead>
<TableHead>Action</TableHead>
<TableHead>Note</TableHead>
<TableHead>Pod Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event, index) => (
<TableRow key={(event.eventTime + '') || index}>
<TableCell>{formatDateTime(event.eventTime, true)}</TableCell>
<TableCell >{event.action}</TableCell>
<TableCell className={cn("font-medium", event.type !== 'Normal' ? 'text-orange-500' : '')}>
{event.reason}
</TableCell>
<TableCell >{event.note}</TableCell>
<TableCell >{event.podName}</TableCell>
</TableRow>
))}
</TableBody>
</ScrollArea>
</Table>
</>}
</div>
</DialogContent>
</Dialog>
</>)
}

View File

@@ -8,10 +8,13 @@ export function formatDate(date: Date | undefined | null): string {
return formatInTimeZone(date, 'Europe/Zurich', 'dd.MM.yyyy');
}
export function formatDateTime(date: Date | undefined | null): string {
export function formatDateTime(date: Date | undefined | null, includeSeconds = false): string {
if (!date) {
return '';
}
if (includeSeconds) {
return formatInTimeZone(date, 'Europe/Zurich', 'dd.MM.yyyy HH:mm:ss');
}
return formatInTimeZone(date, 'Europe/Zurich', 'dd.MM.yyyy HH:mm');
}

View File

@@ -0,0 +1,66 @@
import { CoreV1Event } from "@kubernetes/client-node";
import k3s from "../adapter/kubernetes-api.adapter";
import { EventInfoModel } from "@/shared/model/event-info.model";
import podService from "./pod.service";
import { isDate } from "date-fns";
class EventService {
async getEventsForApp(projectId: string, appId: string): Promise<EventInfoModel[]> {
const pods = await podService.getPodsForApp(projectId, appId);
// Example Request using kubectl:
// /api/v1/namespaces/default/events?fieldSelector=involvedObject.uid%3D8ecf9894-cda6-4687-9598-9f06f6985e0d%2CinvolvedObject.name%3Dlonghorn-nfs-installation-8n9kv%2CinvolvedObject.namespace%3Ddefault&limit=500
// Selectors:
// fieldSelector=involvedObject.namespace={projectId},
// involvedObject.uid={kubernetesUid},
// involvedObject.name={appId}
// Docs: https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/
const returnVal: EventInfoModel[] = [];
for (let podInfo of pods) {
console.log(podInfo.uid)
const result = await k3s.core.listNamespacedEvent(projectId,
undefined,
undefined,
undefined,
`involvedObject.namespace=${projectId},involvedObject.uid=${podInfo.uid},involvedObject.name=${podInfo.podName}`,
undefined,
50);
const events: CoreV1Event[] = result.body.items;
const eventsForPod = events.map(event => {
return {
podName: podInfo.podName,
action: event.action,
eventTime: event.eventTime ?? event.lastTimestamp,
note: event.message,
reason: event.reason,
type: event.type,
} as EventInfoModel;
});
returnVal.push(...eventsForPod);
}
returnVal.sort((a, b) => {
if (!isDate(b.eventTime)) {
b.eventTime = new Date(b.eventTime);
}
if (!isDate(a.eventTime)) {
a.eventTime = new Date(a.eventTime);
}
if (a.eventTime && b.eventTime) {
return b.eventTime.getTime() - a.eventTime.getTime();
}
return 0;
});
return returnVal;
}
}
const eventService = new EventService();
export default eventService;

View File

@@ -29,11 +29,13 @@ class SetupPodService {
async getPodsForApp(projectId: string, appId: string): Promise<{
podName: string;
containerName: string;
uid?: string;
}[]> {
const res = await k3s.core.listNamespacedPod(projectId, undefined, undefined, undefined, undefined, `app=${appId}`);
return res.body.items.map((item) => ({
podName: item.metadata?.name!,
containerName: item.spec?.containers?.[0].name!
containerName: item.spec?.containers?.[0].name!,
uid: item.metadata?.uid,
})).filter((item) => !!item.podName && !!item.containerName);
}
}

View File

@@ -0,0 +1,8 @@
export interface EventInfoModel {
podName: string,
action: string,
eventTime: Date,
note: string,
reason: string,
type: string
}

View File

@@ -2,7 +2,8 @@ import { z } from "zod";
export const podsInfoZodModel = z.object({
podName: z.string(),
containerName: z.string()
containerName: z.string(),
uid: z.string().optional(),
});
export type PodsInfoModel = z.infer<typeof podsInfoZodModel>;