feat(admin): analytics redis cache, shared ops health, ticket queue clarity
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
474de0717b
commit
58fae1f90f
38 files changed
+348
-245
No files matched your search
@@ -5,6 +5,7 @@ import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
type DayRow = {
|
||||
label: string;
|
||||
@@ -192,7 +193,9 @@ export default async function ActivityPage() {
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("pages.admin.analytics.activity");
|
||||
const tRoot = await getTranslations("pages.admin.analytics");
|
||||
const data = await getActivityData(locale);
|
||||
const data = await redisCache(`admin:analytics:activity:${locale}`, 120, () =>
|
||||
getActivityData(locale),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
type EconomyData = {
|
||||
totalCredits: number;
|
||||
@@ -172,7 +173,11 @@ export default async function EconomyPage() {
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("pages.admin.analytics.economy");
|
||||
const tRoot = await getTranslations("pages.admin.analytics");
|
||||
const { data, degraded } = await getEconomyData(locale);
|
||||
const { data, degraded } = await redisCache(
|
||||
`admin:analytics:economy:${locale}`,
|
||||
120,
|
||||
() => getEconomyData(locale),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { StatsCard } from "@/components/admin/stats-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redisCache } from "@/lib/redis-cache";
|
||||
|
||||
type AnalyticsData = {
|
||||
totalUsers: number;
|
||||
@@ -202,7 +203,11 @@ export default async function AnalyticsPage() {
|
||||
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations("pages.admin.analytics");
|
||||
const { data, degraded } = await getAnalyticsData(locale);
|
||||
const { data, degraded } = await redisCache(
|
||||
`admin:analytics:overview:${locale}`,
|
||||
120,
|
||||
() => getAnalyticsData(locale),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
OnlineUsersWidget,
|
||||
StatusCard,
|
||||
} from "@/components/admin/dashboard";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { fetchOpsOnlineUsers } from "@/lib/admin/ops-online-users";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { CommandocentrumControls } from "./commandocentrum-controls";
|
||||
|
||||
@@ -46,33 +46,25 @@ export default async function CommandoCentrum() {
|
||||
|
||||
const t = await getTranslations("pages.admin.commandocentrum");
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
errors,
|
||||
dbOk,
|
||||
emulatorOnline,
|
||||
maintenance,
|
||||
activity,
|
||||
onlineRoster,
|
||||
] = await Promise.all([
|
||||
prisma.user.count().catch(() => -1),
|
||||
prisma.emulatorErrors
|
||||
.findMany({ orderBy: { timestamp: "desc" }, take: 50 })
|
||||
.catch(() => []),
|
||||
prisma.websiteSetting
|
||||
.count()
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
rcon.send("ping", null).catch(() => false),
|
||||
siteSettings.getBool("maintenance_enabled", false),
|
||||
prisma.staffActivities
|
||||
.findMany({ orderBy: { createdAt: "desc" }, take: 12 })
|
||||
.catch(
|
||||
() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
|
||||
),
|
||||
fetchOpsOnlineUsers(40),
|
||||
]);
|
||||
const [totalUsers, errors, health, maintenance, activity, onlineRoster] =
|
||||
await Promise.all([
|
||||
prisma.user.count().catch(() => -1),
|
||||
prisma.emulatorErrors
|
||||
.findMany({ orderBy: { timestamp: "desc" }, take: 50 })
|
||||
.catch(() => []),
|
||||
fetchOpsHealth(),
|
||||
siteSettings.getBool("maintenance_enabled", false),
|
||||
prisma.staffActivities
|
||||
.findMany({ orderBy: { createdAt: "desc" }, take: 12 })
|
||||
.catch(
|
||||
() =>
|
||||
[] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
|
||||
),
|
||||
fetchOpsOnlineUsers(40),
|
||||
]);
|
||||
|
||||
const dbOk = health.dbOk;
|
||||
const emulatorOnline = health.emulatorOk;
|
||||
const onlineCount = onlineRoster.count;
|
||||
const onlineUsers = onlineRoster.users;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { HealthCheckClient } from "./health-check-client";
|
||||
@@ -12,42 +13,27 @@ async function getDevOpsData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const dayAgo = now - 86400;
|
||||
|
||||
// DB health — simple count query
|
||||
let dbStatus: "healthy" | "error" = "healthy";
|
||||
let dbLatency = 0;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await prisma.user.count({ take: 1 });
|
||||
dbLatency = Date.now() - start;
|
||||
} catch {
|
||||
dbStatus = "error";
|
||||
}
|
||||
|
||||
// Recent emulator errors
|
||||
const recentErrors = await prisma.emulatorErrors.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
const errorsToday = await prisma.emulatorErrors.count({
|
||||
where: { timestamp: { gte: dayAgo } },
|
||||
});
|
||||
|
||||
// Emulator settings (version info)
|
||||
const emuVersion = await prisma.emulatorSettings.findFirst({
|
||||
where: { key: "version" },
|
||||
});
|
||||
|
||||
// Online user count as proxy for emulator health
|
||||
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
||||
const [health, recentErrors, errorsToday, emuVersion] = await Promise.all([
|
||||
fetchOpsHealth(),
|
||||
prisma.emulatorErrors.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 20,
|
||||
}),
|
||||
prisma.emulatorErrors.count({
|
||||
where: { timestamp: { gte: dayAgo } },
|
||||
}),
|
||||
prisma.emulatorSettings.findFirst({
|
||||
where: { key: "version" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
dbStatus,
|
||||
dbLatency,
|
||||
dbStatus: health.dbOk ? ("healthy" as const) : ("error" as const),
|
||||
dbLatency: health.dbLatencyMs,
|
||||
recentErrors,
|
||||
errorsToday,
|
||||
emuVersion: emuVersion?.value ?? "Unknown",
|
||||
onlineUsers,
|
||||
onlineUsers: health.onlineUsers,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -17,6 +19,7 @@ export default async function AdminHelpTicketDetailPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.helpTickets");
|
||||
const { id } = await params;
|
||||
const ticketId = positiveBigInt(id);
|
||||
if (!ticketId) notFound();
|
||||
@@ -103,24 +106,31 @@ export default async function AdminHelpTicketDetailPage({
|
||||
const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||
|
||||
return (
|
||||
<AdminHelpTicketDetail
|
||||
ticket={{
|
||||
id: String(ticket.id),
|
||||
title: ticket.title,
|
||||
open: ticket.open,
|
||||
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
||||
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
||||
creator: creator
|
||||
? {
|
||||
id: creator.id,
|
||||
username: creator.username,
|
||||
rank: creator.rank,
|
||||
mail: creator.mail ?? "",
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
messages={messages}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/admin/tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
/>
|
||||
<AdminHelpTicketDetail
|
||||
ticket={{
|
||||
id: String(ticket.id),
|
||||
title: ticket.title,
|
||||
open: ticket.open,
|
||||
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
||||
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
||||
creator: creator
|
||||
? {
|
||||
id: creator.id,
|
||||
username: creator.username,
|
||||
rank: creator.rank,
|
||||
mail: creator.mail ?? "",
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
messages={messages}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -82,7 +83,7 @@ export default async function AdminHelpTicketsPage({
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
||||
|
||||
const [total, openCount, closedCount] = await Promise.all([
|
||||
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||
prisma.websiteHelpCenterTickets
|
||||
.count({ where: { open: true } })
|
||||
@@ -90,6 +91,7 @@ export default async function AdminHelpTicketsPage({
|
||||
prisma.websiteHelpCenterTickets
|
||||
.count({ where: { open: false } })
|
||||
.catch(() => 0),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
@@ -165,6 +167,10 @@ export default async function AdminHelpTicketsPage({
|
||||
hint={t("queueHint")}
|
||||
otherHref="/admin/tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.helpOpen,
|
||||
other: queues.cmsOpen,
|
||||
})}
|
||||
/>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -14,6 +16,7 @@ export default async function AdminTicketDetailPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.tickets");
|
||||
const { id } = await params;
|
||||
const ticketId = Number(id);
|
||||
if (Number.isNaN(ticketId)) notFound();
|
||||
@@ -57,43 +60,50 @@ export default async function AdminTicketDetailPage({
|
||||
const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||
|
||||
return (
|
||||
<AdminTicketDetail
|
||||
ticket={{
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
category: ticket.category,
|
||||
priority: ticket.priority,
|
||||
status: ticket.status,
|
||||
createdAt: ticket.createdAt.toISOString(),
|
||||
closedAt: ticket.closedAt?.toISOString() ?? null,
|
||||
creator: {
|
||||
id: ticket.creator.id,
|
||||
username: ticket.creator.username,
|
||||
look: ticket.creator.look,
|
||||
rank: ticket.creator.rank,
|
||||
mail: ticket.creator.mail ?? "",
|
||||
ipRegister: ticket.creator.ipRegister,
|
||||
accountCreated: ticket.creator.accountCreated,
|
||||
},
|
||||
assignee: ticket.assignee
|
||||
? { id: ticket.assignee.id, username: ticket.assignee.username }
|
||||
: null,
|
||||
}}
|
||||
messages={ticket.messages.map((m) => ({
|
||||
id: m.id,
|
||||
message: m.message,
|
||||
isStaff: m.isStaff === 1,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
user: {
|
||||
id: m.user.id,
|
||||
username: m.user.username,
|
||||
look: m.user.look,
|
||||
rank: m.user.rank,
|
||||
},
|
||||
}))}
|
||||
staffMembers={staffMembers}
|
||||
currentUserId={session.user.id}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/admin/help-tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
/>
|
||||
<AdminTicketDetail
|
||||
ticket={{
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
category: ticket.category,
|
||||
priority: ticket.priority,
|
||||
status: ticket.status,
|
||||
createdAt: ticket.createdAt.toISOString(),
|
||||
closedAt: ticket.closedAt?.toISOString() ?? null,
|
||||
creator: {
|
||||
id: ticket.creator.id,
|
||||
username: ticket.creator.username,
|
||||
look: ticket.creator.look,
|
||||
rank: ticket.creator.rank,
|
||||
mail: ticket.creator.mail ?? "",
|
||||
ipRegister: ticket.creator.ipRegister,
|
||||
accountCreated: ticket.creator.accountCreated,
|
||||
},
|
||||
assignee: ticket.assignee
|
||||
? { id: ticket.assignee.id, username: ticket.assignee.username }
|
||||
: null,
|
||||
}}
|
||||
messages={ticket.messages.map((m) => ({
|
||||
id: m.id,
|
||||
message: m.message,
|
||||
isStaff: m.isStaff === 1,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
user: {
|
||||
id: m.user.id,
|
||||
username: m.user.username,
|
||||
look: m.user.look,
|
||||
rank: m.user.rank,
|
||||
},
|
||||
}))}
|
||||
staffMembers={staffMembers}
|
||||
currentUserId={session.user.id}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -71,7 +72,7 @@ export default async function AdminTicketsPage({
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
||||
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount] =
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||
await Promise.all([
|
||||
prisma.websiteTicket.count({ where }).catch(() => 0),
|
||||
prisma.websiteTicket.count({ where: { status: "open" } }).catch(() => 0),
|
||||
@@ -84,6 +85,7 @@ export default async function AdminTicketsPage({
|
||||
prisma.websiteTicket
|
||||
.count({ where: { status: "closed" } })
|
||||
.catch(() => 0),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
@@ -121,6 +123,10 @@ export default async function AdminTicketsPage({
|
||||
hint={t("queueHint")}
|
||||
otherHref="/admin/help-tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.cmsOpen,
|
||||
other: queues.helpOpen,
|
||||
})}
|
||||
/>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
|
||||
@@ -1,59 +1,16 @@
|
||||
import net from "node:net";
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function checkRcon(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const host = env.RCON_HOST;
|
||||
const port = env.RCON_PORT;
|
||||
|
||||
const socket = new net.Socket();
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, 3000);
|
||||
|
||||
socket.connect(port, host, () => {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
clearTimeout(timeout);
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.DEVOPS_VIEW }, async () => {
|
||||
// DB health
|
||||
let dbOk = false;
|
||||
let dbLatency = 0;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
dbLatency = Date.now() - start;
|
||||
dbOk = true;
|
||||
} catch {
|
||||
dbOk = false;
|
||||
}
|
||||
|
||||
// Emulator health (RCON TCP check)
|
||||
const emulator = await checkRcon();
|
||||
|
||||
// Online users
|
||||
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
||||
const health = await fetchOpsHealth();
|
||||
|
||||
return NextResponse.json({
|
||||
database: dbOk,
|
||||
dbLatency,
|
||||
emulator,
|
||||
onlineUsers,
|
||||
database: health.dbOk,
|
||||
dbLatency: health.dbLatencyMs,
|
||||
emulator: health.emulatorOk,
|
||||
onlineUsers: health.onlineUsers,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
@@ -82,7 +83,7 @@ export default async function ModHelpTicketsPage({
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
||||
|
||||
const [total, openCount, closedCount] = await Promise.all([
|
||||
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||
prisma.websiteHelpCenterTickets
|
||||
.count({ where: { open: true } })
|
||||
@@ -90,6 +91,7 @@ export default async function ModHelpTicketsPage({
|
||||
prisma.websiteHelpCenterTickets
|
||||
.count({ where: { open: false } })
|
||||
.catch(() => 0),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
@@ -165,6 +167,10 @@ export default async function ModHelpTicketsPage({
|
||||
hint={t("queueHint")}
|
||||
otherHref="/mod/tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.helpOpen,
|
||||
other: queues.cmsOpen,
|
||||
})}
|
||||
/>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
@@ -71,7 +72,7 @@ export default async function ModTicketsPage({
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
||||
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount] =
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||
await Promise.all([
|
||||
prisma.websiteTicket.count({ where }).catch(() => 0),
|
||||
prisma.websiteTicket.count({ where: { status: "open" } }).catch(() => 0),
|
||||
@@ -84,6 +85,7 @@ export default async function ModTicketsPage({
|
||||
prisma.websiteTicket
|
||||
.count({ where: { status: "closed" } })
|
||||
.catch(() => 0),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
@@ -121,6 +123,10 @@ export default async function ModTicketsPage({
|
||||
hint={t("queueHint")}
|
||||
otherHref="/mod/help-tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.cmsOpen,
|
||||
other: queues.helpOpen,
|
||||
})}
|
||||
/>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
|
||||
@@ -5,10 +5,13 @@ export function TicketQueueBanner({
|
||||
hint,
|
||||
otherHref,
|
||||
otherLabel,
|
||||
countsLine,
|
||||
}: {
|
||||
hint: string;
|
||||
otherHref: string;
|
||||
otherLabel: string;
|
||||
/** Optional preformatted open-count summary (locale-aware from caller). */
|
||||
countsLine?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -16,6 +19,9 @@ export function TicketQueueBanner({
|
||||
role="status"
|
||||
>
|
||||
<p className="m-0">{hint}</p>
|
||||
{countsLine ? (
|
||||
<p className="m-0 mt-1 text-muted-foreground">{countsLine}</p>
|
||||
) : null}
|
||||
<p className="m-0 mt-1">
|
||||
<Link
|
||||
href={otherHref}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROUTES: Array<[string, string]> = [
|
||||
@@ -135,22 +135,26 @@ describe("admin operations route contract", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("has no requireStaff left in admin pages", () => {
|
||||
const root = "src/app/admin";
|
||||
const offenders: string[] = [];
|
||||
function walk(dir: string) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name === "page.tsx") {
|
||||
const source = readFileSync(full, "utf8");
|
||||
if (/await requireStaff\(/.test(source)) {
|
||||
offenders.push(relative(process.cwd(), full).replaceAll("\\", "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
it("caches analytics full-scans via redisCache", () => {
|
||||
for (const path of [
|
||||
"src/app/admin/analytics/page.tsx",
|
||||
"src/app/admin/analytics/activity/page.tsx",
|
||||
"src/app/admin/analytics/economy/page.tsx",
|
||||
]) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain("redisCache");
|
||||
}
|
||||
walk(root);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("shares ops health probe across CC / DevOps / API", () => {
|
||||
expect(existsSync("src/lib/admin/ops-health.ts")).toBe(true);
|
||||
expect(
|
||||
readFileSync("src/app/admin/commandocentrum/page.tsx", "utf8"),
|
||||
).toContain("fetchOpsHealth");
|
||||
expect(readFileSync("src/app/admin/devops/page.tsx", "utf8")).toContain(
|
||||
"fetchOpsHealth",
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/app/api/admin/devops/health/route.ts", "utf8"),
|
||||
).toContain("fetchOpsHealth");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export type OpsHealth = {
|
||||
dbOk: boolean;
|
||||
dbLatencyMs: number;
|
||||
emulatorOk: boolean;
|
||||
onlineUsers: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared DB + emulator health probe for CommandoCentrum, DevOps, and APIs.
|
||||
* Emulator = real RCON `ping` (same semantics as CommandoCentrum /public health).
|
||||
*/
|
||||
export async function fetchOpsHealth(): Promise<OpsHealth> {
|
||||
const [db, emulatorOk, onlineUsers] = await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const start = Date.now();
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return { dbOk: true, dbLatencyMs: Date.now() - start };
|
||||
} catch {
|
||||
return { dbOk: false, dbLatencyMs: 0 };
|
||||
}
|
||||
})(),
|
||||
rcon.send("ping", null).catch(() => false),
|
||||
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
|
||||
]);
|
||||
|
||||
return {
|
||||
dbOk: db.dbOk,
|
||||
dbLatencyMs: db.dbLatencyMs,
|
||||
emulatorOk: Boolean(emulatorOk),
|
||||
onlineUsers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/** Open-queue sizes for dual ticket products (CMS desk vs help-center). */
|
||||
export async function fetchTicketQueueOpenCounts(): Promise<{
|
||||
cmsOpen: number;
|
||||
helpOpen: number;
|
||||
}> {
|
||||
const [cmsOpen, helpOpen] = await Promise.all([
|
||||
prisma.websiteTicket
|
||||
.count({ where: { status: { not: "closed" } } })
|
||||
.catch(() => 0),
|
||||
prisma.websiteHelpCenterTickets
|
||||
.count({ where: { open: true } })
|
||||
.catch(() => 0),
|
||||
]);
|
||||
return { cmsOpen, helpOpen };
|
||||
}
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2433,7 +2433,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2697,7 +2698,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -1496,6 +1496,7 @@
|
||||
"subtitle": "Internal CMS tickets (assignee, priority, staff workflow). Not the same as help-center tickets from /help.",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}",
|
||||
"noTickets": "No active tickets",
|
||||
"searchPlaceholder": "Search subject, user, category or ID…",
|
||||
"presetActive": "Active",
|
||||
@@ -1515,6 +1516,7 @@
|
||||
"subtitle": "Tickets players open at /help/tickets. Separate from the CMS support desk.",
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}",
|
||||
"noTickets": "No open help center tickets",
|
||||
"searchPlaceholder": "Search title, user or ID…",
|
||||
"presetOpen": "Open",
|
||||
@@ -2461,8 +2463,8 @@
|
||||
"events": "Events",
|
||||
"eventTypes": "Event types",
|
||||
"polls": "Polls",
|
||||
"tickets": "CMS tickets",
|
||||
"helpTickets": "Help-center tickets",
|
||||
"tickets": "Support desk",
|
||||
"helpTickets": "Help center",
|
||||
"templates": "Templates",
|
||||
"directory": "Directory",
|
||||
"multiAccounts": "Multi-accounts",
|
||||
@@ -3008,7 +3010,7 @@
|
||||
"bans": "Bans",
|
||||
"fullAdmin": "Full admin",
|
||||
"backToSite": "Back to site",
|
||||
"tickets": "CMS tickets",
|
||||
"tickets": "Support desk",
|
||||
"helpTickets": "Help-center tickets",
|
||||
"users": "Users",
|
||||
"team": "Team"
|
||||
|
||||
@@ -2433,7 +2433,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2697,7 +2698,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2433,7 +2433,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2697,7 +2698,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
+10
-8
@@ -1482,8 +1482,9 @@
|
||||
"colAssigned": "Assegnato",
|
||||
"colDate": "Data",
|
||||
"meta": "di {user} · {count} msg · {category}",
|
||||
"queueHint": "Questa coda e il supporto CMS. I ticket del help center pubblici stanno nella tab Help-center tickets.",
|
||||
"queueOtherCta": "Apri help-center tickets"
|
||||
"queueHint": "Questa coda e il supporto CMS. I ticket del help center pubblici stanno nella tab Help center.",
|
||||
"queueOtherCta": "Apri help center",
|
||||
"queueCounts": "Aperti qui: {here} · Help center: {other}"
|
||||
},
|
||||
"helpTickets": {
|
||||
"title": "Ticket help center",
|
||||
@@ -1519,8 +1520,9 @@
|
||||
"closed": "Ticket chiuso.",
|
||||
"reopened": "Ticket riaperto."
|
||||
},
|
||||
"queueHint": "Questa coda e il help center pubblico. I ticket del supporto CMS stanno nella tab Support desk.",
|
||||
"queueOtherCta": "Apri supporto CMS"
|
||||
"queueHint": "Questa coda e il help center pubblico. I ticket del supporto CMS stanno nella tab Supporto CMS.",
|
||||
"queueOtherCta": "Apri supporto CMS",
|
||||
"queueCounts": "Aperti qui: {here} · Supporto CMS: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2732,8 +2734,8 @@
|
||||
"events": "Eventi",
|
||||
"eventTypes": "Tipi evento",
|
||||
"polls": "Sondaggi",
|
||||
"tickets": "Ticket CMS",
|
||||
"helpTickets": "Ticket centro assistenza",
|
||||
"tickets": "Supporto CMS",
|
||||
"helpTickets": "Help center",
|
||||
"templates": "Template",
|
||||
"directory": "Directory",
|
||||
"multiAccounts": "Multi-account",
|
||||
@@ -3015,8 +3017,8 @@
|
||||
"bans": "Ban",
|
||||
"fullAdmin": "Admin completo",
|
||||
"backToSite": "Torna al sito",
|
||||
"tickets": "Ticket CMS",
|
||||
"helpTickets": "Ticket help center",
|
||||
"tickets": "Supporto CMS",
|
||||
"helpTickets": "Help center",
|
||||
"users": "Utenti",
|
||||
"team": "Team"
|
||||
},
|
||||
|
||||
+10
-8
@@ -2779,8 +2779,8 @@
|
||||
"events": "Evenementen",
|
||||
"eventTypes": "Evenementtypen",
|
||||
"polls": "Polls",
|
||||
"tickets": "CMS-tickets",
|
||||
"helpTickets": "Helpcentrum-tickets",
|
||||
"tickets": "Supportbalie",
|
||||
"helpTickets": "Helpcentrum",
|
||||
"templates": "Sjablonen",
|
||||
"directory": "Adresboek",
|
||||
"multiAccounts": "Multi-accounts",
|
||||
@@ -2866,8 +2866,9 @@
|
||||
"colAssigned": "Toegewezen",
|
||||
"colDate": "Datum",
|
||||
"meta": "door {user} · {count} berichten · {category}",
|
||||
"queueHint": "Deze wachtrij is de CMS support desk. Speler-helpcenter tickets staan onder Help-center tickets.",
|
||||
"queueOtherCta": "Open helpcenter-tickets"
|
||||
"queueHint": "Deze wachtrij is de CMS support desk. Speler-helpcenter tickets staan onder Helpcentrum.",
|
||||
"queueOtherCta": "Open helpcentrum-tickets",
|
||||
"queueCounts": "Open hier: {here} · Helpcentrum: {other}"
|
||||
},
|
||||
"helpTickets": {
|
||||
"title": "Helpcenter-tickets",
|
||||
@@ -2903,8 +2904,9 @@
|
||||
"closed": "Ticket gesloten.",
|
||||
"reopened": "Ticket heropend."
|
||||
},
|
||||
"queueHint": "Deze wachtrij is het publieke helpcenter. CMS support desk tickets staan onder Support desk.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueHint": "Deze wachtrij is het publieke helpcenter. CMS support desk tickets staan onder Supportbalie.",
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open hier: {here} · Supportbalie: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -3013,8 +3015,8 @@
|
||||
"bans": "Bans",
|
||||
"fullAdmin": "Volledige admin",
|
||||
"backToSite": "Terug naar site",
|
||||
"tickets": "CMS-tickets",
|
||||
"helpTickets": "Helpcenter-tickets",
|
||||
"tickets": "Supportbalie",
|
||||
"helpTickets": "Helpcentrum",
|
||||
"users": "Gebruikers",
|
||||
"team": "Team"
|
||||
},
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2461,7 +2461,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2725,7 +2726,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
@@ -2463,7 +2463,8 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueOtherCta": "Open help-center tickets"
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -2727,7 +2728,8 @@
|
||||
"reopened": "Ticket reopened."
|
||||
},
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
|
||||
Reference in new issue
Block a user