feat(admin): analytics redis cache, shared ops health, ticket queue clarity
CI / check (push) Successful in 29s
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m25s
CI / deploy (push) Failing after 10s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-30 19:57:00 +02:00
1 parent 474de0717b
commit 58fae1f90f
38 files changed
+348 -245

No files matched your search

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