feat: initial import.
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
HTTP_PORT=9283
|
||||||
|
HTTP_HOST=localhost
|
||||||
|
DATABASE_NAME=game_logger
|
||||||
|
DATABASE_HOST=localhost
|
||||||
|
DATABASE_USER=game_logger
|
||||||
|
DATABASE_PASSWORD=game_logger
|
||||||
|
DATABASE_PORT=3306
|
||||||
|
DATABASE_CONNECTIONLIMIT=10
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
.vscode/launch.json
vendored
Normal file
14
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Attach by Process ID",
|
||||||
|
"port": 4321,
|
||||||
|
"request": "attach",
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
],
|
||||||
|
"type": "node"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
20
.vscode/tasks.json
vendored
Normal file
20
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "dev",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"label": "npm: dev",
|
||||||
|
"detail": "Run the development environment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "build",
|
||||||
|
"group": "build",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"label": "npm: build",
|
||||||
|
"detail": "Build the production server"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5175
package-lock.json
generated
Normal file
5175
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "game-logger",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"main": "dist/server/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev:server": "ts-node-dev --project ./src/server/tsconfig.json --inspect=4321 --respawn --transpile-only --ignore-watch node_modules ./src/server/index.ts",
|
||||||
|
"dev:client": "vite serve ./src/client",
|
||||||
|
"dev:typecheck": "tsc --noEmit --watch --project ./src/client/tsconfig.json --preserveWatchOutput",
|
||||||
|
"dev": "concurrently -c magenta,blue,green npm:dev:*",
|
||||||
|
"build:server": "tsc --project ./src/server/tsconfig.json",
|
||||||
|
"build:client": "vite build ./src/client",
|
||||||
|
"build": "concurrently -c magenta,blue npm:build:*",
|
||||||
|
"start": "node dist/server/index.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@fontsource/roboto": "^5.1.1",
|
||||||
|
"@mui/icons-material": "^6.3.1",
|
||||||
|
"@mui/material": "^6.3.1",
|
||||||
|
"@mui/x-date-pickers": "^7.23.3",
|
||||||
|
"@tanstack/react-query": "^4.0.0",
|
||||||
|
"@ts-rest/react-query": "^3.51.0",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/react": "^18.0.0",
|
||||||
|
"@types/react-dom": "^18.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"material-react-table": "^3.1.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0",
|
||||||
|
"react-router": "^7.1.1",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ts-rest/core": "^3.51.0",
|
||||||
|
"@ts-rest/express": "^3.51.0",
|
||||||
|
"async-wrapper-express-ts": "^3.1.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.0.0",
|
||||||
|
"kysely": "^0.27.5",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"mysql2": "^3.12.0",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/client/App.css
Normal file
0
src/client/App.css
Normal file
38
src/client/App.tsx
Normal file
38
src/client/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import "@fontsource/roboto/300.css";
|
||||||
|
import "@fontsource/roboto/400.css";
|
||||||
|
import "@fontsource/roboto/500.css";
|
||||||
|
import "@fontsource/roboto/700.css";
|
||||||
|
import { BrowserRouter, Route, Routes } from "react-router";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Sessions } from "./Sessions";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { ThemeProvider } from "@emotion/react";
|
||||||
|
import { CssBaseline } from "@mui/material";
|
||||||
|
import theme from "./theme";
|
||||||
|
import { SessionDetails } from "./SessionDetails";
|
||||||
|
import { Root } from "./Root";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Root />}>
|
||||||
|
<Route index element={<Sessions />} />
|
||||||
|
<Route
|
||||||
|
path="/session/:sessionId"
|
||||||
|
element={<SessionDetails />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/client/Root.tsx
Normal file
46
src/client/Root.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Breadcrumbs,
|
||||||
|
Container,
|
||||||
|
Link,
|
||||||
|
Stack,
|
||||||
|
Toolbar,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
Outlet,
|
||||||
|
Link as RouterLink,
|
||||||
|
useLocation,
|
||||||
|
useParams,
|
||||||
|
} from "react-router";
|
||||||
|
import "./theme";
|
||||||
|
|
||||||
|
export function Root() {
|
||||||
|
const location = useLocation();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<AppBar position="static">
|
||||||
|
<Toolbar>
|
||||||
|
<Breadcrumbs aria-label="breadcrumb">
|
||||||
|
<Link variant="breadcrumb" component={RouterLink} to="/">
|
||||||
|
Sessions
|
||||||
|
</Link>
|
||||||
|
{location.pathname.includes("/session/") && (
|
||||||
|
<Link
|
||||||
|
variant="breadcrumb"
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/session/${params.sessionId}`}
|
||||||
|
>
|
||||||
|
{`${params.sessionId}`}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Breadcrumbs>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Outlet />
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/client/SessionDetails.tsx
Normal file
102
src/client/SessionDetails.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useParams } from "react-router";
|
||||||
|
import { client } from "./client";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
MaterialReactTable,
|
||||||
|
MRT_ColumnDef,
|
||||||
|
useMaterialReactTable,
|
||||||
|
} from "material-react-table";
|
||||||
|
import { GetSessionResponse } from "./contract";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export function SessionDetails() {
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
|
if (sessionId === undefined) {
|
||||||
|
throw new Error("session id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSessionQuery = client.getSession.useQuery(["session", sessionId], {
|
||||||
|
params: { id: sessionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo<
|
||||||
|
MRT_ColumnDef<GetSessionResponse["log_entries"][0]>[]
|
||||||
|
>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "message",
|
||||||
|
header: "Message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "timestamp",
|
||||||
|
header: "Timestamp",
|
||||||
|
Cell: (x) => <span>{format(x.row.original.timestamp, "Pp")}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "metadata",
|
||||||
|
header: "Metadata",
|
||||||
|
Cell: (x) => {
|
||||||
|
const metadata = x.row.original.metadata;
|
||||||
|
return (
|
||||||
|
<List dense>
|
||||||
|
{Object.keys(metadata)
|
||||||
|
.sort()
|
||||||
|
.map((key) => (
|
||||||
|
<ListItem key={key}>
|
||||||
|
<ListItemText>{key}: {metadata[key]}</ListItemText>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useMaterialReactTable({
|
||||||
|
columns,
|
||||||
|
data:
|
||||||
|
getSessionQuery.status === "success"
|
||||||
|
? getSessionQuery.data.body.log_entries
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return getSessionQuery.status === "error" ? (
|
||||||
|
<p>Error</p>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
|
Session ID: {sessionId}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" component="div">
|
||||||
|
Game:{" "}
|
||||||
|
{getSessionQuery.status === "loading"
|
||||||
|
? "..."
|
||||||
|
: getSessionQuery.data.body.game_name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Version{" "}
|
||||||
|
{getSessionQuery.status === "loading"
|
||||||
|
? "..."
|
||||||
|
: getSessionQuery.data.body.version}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{getSessionQuery.status === "success" && (
|
||||||
|
<MaterialReactTable table={table} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/client/Sessions.tsx
Normal file
142
src/client/Sessions.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { client } from "./client";
|
||||||
|
import {
|
||||||
|
MaterialReactTable,
|
||||||
|
MRT_ColumnFiltersState,
|
||||||
|
MRT_Updater,
|
||||||
|
useMaterialReactTable,
|
||||||
|
type MRT_ColumnDef,
|
||||||
|
type MRT_PaginationState,
|
||||||
|
} from "material-react-table";
|
||||||
|
import { GetSessionsResponse } from "../common/contract";
|
||||||
|
import { format, formatDuration, interval, intervalToDuration } from "date-fns";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
|
export function Sessions() {
|
||||||
|
const [paginationState, setPaginationState] = useState<MRT_PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
const [columnFilters, setColumnFilters] = useState<MRT_ColumnFiltersState>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSessionsQuery = client.getSessions.useQuery(
|
||||||
|
["sessions", { columnFilters, paginationState }],
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
skip: paginationState.pageIndex * paginationState.pageSize,
|
||||||
|
count: paginationState.pageSize,
|
||||||
|
id: columnFilters.find((f) => f.id === "id")?.value as
|
||||||
|
| string
|
||||||
|
| undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return getSessionsQuery.status === "error" ? (
|
||||||
|
<p>Error</p>
|
||||||
|
) : (
|
||||||
|
<SessionsTable
|
||||||
|
data={
|
||||||
|
getSessionsQuery.status === "loading"
|
||||||
|
? { total: 0, sessions: [] }
|
||||||
|
: getSessionsQuery.data.body
|
||||||
|
}
|
||||||
|
paginationState={paginationState}
|
||||||
|
setPaginationState={setPaginationState}
|
||||||
|
isLoading={getSessionsQuery.status === "loading"}
|
||||||
|
isRefetching={getSessionsQuery.isRefetching}
|
||||||
|
columnFilters={columnFilters}
|
||||||
|
setColumnFilters={setColumnFilters}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionsTable({
|
||||||
|
data: { sessions, total },
|
||||||
|
paginationState,
|
||||||
|
columnFilters,
|
||||||
|
setPaginationState,
|
||||||
|
setColumnFilters,
|
||||||
|
isLoading,
|
||||||
|
isRefetching,
|
||||||
|
}: {
|
||||||
|
data: GetSessionsResponse;
|
||||||
|
paginationState: MRT_PaginationState;
|
||||||
|
setPaginationState: (updater: MRT_Updater<MRT_PaginationState>) => void;
|
||||||
|
setColumnFilters: (updater: MRT_Updater<MRT_ColumnFiltersState>) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
isRefetching: boolean;
|
||||||
|
columnFilters: MRT_ColumnFiltersState;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo<MRT_ColumnDef<GetSessionsResponse["sessions"][0]>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "game_name",
|
||||||
|
header: "Game Name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "version",
|
||||||
|
header: "Version",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "time",
|
||||||
|
header: "Time",
|
||||||
|
Cell: ({ row }) => {
|
||||||
|
const rangeInterval = interval(
|
||||||
|
row.original.start_time,
|
||||||
|
row.original.end_time
|
||||||
|
);
|
||||||
|
const duration = intervalToDuration(rangeInterval);
|
||||||
|
const formattedStartTime = format(row.original.start_time, "Pp");
|
||||||
|
const formattedDuration = formatDuration(duration);
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{formattedStartTime}
|
||||||
|
<br />({formattedDuration || "very quick"})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const table = useMaterialReactTable({
|
||||||
|
data: sessions,
|
||||||
|
columns,
|
||||||
|
muiTableBodyRowProps: ({ row }) => ({
|
||||||
|
onClick: (_) => {
|
||||||
|
navigate(`/session/${row.original.id}`);
|
||||||
|
},
|
||||||
|
sx: {
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
manualPagination: true,
|
||||||
|
rowCount: total,
|
||||||
|
onPaginationChange: setPaginationState,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
state: {
|
||||||
|
pagination: paginationState,
|
||||||
|
isLoading,
|
||||||
|
showProgressBars: isRefetching,
|
||||||
|
columnFilters,
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
enableGlobalFilter: false,
|
||||||
|
manualFiltering: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <MaterialReactTable table={table} />;
|
||||||
|
}
|
||||||
10
src/client/api.ts
Normal file
10
src/client/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const serverRoot =
|
||||||
|
import.meta.env.MODE === "development"
|
||||||
|
? "http://localhost:9283"
|
||||||
|
: window.location.protocol + "//" + window.location.host;
|
||||||
|
|
||||||
|
const getSessions = () =>
|
||||||
|
fetch(`${serverRoot}/api/get-sessions`)
|
||||||
|
.then((x) => x.json())
|
||||||
|
.then(console.log)
|
||||||
|
.catch(console.error);
|
||||||
13
src/client/client.ts
Normal file
13
src/client/client.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// client.ts
|
||||||
|
import { initQueryClient } from "@ts-rest/react-query";
|
||||||
|
import { contract } from "../common/contract";
|
||||||
|
|
||||||
|
const serverRoot =
|
||||||
|
import.meta.env.MODE === "development"
|
||||||
|
? "http://localhost:9283"
|
||||||
|
: window.location.protocol + "//" + window.location.host;
|
||||||
|
|
||||||
|
export const client = initQueryClient(contract, {
|
||||||
|
baseUrl: serverRoot,
|
||||||
|
baseHeaders: {},
|
||||||
|
});
|
||||||
12
src/client/index.html
Normal file
12
src/client/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||||
|
<title>Game Logger</title>
|
||||||
|
<script type="module" src="./index.tsx"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
src/client/index.tsx
Normal file
10
src/client/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
const domRoot = document.getElementById("root");
|
||||||
|
if (!domRoot) {
|
||||||
|
alert("Cannot find note root");
|
||||||
|
} else {
|
||||||
|
const root = createRoot(domRoot);
|
||||||
|
root.render(<App />);
|
||||||
|
}
|
||||||
50
src/client/theme.ts
Normal file
50
src/client/theme.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
import { red } from "@mui/material/colors";
|
||||||
|
import { TypographyOwnProps, TypographyPropsVariantOverrides } from "@mui/material";
|
||||||
|
import { OverridableStringUnion} from "@mui/types"
|
||||||
|
import { Variant } from "@mui/material/styles/createTypography";
|
||||||
|
|
||||||
|
declare module "@mui/material/Typography" {
|
||||||
|
// interface ButtonPropsVariantOverrides {
|
||||||
|
// dashed: true;
|
||||||
|
// }
|
||||||
|
interface TypographyPropsVariantOverrides {
|
||||||
|
breadcrumb: true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const x: LinkPropsVariantOverrides;
|
||||||
|
|
||||||
|
// A custom theme for this app
|
||||||
|
const theme = createTheme({
|
||||||
|
cssVariables: true,
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: "#556cd6",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: "#19857b",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: red.A400,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiLink: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
props: { variant: "breadcrumb" },
|
||||||
|
style: {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default theme;
|
||||||
24
src/client/tsconfig.json
Normal file
24
src/client/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"../../node_modules"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"isolatedModules": true,
|
||||||
|
"strict": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"rootDirs": [
|
||||||
|
".",
|
||||||
|
"../common"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/client/vite.config.js
Normal file
11
src/client/vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
build: {
|
||||||
|
outDir: "../../dist/client",
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
});
|
||||||
64
src/common/contract.ts
Normal file
64
src/common/contract.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { initContract } from "@ts-rest/core";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const c = initContract();
|
||||||
|
|
||||||
|
//////
|
||||||
|
|
||||||
|
const GetSessionsResponseSchema = z.object({
|
||||||
|
total: z.number().positive(),
|
||||||
|
sessions: z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
game_name: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
start_time: z.date(),
|
||||||
|
end_time: z.date(),
|
||||||
|
num_events: z.number().positive(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GetSessionsResponse = z.infer<typeof GetSessionsResponseSchema>;
|
||||||
|
|
||||||
|
const GetSessionsRequestQuery = z.object({
|
||||||
|
skip: z.coerce.number().optional(),
|
||||||
|
count: z.coerce.number().optional(),
|
||||||
|
id: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
//////
|
||||||
|
|
||||||
|
const GetSessionResponseSchema = z.object({
|
||||||
|
game_name: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
log_entries: z.object({
|
||||||
|
message: z.string(),
|
||||||
|
timestamp: z.date(),
|
||||||
|
metadata: z.record(z.string(), z.string())
|
||||||
|
}).array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GetSessionResponse = z.infer<typeof GetSessionResponseSchema>;
|
||||||
|
|
||||||
|
//////
|
||||||
|
|
||||||
|
export const contract = c.router({
|
||||||
|
getSessions: {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/get-sessions",
|
||||||
|
responses: {
|
||||||
|
200: GetSessionsResponseSchema,
|
||||||
|
},
|
||||||
|
query: GetSessionsRequestQuery,
|
||||||
|
summary: "get all the sessions",
|
||||||
|
},
|
||||||
|
getSession: {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/get-session/:id",
|
||||||
|
responses: {
|
||||||
|
200: GetSessionResponseSchema,
|
||||||
|
},
|
||||||
|
summary: "get a session by id",
|
||||||
|
}
|
||||||
|
});
|
||||||
16
src/common/tsconfig.json
Normal file
16
src/common/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"exclude": ["../../node_modules"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"isolatedModules": true,
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/server/db/add-entry.ts
Normal file
44
src/server/db/add-entry.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { db } from "./init";
|
||||||
|
|
||||||
|
export async function addEntry(
|
||||||
|
sessionGuid: string,
|
||||||
|
gameName: string,
|
||||||
|
version: string,
|
||||||
|
message: string,
|
||||||
|
metadata: [string, string][]
|
||||||
|
) {
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
// upsert the session info
|
||||||
|
await trx
|
||||||
|
.insertInto("session_info")
|
||||||
|
.ignore()
|
||||||
|
.values({
|
||||||
|
id: sessionGuid,
|
||||||
|
game_name: gameName,
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
// add the main log line
|
||||||
|
const insertResult = await trx
|
||||||
|
.insertInto("log_entries")
|
||||||
|
.values({
|
||||||
|
session_info_id: sessionGuid,
|
||||||
|
message: message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
})
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
// add all the metadata
|
||||||
|
if (metadata.length > 0) {
|
||||||
|
await trx
|
||||||
|
.insertInto("log_metadata")
|
||||||
|
.values(
|
||||||
|
metadata.map(([key, value]) => ({
|
||||||
|
log_entry_id: Number(insertResult.insertId),
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
43
src/server/db/get-session.ts
Normal file
43
src/server/db/get-session.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { GetSessionResponse } from "../contract";
|
||||||
|
import { db } from "./init";
|
||||||
|
|
||||||
|
export async function getSession(id: string): Promise<GetSessionResponse> {
|
||||||
|
// extract the session
|
||||||
|
const session = await db
|
||||||
|
.selectFrom("session_info")
|
||||||
|
.where("id", "=", id)
|
||||||
|
.select(["game_name", "version"])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
// get all the connected log entries
|
||||||
|
const logEntries = await db
|
||||||
|
.selectFrom("log_entries")
|
||||||
|
.where("session_info_id", "=", id)
|
||||||
|
.select(["id", "message", "timestamp"])
|
||||||
|
.orderBy("timestamp", "asc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// get all the metadata for the log entries
|
||||||
|
const metadata = await db
|
||||||
|
.selectFrom("log_metadata")
|
||||||
|
.where(
|
||||||
|
"log_entry_id",
|
||||||
|
"in",
|
||||||
|
logEntries.map((entry) => entry.id)
|
||||||
|
)
|
||||||
|
.select(["log_entry_id", "key", "value"])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// buidl the object accordingly
|
||||||
|
return {
|
||||||
|
game_name: session.game_name,
|
||||||
|
version: session.version,
|
||||||
|
log_entries: logEntries.map((entry) => ({
|
||||||
|
message: entry.message,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
metadata: metadata
|
||||||
|
.filter((m) => m.log_entry_id === entry.id)
|
||||||
|
.reduce((acc, m) => ({ ...acc, [m.key]: m.value }), {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
46
src/server/db/get-sessions.ts
Normal file
46
src/server/db/get-sessions.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { GetSessionsResponse } from "../contract";
|
||||||
|
import { db } from "./init";
|
||||||
|
import { toNumber } from "./to-number";
|
||||||
|
|
||||||
|
export async function getSessions(
|
||||||
|
skip?: number,
|
||||||
|
count?: number,
|
||||||
|
id?: string
|
||||||
|
): Promise<GetSessionsResponse> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
let countQuery = db
|
||||||
|
.selectFrom("session_info")
|
||||||
|
.select((eb) => eb.fn.count("id").as("total_value"));
|
||||||
|
if (id) {
|
||||||
|
countQuery = countQuery.where("session_info.id", "like", `%${id}%`);
|
||||||
|
}
|
||||||
|
const { total_value } = await countQuery.executeTakeFirstOrThrow();
|
||||||
|
let total = toNumber(total_value);
|
||||||
|
|
||||||
|
let query = db
|
||||||
|
.selectFrom("session_info")
|
||||||
|
.innerJoin("log_entries", "log_entries.session_info_id", "session_info.id")
|
||||||
|
.groupBy("session_info.id")
|
||||||
|
.select(({ fn }) => [
|
||||||
|
"session_info.id",
|
||||||
|
"session_info.game_name",
|
||||||
|
"session_info.version",
|
||||||
|
fn.min("log_entries.timestamp").as("start_time"),
|
||||||
|
fn.max("log_entries.timestamp").as("end_time"),
|
||||||
|
fn.count("log_entries.id").as("num_events"),
|
||||||
|
])
|
||||||
|
.orderBy("start_time", "desc")
|
||||||
|
.limit(Math.min(50, count || 10))
|
||||||
|
.offset(skip || 0);
|
||||||
|
if (id) {
|
||||||
|
query = query.where("session_info.id", "like", `%${id}%`);
|
||||||
|
}
|
||||||
|
const sessions = await query.execute();
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
sessions: sessions.map((session) => ({
|
||||||
|
...session,
|
||||||
|
num_events: toNumber(session.num_events),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/server/db/index.ts
Normal file
1
src/server/db/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { addEntry } from "./add-entry";
|
||||||
39
src/server/db/init.ts
Normal file
39
src/server/db/init.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Database } from "./types"; // this is the Database interface we defined earlier
|
||||||
|
import { createPool } from "mysql2"; // do not use 'mysql2/promises'!
|
||||||
|
import { Kysely, MysqlDialect } from "kysely";
|
||||||
|
import { env } from "process";
|
||||||
|
|
||||||
|
const dialect = new MysqlDialect({
|
||||||
|
pool: createPool({
|
||||||
|
database: env.DATABASE_NAME || "",
|
||||||
|
host: env.DATABASE_HOST || "",
|
||||||
|
user: env.DATABASE_USER || "",
|
||||||
|
password: env.DATABASE_PASSWORD || "",
|
||||||
|
port: parseInt(env.DATABASE_PORT || "3308", 10),
|
||||||
|
connectionLimit: parseInt(
|
||||||
|
env.CONDATABASE_CONNECTIONLIMITNECTIONLIMIT || "10",
|
||||||
|
10
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const db = new Kysely<Database>({
|
||||||
|
dialect,
|
||||||
|
log(event) {
|
||||||
|
if (event.level === "error") {
|
||||||
|
console.error("Query failed : ", {
|
||||||
|
durationMs: Math.round(event.queryDurationMillis),
|
||||||
|
error: event.error,
|
||||||
|
sql: event.query.sql,
|
||||||
|
params: event.query.parameters,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// `'query'`
|
||||||
|
console.log("Query executed : ", {
|
||||||
|
durationMs: Math.round(event.queryDurationMillis),
|
||||||
|
sql: event.query.sql,
|
||||||
|
params: event.query.parameters,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
13
src/server/db/to-number.ts
Normal file
13
src/server/db/to-number.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function toNumber(x: string | number | bigint): number {
|
||||||
|
if (typeof x === "string") {
|
||||||
|
return parseInt(x, 10);
|
||||||
|
} else if (typeof x === "bigint") {
|
||||||
|
if (x > Number.MAX_SAFE_INTEGER) {
|
||||||
|
throw new Error("Number is too large to be represented as a number");
|
||||||
|
} else {
|
||||||
|
return Number(x);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/server/db/types.ts
Normal file
39
src/server/db/types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Generated, Insertable, Selectable } from "kysely";
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
log_entries: LogEntriesTable;
|
||||||
|
log_metadata: LogMetadataTable;
|
||||||
|
session_info: SessionInfoTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntriesTable
|
||||||
|
{
|
||||||
|
id: Generated<number>;
|
||||||
|
session_info_id: string;
|
||||||
|
message: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogEntry = Selectable<LogEntriesTable>;
|
||||||
|
export type NewLogEntry = Insertable<LogEntriesTable>;
|
||||||
|
|
||||||
|
export interface LogMetadataTable
|
||||||
|
{
|
||||||
|
id: Generated<number>;
|
||||||
|
log_entry_id: number;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogMetadata = Selectable<LogMetadataTable>;
|
||||||
|
export type NewLogMetadata = Insertable<LogMetadataTable>;
|
||||||
|
|
||||||
|
export interface SessionInfoTable
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
game_name: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionInfo = Selectable<SessionInfoTable>;
|
||||||
|
export type NewSessionInfoTable = Insertable<SessionInfoTable>;
|
||||||
42
src/server/index.ts
Normal file
42
src/server/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import bodyParser from "body-parser";
|
||||||
|
import { registerPostMessage } from "./post-message";
|
||||||
|
import { installRouter } from "./router";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// serve che client files
|
||||||
|
app.use(express.static("./dist/client"));
|
||||||
|
|
||||||
|
// activate cors, but only if in dev mode (in prodution, the client is served by the same server)
|
||||||
|
if (process.env.TS_NODE_DEV) {
|
||||||
|
app.use(cors());
|
||||||
|
}
|
||||||
|
|
||||||
|
// enable body parser to parse json and urlencoded data
|
||||||
|
app.use(bodyParser.urlencoded({ extended: false }));
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
|
// install the ts-rest router
|
||||||
|
installRouter(app);
|
||||||
|
|
||||||
|
// register the POST in multipart to add messages
|
||||||
|
registerPostMessage(app);
|
||||||
|
|
||||||
|
// handle every other GET route with index.html
|
||||||
|
app.get("*", function (_req, res, next) {
|
||||||
|
if (_req.method === "GET") {
|
||||||
|
res.sendFile(path.join(__dirname, "../client/index.html"));
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = parseInt(process.env.HTTP_PORT || "1111", 10);
|
||||||
|
const host = process.env.HTTP_HOST || "localhost";
|
||||||
|
app.listen(port, host, () => {
|
||||||
|
console.log(`Listening on http://${host}:${port}`);
|
||||||
|
});
|
||||||
59
src/server/post-message.ts
Normal file
59
src/server/post-message.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Express } from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import { addEntry } from "./db";
|
||||||
|
|
||||||
|
const upload = multer();
|
||||||
|
|
||||||
|
export function registerPostMessage(app: Express) {
|
||||||
|
app.post("/", upload.none(), (req, res) => {
|
||||||
|
console.log("\nReceived logging message:");
|
||||||
|
const metadata: [string, string][] = [];
|
||||||
|
let gameName: string = "";
|
||||||
|
let sessionGuid: string = "";
|
||||||
|
let message: string = "";
|
||||||
|
let version: string = "";
|
||||||
|
for (const key of Object.keys(req.body)) {
|
||||||
|
const value = req.body[key];
|
||||||
|
console.log(` ${key}: ${value}`);
|
||||||
|
switch (key) {
|
||||||
|
case "gameName":
|
||||||
|
gameName = value;
|
||||||
|
break;
|
||||||
|
case "sessionGuid":
|
||||||
|
sessionGuid = value;
|
||||||
|
break;
|
||||||
|
case "message":
|
||||||
|
message = value;
|
||||||
|
break;
|
||||||
|
case "version":
|
||||||
|
version = value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
metadata.push([key, req.body[key]]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
gameName === "" ||
|
||||||
|
sessionGuid === "" ||
|
||||||
|
message === "" ||
|
||||||
|
version === ""
|
||||||
|
) {
|
||||||
|
res.status(422); // unprocessable entity: used when the body has correct syntax but wrong semantics
|
||||||
|
const errorMessage = `One of the necessary fields was missing: gameName=${gameName}, sessionGuid=${sessionGuid}, message=${message}, version=${version}`;
|
||||||
|
console.error(errorMessage);
|
||||||
|
res.send(errorMessage);
|
||||||
|
} else {
|
||||||
|
addEntry(sessionGuid, gameName, version, message, metadata)
|
||||||
|
.then(() => {
|
||||||
|
res.status(204); // no content
|
||||||
|
res.send("");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500); // internal server error
|
||||||
|
res.send("Internal server error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
27
src/server/router.ts
Normal file
27
src/server/router.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { createExpressEndpoints, initServer } from "@ts-rest/express";
|
||||||
|
import type { Express } from "express";
|
||||||
|
import { contract } from "../common/contract";
|
||||||
|
import { getSessions } from "./db/get-sessions";
|
||||||
|
import { getSession } from "./db/get-session";
|
||||||
|
|
||||||
|
export function installRouter(app: Express) {
|
||||||
|
const s = initServer();
|
||||||
|
const router = s.router(contract, {
|
||||||
|
getSessions: async ({ query: { skip, count, id } }) => {
|
||||||
|
const body = await getSessions(skip, count, id);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getSession: async ({ params: { id } }) => {
|
||||||
|
const body = await getSession(id);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createExpressEndpoints(contract, router, app);
|
||||||
|
}
|
||||||
27
src/server/tsconfig.json
Normal file
27
src/server/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"./**/*", "../common/**/*"
|
||||||
|
],
|
||||||
|
"exclude": ["../../node_modules"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": [
|
||||||
|
"node",
|
||||||
|
"express"
|
||||||
|
],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "CommonJS",
|
||||||
|
// "rootDir": ".",
|
||||||
|
"rootDirs": [".", "../common"],
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"typeRoots": [
|
||||||
|
"../../node_modules/@types"
|
||||||
|
],
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "../../dist/",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user