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