feat: category export and API cleanup.
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
.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 }), {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface LogEntriesTable
|
||||
session_info_id: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export type LogEntry = Selectable<LogEntriesTable>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user