Skip to content

Commit 3ee7fe1

Browse files
committed
feat: add event service and models for app event tracking
1 parent 039d28b commit 3ee7fe1

8 files changed

Lines changed: 194 additions & 5 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SuccessActionResult } from "@/shared/model/server-action-error-return.m
44
import appService from "@/server/services/app.service";
55
import deploymentService from "@/server/services/deployment.service";
66
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
7+
import eventService from "@/server/services/event.service";
78

89

910
export const deploy = async (appId: string, forceBuild = false) =>
@@ -28,3 +29,10 @@ export const startApp = async (appId: string) =>
2829
await deploymentService.setReplicasForDeployment(app.projectId, app.id, app.replicas);
2930
return new SuccessActionResult(undefined, 'Successfully started app.');
3031
});
32+
33+
export const getLatestAppEvents = async (appId: string) =>
34+
simpleAction(async () => {
35+
await getAuthUserSession();
36+
const app = await appService.getById(appId);
37+
return await eventService.getEventsForApp(app.projectId, app.id);
38+
});

src/app/project/app/[appId]/app-action-buttons.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import { Button } from "@/components/ui/button";
44
import { Card, CardContent } from "@/components/ui/card";
5-
import { deploy, startApp, stopApp } from "./action";
5+
import { deploy, startApp, stopApp } from "./actions";
66
import { AppExtendedModel } from "@/shared/model/app-extended.model";
77
import { Toast } from "@/frontend/utils/toast.utils";
88
import AppStatus from "./app-status";
99
import { Hammer, Pause, Play, Rocket } from "lucide-react";
1010
import { toast } from "sonner";
11+
import { AppEventsDialog } from "./app-events-dialog";
1112

1213
export default function AppActionButtons({
1314
app
@@ -16,7 +17,7 @@ export default function AppActionButtons({
1617
}) {
1718
return <Card>
1819
<CardContent className="p-4 flex gap-4">
19-
<div className="self-center"><AppStatus appId={app.id} /></div>
20+
<div className="self-center"><AppEventsDialog app={app}><AppStatus appId={app.id} /></AppEventsDialog></div>
2021
<Button onClick={() => Toast.fromAction(() => deploy(app.id))}><Rocket /> Deploy</Button>
2122
<Button onClick={() => Toast.fromAction(() => deploy(app.id, true))} variant="secondary"><Hammer /> Rebuild</Button>
2223
<Button onClick={() => Toast.fromAction(() => startApp(app.id))} variant="secondary"><Play />Start</Button>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {
2+
Dialog,
3+
DialogContent,
4+
DialogDescription,
5+
DialogHeader,
6+
DialogTitle,
7+
} from "@/components/ui/dialog"
8+
import React, { useEffect } from "react";
9+
import { AppExtendedModel } from "@/shared/model/app-extended.model";
10+
import { getLatestAppEvents } from "./actions";
11+
import { toast } from "sonner";
12+
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
13+
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
14+
import { EventInfoModel } from "@/shared/model/event-info.model";
15+
import { formatDateTime } from "@/frontend/utils/format.utils";
16+
import { ScrollArea } from "@radix-ui/react-scroll-area";
17+
import { cn } from "@/frontend/utils/utils";
18+
19+
export function AppEventsDialog({
20+
app,
21+
children
22+
}: {
23+
app: AppExtendedModel;
24+
children: React.ReactNode;
25+
}) {
26+
27+
const [isOpen, setIsOpen] = React.useState(false);
28+
const [events, setEvents] = React.useState<EventInfoModel[] | undefined>(undefined);
29+
30+
const loadEvents = async () => {
31+
try {
32+
const eventsResponse = await getLatestAppEvents(app.id);
33+
if (eventsResponse.status === 'success') {
34+
setEvents(eventsResponse.data);
35+
} else {
36+
toast.error(eventsResponse.message);
37+
}
38+
} catch (error) {
39+
console.error(error);
40+
toast.error('An error occured while loading events.');
41+
}
42+
}
43+
44+
useEffect(() => {
45+
if (isOpen) {
46+
loadEvents();
47+
} else {
48+
setEvents(undefined);
49+
}
50+
}, [isOpen]);
51+
52+
return (<>
53+
<div onClick={() => setIsOpen(true)} className="cursor-pointer"> {children}</div>
54+
<Dialog open={isOpen} onOpenChange={(isO) => {
55+
setIsOpen(isO);
56+
}}>
57+
<DialogContent className="sm:max-w-[1000px]">
58+
<DialogHeader>
59+
<DialogTitle>App Events</DialogTitle>
60+
<DialogDescription>
61+
App events occur when changes are made to the deployment. For example, when a deployment is created, updated, or restarted.
62+
Advanced users can read these events to understand what is happening in the background. Events are only available for a short period of time.
63+
</DialogDescription>
64+
</DialogHeader>
65+
<div className="space-y-4">
66+
{!events && <FullLoadingSpinner />}
67+
{events && <>
68+
<Table>
69+
<ScrollArea className="max-h-[70vh]">
70+
<TableCaption>{events.length} recent Events</TableCaption>
71+
<TableHeader>
72+
<TableRow>
73+
<TableHead>Time</TableHead>
74+
<TableHead>Type</TableHead>
75+
<TableHead>Action</TableHead>
76+
<TableHead>Note</TableHead>
77+
<TableHead>Pod Name</TableHead>
78+
</TableRow>
79+
</TableHeader>
80+
<TableBody>
81+
{events.map((event, index) => (
82+
<TableRow key={(event.eventTime + '') || index}>
83+
<TableCell>{formatDateTime(event.eventTime, true)}</TableCell>
84+
<TableCell >{event.action}</TableCell>
85+
<TableCell className={cn("font-medium", event.type !== 'Normal' ? 'text-orange-500' : '')}>
86+
{event.reason}
87+
</TableCell>
88+
<TableCell >{event.note}</TableCell>
89+
<TableCell >{event.podName}</TableCell>
90+
</TableRow>
91+
))}
92+
</TableBody>
93+
</ScrollArea>
94+
</Table>
95+
</>}
96+
</div>
97+
</DialogContent>
98+
</Dialog>
99+
</>)
100+
}

src/frontend/utils/format.utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ export function formatDate(date: Date | undefined | null): string {
88
return formatInTimeZone(date, 'Europe/Zurich', 'dd.MM.yyyy');
99
}
1010

11-
export function formatDateTime(date: Date | undefined | null): string {
11+
export function formatDateTime(date: Date | undefined | null, includeSeconds = false): string {
1212
if (!date) {
1313
return '';
1414
}
15+
if (includeSeconds) {
16+
return formatInTimeZone(date, 'Europe/Zurich', 'dd.MM.yyyy HH:mm:ss');
17+
}
1518
return formatInTimeZone(date, 'Europe/Zurich', 'dd.MM.yyyy HH:mm');
1619
}
1720

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { CoreV1Event } from "@kubernetes/client-node";
2+
import k3s from "../adapter/kubernetes-api.adapter";
3+
import { EventInfoModel } from "@/shared/model/event-info.model";
4+
import podService from "./pod.service";
5+
import { isDate } from "date-fns";
6+
7+
class EventService {
8+
9+
async getEventsForApp(projectId: string, appId: string): Promise<EventInfoModel[]> {
10+
11+
const pods = await podService.getPodsForApp(projectId, appId);
12+
13+
// Example Request using kubectl:
14+
// /api/v1/namespaces/default/events?fieldSelector=involvedObject.uid%3D8ecf9894-cda6-4687-9598-9f06f6985e0d%2CinvolvedObject.name%3Dlonghorn-nfs-installation-8n9kv%2CinvolvedObject.namespace%3Ddefault&limit=500
15+
16+
// Selectors:
17+
// fieldSelector=involvedObject.namespace={projectId},
18+
// involvedObject.uid={kubernetesUid},
19+
// involvedObject.name={appId}
20+
21+
// Docs: https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/
22+
23+
const returnVal: EventInfoModel[] = [];
24+
for (let podInfo of pods) {
25+
console.log(podInfo.uid)
26+
const result = await k3s.core.listNamespacedEvent(projectId,
27+
undefined,
28+
undefined,
29+
undefined,
30+
`involvedObject.namespace=${projectId},involvedObject.uid=${podInfo.uid},involvedObject.name=${podInfo.podName}`,
31+
undefined,
32+
50);
33+
34+
const events: CoreV1Event[] = result.body.items;
35+
36+
const eventsForPod = events.map(event => {
37+
return {
38+
podName: podInfo.podName,
39+
action: event.action,
40+
eventTime: event.eventTime ?? event.lastTimestamp,
41+
note: event.message,
42+
reason: event.reason,
43+
type: event.type,
44+
} as EventInfoModel;
45+
});
46+
returnVal.push(...eventsForPod);
47+
}
48+
49+
returnVal.sort((a, b) => {
50+
if (!isDate(b.eventTime)) {
51+
b.eventTime = new Date(b.eventTime);
52+
}
53+
if (!isDate(a.eventTime)) {
54+
a.eventTime = new Date(a.eventTime);
55+
}
56+
if (a.eventTime && b.eventTime) {
57+
return b.eventTime.getTime() - a.eventTime.getTime();
58+
}
59+
return 0;
60+
});
61+
return returnVal;
62+
}
63+
}
64+
65+
const eventService = new EventService();
66+
export default eventService;

src/server/services/setup-services/setup-pod.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ class SetupPodService {
2929
async getPodsForApp(projectId: string, appId: string): Promise<{
3030
podName: string;
3131
containerName: string;
32+
uid?: string;
3233
}[]> {
3334
const res = await k3s.core.listNamespacedPod(projectId, undefined, undefined, undefined, undefined, `app=${appId}`);
3435
return res.body.items.map((item) => ({
3536
podName: item.metadata?.name!,
36-
containerName: item.spec?.containers?.[0].name!
37+
containerName: item.spec?.containers?.[0].name!,
38+
uid: item.metadata?.uid,
3739
})).filter((item) => !!item.podName && !!item.containerName);
3840
}
3941
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export interface EventInfoModel {
2+
podName: string,
3+
action: string,
4+
eventTime: Date,
5+
note: string,
6+
reason: string,
7+
type: string
8+
}

src/shared/model/pods-info.model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { z } from "zod";
22

33
export const podsInfoZodModel = z.object({
44
podName: z.string(),
5-
containerName: z.string()
5+
containerName: z.string(),
6+
uid: z.string().optional(),
67
});
78

89
export type PodsInfoModel = z.infer<typeof podsInfoZodModel>;

0 commit comments

Comments
 (0)