Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
This reverts commit488b6e57c4, reversing changes made tob506b4499a.
This commit is contained in:
1 parent
488b6e57c4
commit
b1ddda66ff
802 files changed
+61370
-76659
No files matched your search
@@ -0,0 +1,237 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
type ListParamsInput = {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
defaultPerPage?: number;
|
||||
};
|
||||
|
||||
async function requireLogsView() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
}
|
||||
|
||||
function parsePageParams(
|
||||
rawParams: Record<string, string>,
|
||||
defaultPerPage: number,
|
||||
) {
|
||||
const sp = new URLSearchParams(rawParams);
|
||||
const parsed = parseListParams(sp);
|
||||
const perPage = Number(rawParams.perPage) || defaultPerPage;
|
||||
return { search: parsed.search.trim(), page: parsed.page, perPage };
|
||||
}
|
||||
|
||||
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
||||
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
export type ChatLogListRow = {
|
||||
id: number;
|
||||
username: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
roomId: number;
|
||||
};
|
||||
|
||||
export async function loadChatLogList({
|
||||
searchParams,
|
||||
defaultPerPage = 50,
|
||||
}: ListParamsInput) {
|
||||
await requireLogsView();
|
||||
|
||||
const rawParams = await searchParams;
|
||||
const { search, page, perPage } = parsePageParams(rawParams, defaultPerPage);
|
||||
const offset = (page - 1) * perPage;
|
||||
const like = `%${search}%`;
|
||||
|
||||
type RawRow = {
|
||||
roomId: number;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
const [rawRowsResult, countRows] = search
|
||||
? await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username
|
||||
FROM chatlogs_room c
|
||||
LEFT JOIN users u ON u.id = c.user_from_id
|
||||
WHERE c.message LIKE ${like} OR u.username LIKE ${like}
|
||||
ORDER BY c.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM chatlogs_room c
|
||||
LEFT JOIN users u ON u.id = c.user_from_id
|
||||
WHERE c.message LIKE ${like} OR u.username LIKE ${like}`),
|
||||
])
|
||||
: await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT c.room_id AS roomId, c.message, c.timestamp, u.username AS username
|
||||
FROM chatlogs_room c
|
||||
LEFT JOIN users u ON u.id = c.user_from_id
|
||||
ORDER BY c.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count FROM chatlogs_room`),
|
||||
]);
|
||||
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows: ChatLogListRow[] = rawRowsResult.map((row, index) => ({
|
||||
id: pagination.offset + index + 1,
|
||||
username: row.username ?? "-",
|
||||
message: row.message,
|
||||
timestamp: row.timestamp,
|
||||
roomId: row.roomId,
|
||||
}));
|
||||
|
||||
return { rows, ...pagination };
|
||||
}
|
||||
|
||||
export type CommandLogListRow = {
|
||||
id: number;
|
||||
username: string;
|
||||
command: string;
|
||||
params: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export async function loadCommandLogList({
|
||||
searchParams,
|
||||
defaultPerPage = 50,
|
||||
}: ListParamsInput) {
|
||||
await requireLogsView();
|
||||
|
||||
const rawParams = await searchParams;
|
||||
const { search, page, perPage } = parsePageParams(rawParams, defaultPerPage);
|
||||
const offset = (page - 1) * perPage;
|
||||
const like = `%${search}%`;
|
||||
|
||||
type RawRow = {
|
||||
command: string;
|
||||
params: string;
|
||||
timestamp: number;
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
const [rawRowsResult, countRows] = search
|
||||
? await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT c.command, c.params, c.timestamp, u.username AS username
|
||||
FROM commandlogs c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.command LIKE ${like}
|
||||
OR c.params LIKE ${like}
|
||||
OR u.username LIKE ${like}
|
||||
ORDER BY c.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM commandlogs c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.command LIKE ${like}
|
||||
OR c.params LIKE ${like}
|
||||
OR u.username LIKE ${like}`),
|
||||
])
|
||||
: await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT c.command, c.params, c.timestamp, u.username AS username
|
||||
FROM commandlogs c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
ORDER BY c.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count FROM commandlogs`),
|
||||
]);
|
||||
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows: CommandLogListRow[] = rawRowsResult.map((row, index) => ({
|
||||
id: pagination.offset + index + 1,
|
||||
username: row.username ?? "-",
|
||||
command: row.command,
|
||||
params: row.params,
|
||||
timestamp: row.timestamp,
|
||||
}));
|
||||
|
||||
return { rows, ...pagination };
|
||||
}
|
||||
|
||||
export type TradeLogListRow = {
|
||||
id: number;
|
||||
userOneName: string;
|
||||
userTwoName: string;
|
||||
status: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export async function loadTradeLogList({
|
||||
searchParams,
|
||||
defaultPerPage = 50,
|
||||
}: ListParamsInput) {
|
||||
await requireLogsView();
|
||||
|
||||
const rawParams = await searchParams;
|
||||
const { search, page, perPage } = parsePageParams(rawParams, defaultPerPage);
|
||||
const offset = (page - 1) * perPage;
|
||||
const like = `%${search}%`;
|
||||
|
||||
type RawRow = {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
userOneName: string | null;
|
||||
userTwoName: string | null;
|
||||
};
|
||||
|
||||
const [rawRowsResult, countRows] = search
|
||||
? await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT t.id, t.timestamp,
|
||||
u1.username AS userOneName, u2.username AS userTwoName
|
||||
FROM room_trade_log t
|
||||
LEFT JOIN users u1 ON u1.id = t.user_one_id
|
||||
LEFT JOIN users u2 ON u2.id = t.user_two_id
|
||||
WHERE u1.username LIKE ${like} OR u2.username LIKE ${like}
|
||||
ORDER BY t.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM room_trade_log t
|
||||
LEFT JOIN users u1 ON u1.id = t.user_one_id
|
||||
LEFT JOIN users u2 ON u2.id = t.user_two_id
|
||||
WHERE u1.username LIKE ${like} OR u2.username LIKE ${like}`),
|
||||
])
|
||||
: await Promise.all([
|
||||
rawRows<RawRow>(sql`
|
||||
SELECT t.id, t.timestamp,
|
||||
u1.username AS userOneName, u2.username AS userTwoName
|
||||
FROM room_trade_log t
|
||||
LEFT JOIN users u1 ON u1.id = t.user_one_id
|
||||
LEFT JOIN users u2 ON u2.id = t.user_two_id
|
||||
ORDER BY t.timestamp DESC
|
||||
LIMIT ${perPage} OFFSET ${offset}`),
|
||||
rawRows<{ count: bigint }>(sql`
|
||||
SELECT COUNT(*) AS count FROM room_trade_log`),
|
||||
]);
|
||||
|
||||
const total = Number(countRows[0]?.count ?? 0);
|
||||
const pagination = calcPagination(total, page, perPage);
|
||||
const rows: TradeLogListRow[] = rawRowsResult.map((row) => ({
|
||||
id: row.id,
|
||||
userOneName: row.userOneName ?? "-",
|
||||
userTwoName: row.userTwoName ?? "-",
|
||||
// room_trade_log only stores completed trades; no status column exists
|
||||
status: "completed",
|
||||
timestamp: row.timestamp,
|
||||
}));
|
||||
|
||||
return { rows, ...pagination };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface AuditRow {
|
||||
id: number;
|
||||
username: string;
|
||||
action: string;
|
||||
target: string;
|
||||
targetId: number | null;
|
||||
diff: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function DiffCell({ diff }: { diff: string | null }) {
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!diff)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
let parsed: Record<string, { from: unknown; to: unknown }>;
|
||||
try {
|
||||
parsed = JSON.parse(diff);
|
||||
} catch {
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length === 0)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
const summary =
|
||||
keys.length === 1
|
||||
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
|
||||
: t("audit.changesCount", { count: keys.length });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs font-normal"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{summary}
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="mt-1 space-y-0.5 text-xs">
|
||||
{keys.map((k) => (
|
||||
<div key={k} className="flex gap-1">
|
||||
<span className="font-medium">{k}:</span>
|
||||
<span className="text-[var(--admin-error)] line-through">
|
||||
{String(parsed[k].from ?? "∅")}
|
||||
</span>
|
||||
<span>→</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{String(parsed[k].to ?? "∅")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuditTableProps {
|
||||
data: PaginatedResult<AuditRow>;
|
||||
}
|
||||
|
||||
export function AuditTable({ data }: AuditTableProps) {
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
|
||||
const columns: DataTableColumn<AuditRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{ key: "username", label: t("colUser"), sortable: true },
|
||||
{
|
||||
key: "action",
|
||||
label: t("colAction"),
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const v = String(value);
|
||||
let variant: "default" | "secondary" | "destructive" = "secondary";
|
||||
if (v.includes("create") || v.includes("add")) variant = "default";
|
||||
if (v.includes("delete") || v.includes("remove") || v.includes("ban"))
|
||||
variant = "destructive";
|
||||
return <Badge variant={variant}>{v}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: "target", label: t("colTarget"), sortable: true },
|
||||
{
|
||||
key: "targetId",
|
||||
label: t("audit.colTargetId"),
|
||||
render: (value) => (
|
||||
<span>{value != null ? String(value) : t("emptyValue")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "diff",
|
||||
label: t("audit.colDetails"),
|
||||
render: (_value, row) => <DiffCell diff={row.diff} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: t("audit.colDate"),
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span>{String(value) || t("audit.notAvailable")}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||
exportUrl="/api/admin/export?type=audit"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function AuditLogsLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }, (_, i) => i).map((n) => (
|
||||
<div
|
||||
key={`skeleton-row-${n}`}
|
||||
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { getAuditLogs } from "@/lib/services/audit";
|
||||
import { AuditTable } from "./audit-table";
|
||||
|
||||
export default async function AuditLogsPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
await paramsPromise;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const sp = new URLSearchParams(params);
|
||||
const { search, perPage, page } = parseListParams(sp);
|
||||
|
||||
const result = await getAuditLogs({ search, page, perPage });
|
||||
|
||||
const pagination = calcPagination(result.total, result.page, result.perPage);
|
||||
|
||||
const rows = result.rows.map((r) => ({
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
action: r.action,
|
||||
target: r.target,
|
||||
targetId: r.targetId,
|
||||
diff: r.diff,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AuditTable data={{ rows, ...pagination }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface ChatLogRow {
|
||||
id: number;
|
||||
username: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
roomId: number;
|
||||
}
|
||||
|
||||
interface ChatTableProps {
|
||||
data: PaginatedResult<ChatLogRow>;
|
||||
}
|
||||
|
||||
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>{formatDate(new Date(Number(value) * 1000))}</span>
|
||||
),
|
||||
},
|
||||
{ key: "roomId", label: t("chat.colRoomId"), sortable: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("chat.searchPlaceholder")}
|
||||
exportUrl="/api/admin/export?type=chatlogs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function ChatLogsLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }, (_, i) => i).map((n) => (
|
||||
<div
|
||||
key={`skeleton-row-${n}`}
|
||||
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { loadChatLogList } from "../_lib/load-ignored-logs";
|
||||
import { ChatTable } from "./chat-table";
|
||||
|
||||
export default async function ChatLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const result = await loadChatLogList({ searchParams, defaultPerPage: 50 });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ChatTable
|
||||
data={{
|
||||
rows: result.rows,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface CommandLogRow {
|
||||
id: number;
|
||||
username: string;
|
||||
command: string;
|
||||
params: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CommandsTableProps {
|
||||
data: PaginatedResult<CommandLogRow>;
|
||||
}
|
||||
|
||||
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>{formatDate(new Date(Number(value) * 1000))}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("commands.searchPlaceholder")}
|
||||
exportUrl="/api/admin/export?type=commands"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function CommandLogsLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }, (_, i) => i).map((n) => (
|
||||
<div
|
||||
key={`skeleton-row-${n}`}
|
||||
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { loadCommandLogList } from "../_lib/load-ignored-logs";
|
||||
import { CommandsTable } from "./commands-table";
|
||||
|
||||
export default async function CommandLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const result = await loadCommandLogList({ searchParams, defaultPerPage: 50 });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<CommandsTable
|
||||
data={{
|
||||
rows: result.rows,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
inArray,
|
||||
like,
|
||||
type SQL,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db, StaffActivities, User } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { StaffActivitiesTable } from "./staff-activities-table";
|
||||
|
||||
const SORT_MAP = {
|
||||
when: StaffActivities.createdAt,
|
||||
staff: StaffActivities.userId,
|
||||
action: StaffActivities.action,
|
||||
} as const;
|
||||
|
||||
export default async function AdminStaffActivities({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.logs");
|
||||
const raw = await searchParams;
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const authorizationOnly = raw.filter_authorization === "1";
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
if (parsed.search.trim()) {
|
||||
const q = parsed.search.trim();
|
||||
conditions.push(
|
||||
sql`(${like(StaffActivities.action, `%${q}%`)} OR ${like(StaffActivities.description, `%${q}%`)} OR ${like(StaffActivities.ipAddress, `%${q}%`)})`,
|
||||
);
|
||||
}
|
||||
if (authorizationOnly) {
|
||||
conditions.push(like(StaffActivities.action, "permission.%"));
|
||||
}
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const sortCol =
|
||||
SORT_MAP[(parsed.sort ?? "") as keyof typeof SORT_MAP] ??
|
||||
StaffActivities.createdAt;
|
||||
const orderBy = parsed.order === "asc" ? asc(sortCol) : desc(sortCol);
|
||||
|
||||
const [totalRows] = await db
|
||||
.select({ total: count() })
|
||||
.from(StaffActivities)
|
||||
.where(where)
|
||||
.catch(() => [{ total: 0 }]);
|
||||
const total = Number(totalRows?.total ?? 0);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
const activities = await db
|
||||
.select({
|
||||
id: StaffActivities.id,
|
||||
userId: StaffActivities.userId,
|
||||
action: StaffActivities.action,
|
||||
description: StaffActivities.description,
|
||||
targetType: StaffActivities.targetType,
|
||||
targetId: StaffActivities.targetId,
|
||||
ipAddress: StaffActivities.ipAddress,
|
||||
createdAt: StaffActivities.createdAt,
|
||||
})
|
||||
.from(StaffActivities)
|
||||
.where(where)
|
||||
.orderBy(orderBy)
|
||||
.limit(pagination.perPage)
|
||||
.offset(pagination.offset)
|
||||
.catch(() => []);
|
||||
|
||||
const staffIds = Array.from(
|
||||
new Set(activities.map((a) => Number(a.userId))),
|
||||
).filter((n) => n > 0);
|
||||
let nameById = new Map<number, string>();
|
||||
if (staffIds.length) {
|
||||
const users = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(inArray(User.id, staffIds))
|
||||
.catch(() => []);
|
||||
nameById = new Map(users.map((x) => [x.id, x.username]));
|
||||
}
|
||||
|
||||
const recentCount = activities.filter(
|
||||
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60_000,
|
||||
).length;
|
||||
|
||||
const rows = activities.map((a) => ({
|
||||
id: Number(a.id),
|
||||
when: formatDate(a.createdAt),
|
||||
staff: t("staffWithId", {
|
||||
id: Number(a.userId),
|
||||
name: nameById.get(Number(a.userId)) ?? t("unknownUser"),
|
||||
}),
|
||||
action: a.action,
|
||||
description: a.description,
|
||||
target:
|
||||
a.targetType && a.targetId
|
||||
? `${a.targetType === "user" ? t("userPrefix") : a.targetType} #${String(a.targetId)}`
|
||||
: "—",
|
||||
ip: a.ipAddress || "—",
|
||||
}));
|
||||
|
||||
return (
|
||||
<AdminPageShell
|
||||
icon={ClipboardList}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
|
||||
<StatusCard
|
||||
label={t("onThisPage")}
|
||||
value={activities.length}
|
||||
icon="📄"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("recent")}
|
||||
value={recentCount}
|
||||
state={recentCount > 0 ? "ok" : "neutral"}
|
||||
icon="🟢"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StaffActivitiesTable
|
||||
data={{
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
export interface StaffActivityRow {
|
||||
id: number;
|
||||
when: string;
|
||||
staff: string;
|
||||
action: string;
|
||||
description: string;
|
||||
target: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export function StaffActivitiesTable({
|
||||
data,
|
||||
}: {
|
||||
data: PaginatedResult<StaffActivityRow>;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
|
||||
const columns: DataTableColumn<StaffActivityRow>[] = [
|
||||
{ key: "when", label: t("colWhen"), sortable: true },
|
||||
{ key: "staff", label: t("colStaff"), sortable: true },
|
||||
{
|
||||
key: "action",
|
||||
label: t("colAction"),
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<Badge variant="secondary" className="font-mono text-[0.7rem]">
|
||||
{String(value)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: "description", label: t("colDescription") },
|
||||
{ key: "target", label: t("colTarget") },
|
||||
{ key: "ip", label: t("colIp") },
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
presets={[
|
||||
{
|
||||
label: t("authorizationErrors"),
|
||||
params: { filter_authorization: "1" },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function TradeLogsLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-64 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
{Array.from({ length: 8 }, (_, i) => i).map((n) => (
|
||||
<div
|
||||
key={`skeleton-row-${n}`}
|
||||
className="flex items-center gap-6 px-4 py-3 border-b last:border-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { loadTradeLogList } from "../_lib/load-ignored-logs";
|
||||
import { TradesTable } from "./trades-table";
|
||||
|
||||
export default async function TradeLogsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const result = await loadTradeLogList({ searchParams, defaultPerPage: 50 });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TradesTable
|
||||
data={{
|
||||
rows: result.rows,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
interface TradeLogRow {
|
||||
id: number;
|
||||
userOneName: string;
|
||||
userTwoName: string;
|
||||
status: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface TradesTableProps {
|
||||
data: PaginatedResult<TradeLogRow>;
|
||||
}
|
||||
|
||||
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>{formatDate(new Date(Number(value) * 1000))}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("trades.searchPlaceholder")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user