Merge pull request 'feat(i18n): observability - analytics + log tables (+ events/polls detail)' (#10) from feat/observability-i18n into main
Local Build and Deploy / deploy (push) Successful in 59s

Reviewed-on: #10
This commit is contained in:
Simo committed 2026-07-18 21:52:36 +02:00
commit 022fbfa223
13 files changed
+530 -165

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14 -11
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table"; import { DataTable } from "@/components/admin/data-table";
import { formatTimestamp } from "@/lib/admin-helpers"; import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types"; import type { DataTableColumn, PaginatedResult } from "@/types";
@@ -12,35 +13,37 @@ interface ChatLogRow {
roomId: number; roomId: number;
} }
interface ChatTableProps {
data: PaginatedResult<ChatLogRow>;
}
export function ChatTable({ data }: ChatTableProps) {
const t = useTranslations("pages.admin.logs");
const columns: DataTableColumn<ChatLogRow>[] = [ const columns: DataTableColumn<ChatLogRow>[] = [
{ key: "id", label: "ID", sortable: true }, { key: "id", label: t("colId"), sortable: true },
{ key: "username", label: "User" }, { key: "username", label: t("colUser") },
{ {
key: "message", key: "message",
label: "Message", label: t("chat.colMessage"),
render: (value) => ( render: (value) => (
<span className="max-w-[300px] truncate block">{String(value)}</span> <span className="max-w-[300px] truncate block">{String(value)}</span>
), ),
}, },
{ {
key: "timestamp", key: "timestamp",
label: "Time", label: t("colTime"),
sortable: true, sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>, render: (value) => <span>{formatTimestamp(Number(value))}</span>,
}, },
{ key: "roomId", label: "Room ID", sortable: true }, { key: "roomId", label: t("chat.colRoomId"), sortable: true },
]; ];
interface ChatTableProps {
data: PaginatedResult<ChatLogRow>;
}
export function ChatTable({ data }: ChatTableProps) {
return ( return (
<DataTable <DataTable
data={data} data={data}
columns={columns} columns={columns}
searchPlaceholder="Search by username or message..." searchPlaceholder={t("chat.searchPlaceholder")}
exportUrl="/api/admin/export?type=chatlogs" exportUrl="/api/admin/export?type=chatlogs"
/> />
); );
+25 -22
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table"; import { DataTable } from "@/components/admin/data-table";
import { formatTimestamp } from "@/lib/admin-helpers"; import { formatTimestamp } from "@/lib/admin-helpers";
import type { DataTableColumn, PaginatedResult } from "@/types"; import type { DataTableColumn, PaginatedResult } from "@/types";
@@ -12,37 +13,39 @@ interface CommandLogRow {
timestamp: number; timestamp: number;
} }
const columns: DataTableColumn<CommandLogRow>[] = [
{ key: "id", label: "ID", sortable: true },
{ key: "username", label: "User" },
{ key: "command", label: "Command", sortable: true },
{
key: "params",
label: "Params",
render: (value) => (
<span className="max-w-[200px] truncate block font-mono text-sm">
{String(value) || "-"}
</span>
),
},
{
key: "timestamp",
label: "Time",
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
];
interface CommandsTableProps { interface CommandsTableProps {
data: PaginatedResult<CommandLogRow>; data: PaginatedResult<CommandLogRow>;
} }
export function CommandsTable({ data }: CommandsTableProps) { export function CommandsTable({ data }: CommandsTableProps) {
const t = useTranslations("pages.admin.logs");
const columns: DataTableColumn<CommandLogRow>[] = [
{ key: "id", label: t("colId"), sortable: true },
{ key: "username", label: t("colUser") },
{ key: "command", label: t("commands.colCommand"), sortable: true },
{
key: "params",
label: t("commands.colParams"),
render: (value) => (
<span className="max-w-[200px] truncate block font-mono text-sm">
{String(value) || t("emptyValue")}
</span>
),
},
{
key: "timestamp",
label: t("colTime"),
sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
},
];
return ( return (
<DataTable <DataTable
data={data} data={data}
columns={columns} columns={columns}
searchPlaceholder="Search by username, command, or params..." searchPlaceholder={t("commands.searchPlaceholder")}
exportUrl="/api/admin/export?type=commands" exportUrl="/api/admin/export?type=commands"
/> />
); );
+1 -1
View File
@@ -88,7 +88,7 @@ export default async function AdminStaffActivities({
when: fromDate(a.createdAt), when: fromDate(a.createdAt),
staff: t("staffWithId", { staff: t("staffWithId", {
id: Number(a.userId), id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown", name: nameById.get(Number(a.userId)) ?? t("unknownUser"),
}), }),
action: a.action, action: a.action,
description: a.description, description: a.description,
+17 -12
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table"; import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { formatTimestamp } from "@/lib/admin-helpers"; import { formatTimestamp } from "@/lib/admin-helpers";
@@ -13,13 +14,20 @@ interface TradeLogRow {
timestamp: number; timestamp: number;
} }
interface TradesTableProps {
data: PaginatedResult<TradeLogRow>;
}
export function TradesTable({ data }: TradesTableProps) {
const t = useTranslations("pages.admin.logs");
const columns: DataTableColumn<TradeLogRow>[] = [ const columns: DataTableColumn<TradeLogRow>[] = [
{ key: "id", label: "ID", sortable: true }, { key: "id", label: t("colId"), sortable: true },
{ key: "userOneName", label: "User One" }, { key: "userOneName", label: t("trades.colUserOne") },
{ key: "userTwoName", label: "User Two" }, { key: "userTwoName", label: t("trades.colUserTwo") },
{ {
key: "status", key: "status",
label: "Status", label: t("trades.colStatus"),
sortable: true, sortable: true,
render: (value) => { render: (value) => {
const v = String(value); const v = String(value);
@@ -29,27 +37,24 @@ const columns: DataTableColumn<TradeLogRow>[] = [
: v === "cancelled" : v === "cancelled"
? "destructive" ? "destructive"
: "secondary"; : "secondary";
return <Badge variant={variant}>{v || "unknown"}</Badge>; return (
<Badge variant={variant}>{v || t("trades.statusUnknown")}</Badge>
);
}, },
}, },
{ {
key: "timestamp", key: "timestamp",
label: "Time", label: t("colTime"),
sortable: true, sortable: true,
render: (value) => <span>{formatTimestamp(Number(value))}</span>, render: (value) => <span>{formatTimestamp(Number(value))}</span>,
}, },
]; ];
interface TradesTableProps {
data: PaginatedResult<TradeLogRow>;
}
export function TradesTable({ data }: TradesTableProps) {
return ( return (
<DataTable <DataTable
data={data} data={data}
columns={columns} columns={columns}
searchPlaceholder="Search by username..." searchPlaceholder={t("trades.searchPlaceholder")}
/> />
); );
} }
+16 -10
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Tooltip, Tooltip,
@@ -14,7 +15,7 @@ interface PeakHoursData {
max: number; max: number;
} }
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const DAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
const HOURS = Array.from({ length: 24 }, (_, i) => i); const HOURS = Array.from({ length: 24 }, (_, i) => i);
function getColor(value: number, max: number) { function getColor(value: number, max: number) {
@@ -28,13 +29,14 @@ function getColor(value: number, max: number) {
} }
export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) { export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
const t = useTranslations("pages.admin.analytics.peakHours");
const days = DAY_KEYS.map((key) => t(`days.${key}`));
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Peak Hours</CardTitle> <CardTitle className="text-sm font-medium">{t("title")}</CardTitle>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{t("description")}</p>
User activity by day &amp; hour (last 7 days)
</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -51,8 +53,8 @@ export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
))} ))}
</div> </div>
{/* Rows */} {/* Rows */}
{DAYS.map((day, dayIdx) => ( {days.map((day, dayIdx) => (
<div key={day} className="flex items-center gap-px mb-px"> <div key={DAY_KEYS[dayIdx]} className="flex items-center gap-px mb-px">
<span className="w-10 text-xs text-muted-foreground text-right pr-2 shrink-0"> <span className="w-10 text-xs text-muted-foreground text-right pr-2 shrink-0">
{day} {day}
</span> </span>
@@ -67,7 +69,7 @@ export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="text-xs"> <TooltipContent side="top" className="text-xs">
{day} {hour}:00 — {count} users {t("tooltip", { day, hour, count })}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
@@ -77,13 +79,17 @@ export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
))} ))}
{/* Legend */} {/* Legend */}
<div className="flex items-center gap-2 mt-3 ml-10"> <div className="flex items-center gap-2 mt-3 ml-10">
<span className="text-[10px] text-muted-foreground">Less</span> <span className="text-[10px] text-muted-foreground">
{t("less")}
</span>
<div className="h-3 w-3 rounded-sm bg-muted" /> <div className="h-3 w-3 rounded-sm bg-muted" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/20" /> <div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/20" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/55" /> <div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/55" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)] dark:bg-[var(--admin-success)]" /> <div className="h-3 w-3 rounded-sm bg-[var(--admin-success)] dark:bg-[var(--admin-success)]" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success-strong,var(--admin-success))]" /> <div className="h-3 w-3 rounded-sm bg-[var(--admin-success-strong,var(--admin-success))]" />
<span className="text-[10px] text-muted-foreground">More</span> <span className="text-[10px] text-muted-foreground">
{t("more")}
</span>
</div> </div>
</div> </div>
</div> </div>
+7 -3
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface RevenueDataPoint { interface RevenueDataPoint {
@@ -9,6 +10,7 @@ interface RevenueDataPoint {
} }
export function RevenueChart({ data }: { data: RevenueDataPoint[] }) { export function RevenueChart({ data }: { data: RevenueDataPoint[] }) {
const t = useTranslations("pages.admin.analytics.economy");
const max = Math.max( const max = Math.max(
1, 1,
...data.flatMap((point) => [point.credits, point.points]), ...data.flatMap((point) => [point.credits, point.points]),
@@ -16,7 +18,9 @@ export function RevenueChart({ data }: { data: RevenueDataPoint[] }) {
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Catalog Spending</CardTitle> <CardTitle className="text-sm font-medium">
{t("catalogSpendingTitle")}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
@@ -32,14 +36,14 @@ export function RevenueChart({ data }: { data: RevenueDataPoint[] }) {
style={{ style={{
width: `${Math.max(2, (point.credits / max) * 100)}%`, width: `${Math.max(2, (point.credits / max) * 100)}%`,
}} }}
title={`${point.credits} credits`} title={t("creditsTooltip", { count: point.credits })}
/> />
<div <div
className="h-2 rounded bg-[var(--admin-accent)]" className="h-2 rounded bg-[var(--admin-accent)]"
style={{ style={{
width: `${Math.max(2, (point.points / max) * 100)}%`, width: `${Math.max(2, (point.points / max) * 100)}%`,
}} }}
title={`${point.points} points`} title={t("pointsTooltip", { count: point.points })}
/> />
</div> </div>
</div> </div>
+104 -1
View File
File diff suppressed because it is too large. Load diff
+104 -1
View File
File diff suppressed because it is too large. Load diff
+104 -1
View File
File diff suppressed because it is too large. Load diff