feat: category export and API cleanup.

This commit is contained in:
mattia
2025-01-06 20:45:51 +01:00
parent 50ecb39cce
commit 99859177b3
16 changed files with 566 additions and 248 deletions

View File

@@ -1,22 +1,39 @@
import { useParams } from "react-router";
import { client } from "./client";
import {
Autocomplete,
Button,
Card,
CardActions,
CardContent,
CircularProgress,
Collapse,
IconButton,
List,
ListItem,
ListItemText,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useMemo } from "react";
import {
Fragment,
useCallback,
useMemo,
useState,
} from "react";
import {
createMRTColumnHelper,
MaterialReactTable,
MRT_ColumnDef,
useMaterialReactTable,
} from "material-react-table";
import { GetSessionResponse } from "./contract";
import { format } from "date-fns";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import { useQueryClient } from "@tanstack/react-query";
type LogEntry = GetSessionResponse["log_entries"][0];
const columnHelper = createMRTColumnHelper<LogEntry>(); // <--- pass your TData type as a generic to createMRTColumnHelper (if using TS)
export function SessionDetails() {
const { sessionId } = useParams<{ sessionId: string }>();
@@ -28,42 +45,49 @@ export function SessionDetails() {
params: { id: sessionId },
});
const columns = useMemo<
MRT_ColumnDef<GetSessionResponse["log_entries"][0]>[]
>(
const columns = useMemo(
() => [
{
accessorKey: "message",
columnHelper.accessor("category", {
header: "Category",
Cell: ({ cell }) => <Typography>{cell.getValue()}</Typography>,
}),
columnHelper.accessor("message", {
header: "Message",
},
{
accessorKey: "timestamp",
Cell: ({ cell }) => <Typography>{cell.getValue()}</Typography>,
}),
columnHelper.accessor("timestamp", {
header: "Timestamp",
Cell: (x) => <span>{format(x.row.original.timestamp, "Pp")}</span>,
},
{
accessorKey: "metadata",
Cell: ({ cell }) => (
<Typography>{format(cell.getValue(), "Pp")}</Typography>
),
}),
columnHelper.accessor("metadata", {
header: "Metadata",
Cell: (x) => {
const metadata = x.row.original.metadata;
Cell: ({ cell }) => {
const metadata = cell.getValue();
return (
<List dense>
{Object.keys(metadata)
.sort()
.map((key) => (
<ListItem key={key}>
<ListItemText>{key}: {metadata[key]}</ListItemText>
<ListItemText>
<Typography>
{key}: {metadata[key]}
</Typography>
</ListItemText>
</ListItem>
))}
</List>
);
},
},
}),
],
[]
);
const table = useMaterialReactTable({
layoutMode: "grid",
columns,
data:
getSessionQuery.status === "success"
@@ -71,6 +95,62 @@ export function SessionDetails() {
: [],
});
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const toggleExportDialog = useCallback(
() => setIsExportDialogOpen((x) => !x),
[]
);
const getCategoriesQuery = client.getCategories.useQuery(
["get-categories", sessionId],
{
query: {
sessionId,
},
},
{
enabled: isExportDialogOpen,
}
);
const [selectedCategory, setSelectedCategory] = useState("");
const handleSelectedCategoryChange = useCallback(
(_: unknown, newValue: string | null) => {
setSelectedCategory(newValue || "");
},
[]
);
const queryClient = useQueryClient();
const onExport = useCallback(() => {
client.getEntries
.fetchQuery(
queryClient,
["get-entries", sessionId, selectedCategory],
{
params: { sessionId },
query: { category: selectedCategory },
},
{
meta: { causeDownload: true },
}
)
.then((data) => {
const json = JSON.stringify(data.body, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.setAttribute("href", url);
a.setAttribute(
"download",
`entries-${sessionId}-${selectedCategory}.json`
);
a.click();
})
.catch(console.error);
}, [queryClient, sessionId, selectedCategory]);
return getSessionQuery.status === "error" ? (
<p>Error</p>
) : (
@@ -93,6 +173,52 @@ export function SessionDetails() {
: getSessionQuery.data.body.version}
</Typography>
</CardContent>
<CardActions disableSpacing>
<IconButton aria-label="export category" onClick={toggleExportDialog}>
<FileDownloadIcon />
</IconButton>
</CardActions>
<Collapse in={isExportDialogOpen} timeout="auto" unmountOnExit>
<CardContent>
<Stack spacing={2}>
<Typography variant="h6" component="div">
Export
</Typography>
<Autocomplete
options={
getCategoriesQuery.status === "success"
? getCategoriesQuery.data.body
: []
}
value={selectedCategory}
onChange={handleSelectedCategoryChange}
loading={getCategoriesQuery.isLoading}
renderInput={(params) => (
<TextField
{...params}
label="Category"
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<Fragment>
{getCategoriesQuery.isLoading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</Fragment>
),
},
}}
/>
)}
/>
<Button disabled={!selectedCategory} onClick={onExport}>
Export
</Button>
</Stack>
</CardContent>
</Collapse>
</Card>
{getSessionQuery.status === "success" && (
<MaterialReactTable table={table} />

View File

@@ -7,7 +7,7 @@ import {
type MRT_ColumnDef,
type MRT_PaginationState,
} from "material-react-table";
import { GetSessionsResponse } from "../common/contract";
import { GetSessionsResponse } from "./contract";
import { format, formatDuration, interval, intervalToDuration } from "date-fns";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";

View File

@@ -1,227 +0,0 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { extendZodWithOpenApi } from "@anatine/zod-openapi";
import { OpenAPIObject } from "openapi3-ts/oas31";
////// INITIALIZATION
extendZodWithOpenApi(z);
const c = initContract();
const ErrorSchema = z.object({
code: z.number().optional().openapi({
title: "Error code",
description: "Optional code to uniquely identify this error",
}),
message: z.string().openapi({
title: "Error message",
description: "Human readable message to describe the error",
}),
});
////// POST LOG
const SingleMetadataSchema = z.object({
key: z.string().openapi({
title: "Key",
description: "Name of this metadata property",
example: "attack-power",
}),
value: z.string().openapi({
title: "Value",
description: "Value of the metadata property",
example: "923",
}),
});
export type SingleMetadata = z.infer<typeof SingleMetadataSchema>;
const PostLogRequestParamsSchema = z.object({
gameName: z.string().openapi({
title: "Game name",
description: "Name of the game that generated this log",
example: "Asteroid",
}),
version: z.string().openapi({
title: "Version",
description: "Version of the game that generated this log",
example: "1.7.0",
}),
saveGuid: z.string().uuid().openapi({
title: "Save GUID",
description:
"A unique identifier for a whole game, which can be part of a multi-session game",
example: "11111111-2222-3333-4444-555555555555",
}),
sessionGuid: z.string().uuid().openapi({
title: "Session GUID",
description: "A unique identifier for the single play session",
example: "11111111-2222-3333-4444-555555555555",
}),
});
const PostLogRequestBodySchema = z.object({
message: z.string().openapi({
title: "Message",
description: "A human readable message that describes the log line",
example: "Clicked on the telephone",
}),
metadata: SingleMetadataSchema.array().openapi({
title: "Metadata",
description: "Extra data saved with the log message",
}),
});
////// GET SESSIONS
const GetSessionsResponseSchema = z.object({
total: z.number().positive().openapi({
title: "Total logs",
description:
"The total number of logs satisfying the filters, regardless of pagination",
}),
sessions: z
.object({
id: z.string().openapi({
title: "Session GUID",
description: "A unique identifier for the single play session",
example: "11111111-2222-3333-4444-555555555555",
}),
save_id: z.string().openapi({
title: "Save GUID",
description:
"A unique identifier for a whole game, which can be part of a multi-session game",
example: "11111111-2222-3333-4444-555555555555",
}),
game_name: z.string().openapi({
title: "Game name",
description: "Name of the game that generated this log",
example: "Asteroid",
}),
version: z.string().openapi({
title: "Version",
description: "Version of the game that generated this log",
example: "1.7.0",
}),
start_time: z.date().openapi({
title: "Start Time",
description: "Time of the first log event for this session",
example: "2025-01-06T14:46:38.000Z",
}),
end_time: z.date().openapi({
title: "Start Time",
description: "Time of the last log event for this session",
example: "2025-01-06T14:46:38.000Z",
}),
num_events: z.number().positive().openapi({
title: "# Events",
description: "Number of events in this session",
example: "48",
}),
})
.array(),
});
export type GetSessionsResponse = z.infer<typeof GetSessionsResponseSchema>;
const GetSessionsRequestQuerySchema = z.object({
skip: z.coerce.number().optional().openapi({
title: "Skip",
default:
"How many log events to skip from the beginning of this session (used for pagination)",
example: "30",
}),
count: z.coerce.number().optional().openapi({
title: "Count",
default: "How many log events to return at most (used for pagination)",
example: "10",
}),
id: z.string().optional().openapi({
title: "Session GUID",
description: "A filter for the session GUID",
example: "11111111-2222-3333-4444-555555555555",
}),
});
////// GET SESSION
const GetSessionResponseSchema = z.object({
game_name: z.string().openapi({
title: "Game name",
description: "Name of the game that generated this log",
example: "Asteroid",
}),
version: z.string().openapi({
title: "Version",
description: "Version of the game that generated this log",
example: "1.7.0",
}),
log_entries: z
.object({
message: z.string().openapi({
title: "Message",
description: "A human readable message that describes the log line",
example: "Clicked on the telephone",
}),
timestamp: z.date().openapi({
title: "Timestamp",
description: "Date/time when the event occoured",
example: "2025-01-06T14:46:38.000Z",
}),
metadata: z.record(z.string(), z.string()).openapi({
title: "Metadata",
description: "Extra data saved with the log message",
}),
})
.array(),
});
export type GetSessionResponse = z.infer<typeof GetSessionResponseSchema>;
////// CONTRACT
export const contract = c.router(
{
postLog: {
method: "POST",
path: "/logs/:gameName/:version/:saveGuid/:sessionGuid",
body: PostLogRequestBodySchema,
pathParams: PostLogRequestParamsSchema,
responses: {
204: c.noBody(),
422: ErrorSchema,
500: ErrorSchema,
},
summary: "post a new log",
},
getSessions: {
method: "GET",
path: "/get-sessions",
responses: {
200: GetSessionsResponseSchema,
},
query: GetSessionsRequestQuerySchema,
summary: "get all the sessions",
},
getSession: {
method: "GET",
path: "/get-session/:id",
responses: {
200: GetSessionResponseSchema,
},
summary: "get a session by id",
},
getOpenApi: {
method: "GET",
path: "/get-openapi",
responses: {
200: c.type<OpenAPIObject>(),
},
summary: "get the OpenAPI document for this API"
},
},
{
pathPrefix: "/api",
}
);

View File

@@ -0,0 +1,20 @@
import { Z } from "./types";
export function exportGetCategoriesSchema(z: Z) {
const GetCategoriesRequestQuerySchema = z.object({
sessionId: z.string().uuid().openapi({
title: "Session GUID",
description:
"The GUID of the session where the log entries are retrieved from",
example: "11111111-2222-3333-4444-555555555555",
}),
});
const GetCategoriesResponseSchema = z.string().array().openapi({
title: "Categories",
description: "The categories present for this session",
example: "UI.save-load",
});
return { GetCategoriesRequestQuerySchema, GetCategoriesResponseSchema };
}

View File

@@ -0,0 +1,27 @@
import { exportGetSessionSchema } from "./get-session";
import { Z } from "./types";
export function exportGetEntriesSchema(z: Z) {
const { LogEntrySchema } = exportGetSessionSchema(z);
const GetEntriesRequestPathSchema = z.object({
sessionId: z.string().uuid().openapi({
title: "Session GUID",
description:
"The GUID of the session where the log entries are retrieved from",
example: "11111111-2222-3333-4444-555555555555",
}),
});
const GetEntriesRequestQuerySchema = z.object({
category: z.string().optional().openapi({
title: "Category",
description: "Optional category filter",
example: "UI.save-load",
}),
});
const GetEntriesResponseSchema = LogEntrySchema.array();
return { GetEntriesRequestPathSchema, GetEntriesRequestQuerySchema, GetEntriesResponseSchema };
}

View File

@@ -0,0 +1,51 @@
import { Z } from "./types";
export function exportGetSessionSchema(z: Z) {
const LogEntrySchema = z
.object({
message: z.string().openapi({
title: "Message",
description: "A human readable message that describes the log line",
example: "Clicked on the telephone",
}),
timestamp: z.date().openapi({
title: "Timestamp",
description: "Date/time when the event occoured",
example: "2025-01-06T14:46:38.000Z",
}),
metadata: z.record(z.string(), z.string()).openapi({
title: "Metadata",
description: "Extra data saved with the log message",
}),
category: z.string().optional().openapi({
title: "Category",
description:
"An optional category to identify the family of the message",
example: "UI.save-load.loading",
}),
});
const GetSessionResponseSchema = z.object({
game_name: z.string().openapi({
title: "Game name",
description: "Name of the game that generated this log",
example: "Asteroid",
}),
version: z.string().openapi({
title: "Version",
description: "Version of the game that generated this log",
example: "1.7.0",
}),
log_entries: LogEntrySchema.array(),
});
const GetSessionRequestPathSchema = z.object({
id: z.string().uuid().openapi({
title: "Session GUID",
description: "GUID of the session to retrieve",
example: "11111111-2222-3333-4444-555555555555",
}),
});
return { GetSessionResponseSchema, GetSessionRequestPathSchema, LogEntrySchema };
}

View File

@@ -0,0 +1,72 @@
import { Z } from "./types";
export function exportGetSessionsSchema(z: Z) {
const GetSessionsResponseSchema = z.object({
total: z.number().positive().openapi({
title: "Total logs",
description:
"The total number of logs satisfying the filters, regardless of pagination",
}),
sessions: z
.object({
id: z.string().openapi({
title: "Session GUID",
description: "A unique identifier for the single play session",
example: "11111111-2222-3333-4444-555555555555",
}),
save_id: z.string().openapi({
title: "Save GUID",
description:
"A unique identifier for a whole game, which can be part of a multi-session game",
example: "11111111-2222-3333-4444-555555555555",
}),
game_name: z.string().openapi({
title: "Game name",
description: "Name of the game that generated this log",
example: "Asteroid",
}),
version: z.string().openapi({
title: "Version",
description: "Version of the game that generated this log",
example: "1.7.0",
}),
start_time: z.date().openapi({
title: "Start Time",
description: "Time of the first log event for this session",
example: "2025-01-06T14:46:38.000Z",
}),
end_time: z.date().openapi({
title: "Start Time",
description: "Time of the last log event for this session",
example: "2025-01-06T14:46:38.000Z",
}),
num_events: z.number().positive().openapi({
title: "# Events",
description: "Number of events in this session",
example: "48",
}),
})
.array(),
});
const GetSessionsRequestQuerySchema = z.object({
skip: z.coerce.number().optional().openapi({
title: "Skip",
default:
"How many log events to skip from the beginning of this session (used for pagination)",
example: "30",
}),
count: z.coerce.number().optional().openapi({
title: "Count",
default: "How many log events to return at most (used for pagination)",
example: "10",
}),
id: z.string().optional().openapi({
title: "Session GUID",
description: "A filter for the session GUID",
example: "11111111-2222-3333-4444-555555555555",
}),
});
return { GetSessionsResponseSchema, GetSessionsRequestQuerySchema };
}

View File

@@ -0,0 +1,114 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { extendZodWithOpenApi } from "@anatine/zod-openapi";
import { OpenAPIObject } from "openapi3-ts/oas31";
import { exportGetSessionSchema } from "./get-session";
import { exportGetSessionsSchema } from "./get-sessions";
import { exportPostLogSchema } from "./post-log";
import { exportGetEntriesSchema } from "./get-entries";
import { exportGetCategoriesSchema } from "./get-categories";
extendZodWithOpenApi(z);
const { GetSessionResponseSchema, GetSessionRequestPathSchema } =
exportGetSessionSchema(z);
const { GetSessionsRequestQuerySchema, GetSessionsResponseSchema } =
exportGetSessionsSchema(z);
const {
PostLogRequestBodySchema,
PostLogRequestParamsSchema,
SingleMetadataSchema,
} = exportPostLogSchema(z);
const {
GetEntriesRequestPathSchema,
GetEntriesResponseSchema,
GetEntriesRequestQuerySchema,
} = exportGetEntriesSchema(z);
const { GetCategoriesRequestQuerySchema, GetCategoriesResponseSchema } =
exportGetCategoriesSchema(z);
const ErrorSchema = z.object({
code: z.number().optional().openapi({
title: "Error code",
description: "Optional code to uniquely identify this error",
}),
message: z.string().openapi({
title: "Error message",
description: "Human readable message to describe the error",
}),
});
////// CONTRACT
const c = initContract();
export const contract = c.router(
{
postLog: {
method: "POST",
path: "/logs/:gameName/:version/:saveGuid/:sessionGuid",
body: PostLogRequestBodySchema,
pathParams: PostLogRequestParamsSchema,
responses: {
204: c.noBody(),
422: ErrorSchema,
500: ErrorSchema,
},
summary: "post a new log",
},
getSessions: {
method: "GET",
path: "/get-sessions",
responses: {
200: GetSessionsResponseSchema,
},
query: GetSessionsRequestQuerySchema,
summary: "get all the sessions",
},
getSession: {
method: "GET",
path: "/get-session/:id",
pathParams: GetSessionRequestPathSchema,
responses: {
200: GetSessionResponseSchema,
},
summary: "get a session by id",
},
getEntries: {
method: "GET",
path: "/sessions/:sessionId/entries",
pathParams: GetEntriesRequestPathSchema,
query: GetEntriesRequestQuerySchema,
responses: {
200: GetEntriesResponseSchema,
},
summary: "get the entries of a session",
},
getCategories: {
method: "GET",
path: "/categories",
query: GetCategoriesRequestQuerySchema,
responses: {
200: GetCategoriesResponseSchema,
},
summary: "Get the log categories present for this session",
},
getOpenApi: {
method: "GET",
path: "/get-openapi",
responses: {
200: c.type<OpenAPIObject>(),
},
summary: "get the OpenAPI document for this API",
},
},
{
pathPrefix: "/api",
}
);
///// TYPES
export type SingleMetadata = z.infer<typeof SingleMetadataSchema>;
export type GetSessionsResponse = z.infer<typeof GetSessionsResponseSchema>;
export type GetSessionResponse = z.infer<typeof GetSessionResponseSchema>;

View File

@@ -0,0 +1,63 @@
import { Z } from "./types";
export function exportPostLogSchema(z: Z) {
const SingleMetadataSchema = z.object({
key: z.string().openapi({
title: "Key",
description: "Name of this metadata property",
example: "attack-power",
}),
value: z.string().openapi({
title: "Value",
description: "Value of the metadata property",
example: "923",
}),
});
const PostLogRequestParamsSchema = z.object({
gameName: z.string().openapi({
title: "Game name",
description: "Name of the game that generated this log",
example: "Asteroid",
}),
version: z.string().openapi({
title: "Version",
description: "Version of the game that generated this log",
example: "1.7.0",
}),
saveGuid: z.string().uuid().openapi({
title: "Save GUID",
description:
"A unique identifier for a whole game, which can be part of a multi-session game",
example: "11111111-2222-3333-4444-555555555555",
}),
sessionGuid: z.string().uuid().openapi({
title: "Session GUID",
description: "A unique identifier for the single play session",
example: "11111111-2222-3333-4444-555555555555",
}),
});
const PostLogRequestBodySchema = z.object({
message: z.string().openapi({
title: "Message",
description: "A human readable message that describes the log line",
example: "Clicked on the telephone",
}),
metadata: SingleMetadataSchema.array().openapi({
title: "Metadata",
description: "Extra data saved with the log message",
}),
category: z.string().optional().openapi({
title: "Category",
description: "An optional category to identify the family of the message",
example: "UI.save-load.loading",
}),
});
return {
SingleMetadataSchema,
PostLogRequestParamsSchema,
PostLogRequestBodySchema,
};
}

View File

@@ -0,0 +1,3 @@
import { z } from "zod";
export type Z = typeof z;

View File

@@ -0,0 +1,12 @@
import { db } from "./init";
export async function getCategories(sessionId: string) {
const result = await db
.selectFrom("log_entries")
.select("category")
.where("log_entries.session_info_id", "=", sessionId)
.where("category", "is not", null)
.distinct()
.execute();
return result.map((entry) => entry.category!);
}

View File

@@ -0,0 +1,36 @@
import { db } from "./init";
export function getEntries(sessionId: string, category?: string) {
return db.transaction().execute(async (trx) => {
let query = trx
.selectFrom("log_entries")
.where("session_info_id", "=", sessionId)
.select(["id", "message", "timestamp", "category"])
.orderBy("timestamp", "asc");
if (category) {
query = query.where("category", "=", category);
}
const logEntries = await query.execute();
// get all the metadata for the log entries
const metadata = await trx
.selectFrom("log_metadata")
.where(
"log_entry_id",
"in",
logEntries.map((entry) => entry.id)
)
.select(["log_entry_id", "key", "value"])
.execute();
return logEntries.map((entry) => ({
message: entry.message,
timestamp: entry.timestamp,
category: entry.category,
metadata: metadata
.filter((m) => m.log_entry_id === entry.id)
.reduce((acc, m) => ({ ...acc, [m.key]: m.value }), {}),
}));
});
}

View File

@@ -13,7 +13,7 @@ export async function getSession(id: string): Promise<GetSessionResponse> {
const logEntries = await db
.selectFrom("log_entries")
.where("session_info_id", "=", id)
.select(["id", "message", "timestamp"])
.select(["id", "message", "timestamp", "category"])
.orderBy("timestamp", "asc")
.execute();
@@ -35,6 +35,7 @@ export async function getSession(id: string): Promise<GetSessionResponse> {
log_entries: logEntries.map((entry) => ({
message: entry.message,
timestamp: entry.timestamp,
category: entry.category,
metadata: metadata
.filter((m) => m.log_entry_id === entry.id)
.reduce((acc, m) => ({ ...acc, [m.key]: m.value }), {}),

View File

@@ -8,6 +8,7 @@ export async function postLog(
saveGuid: string,
sessionGuid: string,
message: string,
category: string | undefined,
metadata: SingleMetadata[]
) {
if (
@@ -38,6 +39,7 @@ export async function postLog(
session_info_id: sessionGuid,
message: message,
timestamp: new Date(),
category,
})
.executeTakeFirstOrThrow();
// add all the metadata

View File

@@ -12,6 +12,7 @@ export interface LogEntriesTable
session_info_id: string;
message: string;
timestamp: Date;
category?: string;
}
export type LogEntry = Selectable<LogEntriesTable>;

View File

@@ -1,9 +1,11 @@
import { createExpressEndpoints, initServer } from "@ts-rest/express";
import type { Express } from "express";
import { type Express } from "express";
import { contract } from "../common/contract";
import { HttpError } from "./http-error";
import { OpenAPIObject } from "openapi3-ts/oas31";
import { getSession, getSessions, postLog } from "./db";
import { getEntries } from "./db/get-entries";
import { getCategories } from "./db/get-categories";
export function installRouter(app: Express, openAPIObject: OpenAPIObject) {
const s = initServer();
@@ -16,6 +18,7 @@ export function installRouter(app: Express, openAPIObject: OpenAPIObject) {
params.saveGuid,
params.sessionGuid,
body.message,
body.category,
body.metadata
);
return {
@@ -53,6 +56,20 @@ export function installRouter(app: Express, openAPIObject: OpenAPIObject) {
body,
};
},
getEntries: async ({ params: { sessionId }, query: { category } }) => {
const body = await getEntries(sessionId, category);
return {
status: 200,
body,
};
},
getCategories: async ({ query: { sessionId } }) => {
const body = await getCategories(sessionId);
return {
status: 200,
body,
};
},
getOpenApi: async () => {
return {
status: 200,