feat: category export and API cleanup.
This commit is contained in:
@@ -1,22 +1,39 @@
|
|||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import { client } from "./client";
|
import { client } from "./client";
|
||||||
import {
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
CardActions,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Stack,
|
Stack,
|
||||||
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
createMRTColumnHelper,
|
||||||
MaterialReactTable,
|
MaterialReactTable,
|
||||||
MRT_ColumnDef,
|
|
||||||
useMaterialReactTable,
|
useMaterialReactTable,
|
||||||
} from "material-react-table";
|
} from "material-react-table";
|
||||||
import { GetSessionResponse } from "./contract";
|
import { GetSessionResponse } from "./contract";
|
||||||
import { format } from "date-fns";
|
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() {
|
export function SessionDetails() {
|
||||||
const { sessionId } = useParams<{ sessionId: string }>();
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
@@ -28,42 +45,49 @@ export function SessionDetails() {
|
|||||||
params: { id: sessionId },
|
params: { id: sessionId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns = useMemo<
|
const columns = useMemo(
|
||||||
MRT_ColumnDef<GetSessionResponse["log_entries"][0]>[]
|
|
||||||
>(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
columnHelper.accessor("category", {
|
||||||
accessorKey: "message",
|
header: "Category",
|
||||||
|
Cell: ({ cell }) => <Typography>{cell.getValue()}</Typography>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("message", {
|
||||||
header: "Message",
|
header: "Message",
|
||||||
},
|
Cell: ({ cell }) => <Typography>{cell.getValue()}</Typography>,
|
||||||
{
|
}),
|
||||||
accessorKey: "timestamp",
|
columnHelper.accessor("timestamp", {
|
||||||
header: "Timestamp",
|
header: "Timestamp",
|
||||||
Cell: (x) => <span>{format(x.row.original.timestamp, "Pp")}</span>,
|
Cell: ({ cell }) => (
|
||||||
},
|
<Typography>{format(cell.getValue(), "Pp")}</Typography>
|
||||||
{
|
),
|
||||||
accessorKey: "metadata",
|
}),
|
||||||
|
columnHelper.accessor("metadata", {
|
||||||
header: "Metadata",
|
header: "Metadata",
|
||||||
Cell: (x) => {
|
Cell: ({ cell }) => {
|
||||||
const metadata = x.row.original.metadata;
|
const metadata = cell.getValue();
|
||||||
return (
|
return (
|
||||||
<List dense>
|
<List dense>
|
||||||
{Object.keys(metadata)
|
{Object.keys(metadata)
|
||||||
.sort()
|
.sort()
|
||||||
.map((key) => (
|
.map((key) => (
|
||||||
<ListItem key={key}>
|
<ListItem key={key}>
|
||||||
<ListItemText>{key}: {metadata[key]}</ListItemText>
|
<ListItemText>
|
||||||
|
<Typography>
|
||||||
|
{key}: {metadata[key]}
|
||||||
|
</Typography>
|
||||||
|
</ListItemText>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useMaterialReactTable({
|
const table = useMaterialReactTable({
|
||||||
|
layoutMode: "grid",
|
||||||
columns,
|
columns,
|
||||||
data:
|
data:
|
||||||
getSessionQuery.status === "success"
|
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" ? (
|
return getSessionQuery.status === "error" ? (
|
||||||
<p>Error</p>
|
<p>Error</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -93,6 +173,52 @@ export function SessionDetails() {
|
|||||||
: getSessionQuery.data.body.version}
|
: getSessionQuery.data.body.version}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</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>
|
</Card>
|
||||||
{getSessionQuery.status === "success" && (
|
{getSessionQuery.status === "success" && (
|
||||||
<MaterialReactTable table={table} />
|
<MaterialReactTable table={table} />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type MRT_ColumnDef,
|
type MRT_ColumnDef,
|
||||||
type MRT_PaginationState,
|
type MRT_PaginationState,
|
||||||
} from "material-react-table";
|
} from "material-react-table";
|
||||||
import { GetSessionsResponse } from "../common/contract";
|
import { GetSessionsResponse } from "./contract";
|
||||||
import { format, formatDuration, interval, intervalToDuration } from "date-fns";
|
import { format, formatDuration, interval, intervalToDuration } from "date-fns";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|||||||
@@ -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",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
20
src/common/contract/get-categories.ts
Normal file
20
src/common/contract/get-categories.ts
Normal 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 };
|
||||||
|
}
|
||||||
27
src/common/contract/get-entries.ts
Normal file
27
src/common/contract/get-entries.ts
Normal 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 };
|
||||||
|
}
|
||||||
51
src/common/contract/get-session.ts
Normal file
51
src/common/contract/get-session.ts
Normal 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 };
|
||||||
|
}
|
||||||
72
src/common/contract/get-sessions.ts
Normal file
72
src/common/contract/get-sessions.ts
Normal 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 };
|
||||||
|
}
|
||||||
114
src/common/contract/index.ts
Normal file
114
src/common/contract/index.ts
Normal 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>;
|
||||||
63
src/common/contract/post-log.ts
Normal file
63
src/common/contract/post-log.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
src/common/contract/types.ts
Normal file
3
src/common/contract/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type Z = typeof z;
|
||||||
12
src/server/db/get-categories.ts
Normal file
12
src/server/db/get-categories.ts
Normal 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!);
|
||||||
|
}
|
||||||
36
src/server/db/get-entries.ts
Normal file
36
src/server/db/get-entries.ts
Normal 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 }), {}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export async function getSession(id: string): Promise<GetSessionResponse> {
|
|||||||
const logEntries = await db
|
const logEntries = await db
|
||||||
.selectFrom("log_entries")
|
.selectFrom("log_entries")
|
||||||
.where("session_info_id", "=", id)
|
.where("session_info_id", "=", id)
|
||||||
.select(["id", "message", "timestamp"])
|
.select(["id", "message", "timestamp", "category"])
|
||||||
.orderBy("timestamp", "asc")
|
.orderBy("timestamp", "asc")
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ export async function getSession(id: string): Promise<GetSessionResponse> {
|
|||||||
log_entries: logEntries.map((entry) => ({
|
log_entries: logEntries.map((entry) => ({
|
||||||
message: entry.message,
|
message: entry.message,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
|
category: entry.category,
|
||||||
metadata: metadata
|
metadata: metadata
|
||||||
.filter((m) => m.log_entry_id === entry.id)
|
.filter((m) => m.log_entry_id === entry.id)
|
||||||
.reduce((acc, m) => ({ ...acc, [m.key]: m.value }), {}),
|
.reduce((acc, m) => ({ ...acc, [m.key]: m.value }), {}),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export async function postLog(
|
|||||||
saveGuid: string,
|
saveGuid: string,
|
||||||
sessionGuid: string,
|
sessionGuid: string,
|
||||||
message: string,
|
message: string,
|
||||||
|
category: string | undefined,
|
||||||
metadata: SingleMetadata[]
|
metadata: SingleMetadata[]
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
@@ -38,6 +39,7 @@ export async function postLog(
|
|||||||
session_info_id: sessionGuid,
|
session_info_id: sessionGuid,
|
||||||
message: message,
|
message: message,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
|
category,
|
||||||
})
|
})
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
// add all the metadata
|
// add all the metadata
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface LogEntriesTable
|
|||||||
session_info_id: string;
|
session_info_id: string;
|
||||||
message: string;
|
message: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
|
category?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogEntry = Selectable<LogEntriesTable>;
|
export type LogEntry = Selectable<LogEntriesTable>;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createExpressEndpoints, initServer } from "@ts-rest/express";
|
import { createExpressEndpoints, initServer } from "@ts-rest/express";
|
||||||
import type { Express } from "express";
|
import { type Express } from "express";
|
||||||
import { contract } from "../common/contract";
|
import { contract } from "../common/contract";
|
||||||
import { HttpError } from "./http-error";
|
import { HttpError } from "./http-error";
|
||||||
import { OpenAPIObject } from "openapi3-ts/oas31";
|
import { OpenAPIObject } from "openapi3-ts/oas31";
|
||||||
import { getSession, getSessions, postLog } from "./db";
|
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) {
|
export function installRouter(app: Express, openAPIObject: OpenAPIObject) {
|
||||||
const s = initServer();
|
const s = initServer();
|
||||||
@@ -16,6 +18,7 @@ export function installRouter(app: Express, openAPIObject: OpenAPIObject) {
|
|||||||
params.saveGuid,
|
params.saveGuid,
|
||||||
params.sessionGuid,
|
params.sessionGuid,
|
||||||
body.message,
|
body.message,
|
||||||
|
body.category,
|
||||||
body.metadata
|
body.metadata
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -53,6 +56,20 @@ export function installRouter(app: Express, openAPIObject: OpenAPIObject) {
|
|||||||
body,
|
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 () => {
|
getOpenApi: async () => {
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
Reference in New Issue
Block a user