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
Local Build and Deploy / deploy (push) Successful in 59s
Reviewed-on: #10
This commit is contained in:
commit
022fbfa223
13 files changed
+576
-211
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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<ChatLogRow>[] = [
|
|
||||||
{ key: "id", label: "ID", sortable: true },
|
|
||||||
{ key: "username", label: "User" },
|
|
||||||
{
|
|
||||||
key: "message",
|
|
||||||
label: "Message",
|
|
||||||
render: (value) => (
|
|
||||||
<span className="max-w-[300px] truncate block">{String(value)}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "timestamp",
|
|
||||||
label: "Time",
|
|
||||||
sortable: true,
|
|
||||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
|
||||||
},
|
|
||||||
{ key: "roomId", label: "Room ID", sortable: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ChatTableProps {
|
interface ChatTableProps {
|
||||||
data: PaginatedResult<ChatLogRow>;
|
data: PaginatedResult<ChatLogRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatTable({ data }: ChatTableProps) {
|
export function ChatTable({ data }: ChatTableProps) {
|
||||||
|
const t = useTranslations("pages.admin.logs");
|
||||||
|
|
||||||
|
const columns: DataTableColumn<ChatLogRow>[] = [
|
||||||
|
{ key: "id", label: t("colId"), sortable: true },
|
||||||
|
{ key: "username", label: t("colUser") },
|
||||||
|
{
|
||||||
|
key: "message",
|
||||||
|
label: t("chat.colMessage"),
|
||||||
|
render: (value) => (
|
||||||
|
<span className="max-w-[300px] truncate block">{String(value)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "timestamp",
|
||||||
|
label: t("colTime"),
|
||||||
|
sortable: true,
|
||||||
|
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
||||||
|
},
|
||||||
|
{ key: "roomId", label: t("chat.colRoomId"), sortable: true },
|
||||||
|
];
|
||||||
|
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,43 +14,47 @@ interface TradeLogRow {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: DataTableColumn<TradeLogRow>[] = [
|
|
||||||
{ key: "id", label: "ID", sortable: true },
|
|
||||||
{ key: "userOneName", label: "User One" },
|
|
||||||
{ key: "userTwoName", label: "User Two" },
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
label: "Status",
|
|
||||||
sortable: true,
|
|
||||||
render: (value) => {
|
|
||||||
const v = String(value);
|
|
||||||
const variant =
|
|
||||||
v === "completed"
|
|
||||||
? "default"
|
|
||||||
: v === "cancelled"
|
|
||||||
? "destructive"
|
|
||||||
: "secondary";
|
|
||||||
return <Badge variant={variant}>{v || "unknown"}</Badge>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "timestamp",
|
|
||||||
label: "Time",
|
|
||||||
sortable: true,
|
|
||||||
render: (value) => <span>{formatTimestamp(Number(value))}</span>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface TradesTableProps {
|
interface TradesTableProps {
|
||||||
data: PaginatedResult<TradeLogRow>;
|
data: PaginatedResult<TradeLogRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TradesTable({ data }: TradesTableProps) {
|
export function TradesTable({ data }: TradesTableProps) {
|
||||||
|
const t = useTranslations("pages.admin.logs");
|
||||||
|
|
||||||
|
const columns: DataTableColumn<TradeLogRow>[] = [
|
||||||
|
{ key: "id", label: t("colId"), sortable: true },
|
||||||
|
{ key: "userOneName", label: t("trades.colUserOne") },
|
||||||
|
{ key: "userTwoName", label: t("trades.colUserTwo") },
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: t("trades.colStatus"),
|
||||||
|
sortable: true,
|
||||||
|
render: (value) => {
|
||||||
|
const v = String(value);
|
||||||
|
const variant =
|
||||||
|
v === "completed"
|
||||||
|
? "default"
|
||||||
|
: v === "cancelled"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary";
|
||||||
|
return (
|
||||||
|
<Badge variant={variant}>{v || t("trades.statusUnknown")}</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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..."
|
searchPlaceholder={t("trades.searchPlaceholder")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+104
-1
File diff suppressed because it is too large.
Load diff
+104
-1
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user