mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-10 21:49:19 -06:00
feat: add event service and models for app event tracking
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
100
src/app/project/app/[appId]/app-events-dialog.tsx
Normal file
100
src/app/project/app/[appId]/app-events-dialog.tsx
Normal 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>
|
||||
</>)
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
66
src/server/services/event.service.ts
Normal file
66
src/server/services/event.service.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
8
src/shared/model/event-info.model.ts
Normal file
8
src/shared/model/event-info.model.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface EventInfoModel {
|
||||
podName: string,
|
||||
action: string,
|
||||
eventTime: Date,
|
||||
note: string,
|
||||
reason: string,
|
||||
type: string
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user