feat(ops): health-fail alerts, optional DB backup, admin UX polish
Wire jobs-worker health probes to Discord/email alerts with cooldown, optional mysqldump, rate-limit /api/health, mark-all-read alerts, ConfirmDialog on destructive admin actions, and raise coverage floors. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
3bd712e744
commit
725e1cb338
16 files changed
+327
-32
No files matched your search
@@ -39,6 +39,11 @@ BADGE_UPLOAD_DIR=./public/assets/images/badges
|
|||||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
EMULATOR_BACKUP_DIR=./backups/emulator
|
EMULATOR_BACKUP_DIR=./backups/emulator
|
||||||
EMULATOR_BACKUP_KEEP=7
|
EMULATOR_BACKUP_KEEP=7
|
||||||
|
# Optional mysqldump (jobs-worker daily 03:30). Requires mysqldump on PATH.
|
||||||
|
DB_BACKUP_DIR=
|
||||||
|
DB_BACKUP_KEEP=7
|
||||||
|
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
||||||
|
HEALTH_ALERT_COOLDOWN_MIN=15
|
||||||
|
|
||||||
# --- RCON (Low Latency Loop) ---
|
# --- RCON (Low Latency Loop) ---
|
||||||
RCON_HOST=127.0.0.1
|
RCON_HOST=127.0.0.1
|
||||||
|
|||||||
+160
-1
@@ -1,9 +1,12 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
import { lt } from "drizzle-orm";
|
import { lt, sql } from "drizzle-orm";
|
||||||
import { env } from "../src/env";
|
import { env } from "../src/env";
|
||||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
||||||
import { logger } from "../src/lib/logger";
|
import { logger } from "../src/lib/logger";
|
||||||
|
import { redis } from "../src/lib/redis";
|
||||||
|
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||||
|
import { rcon } from "../src/lib/services/rcon";
|
||||||
|
|
||||||
function initWorkerSentry(): void {
|
function initWorkerSentry(): void {
|
||||||
const dsn = process.env.SENTRY_DSN;
|
const dsn = process.env.SENTRY_DSN;
|
||||||
@@ -28,6 +31,71 @@ function captureWorkerError(err: unknown, context: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** In-process cooldown so a flapping probe does not spam Discord/email. */
|
||||||
|
const alertCooldownMs = (env.HEALTH_ALERT_COOLDOWN_MIN ?? 15) * 60_000;
|
||||||
|
const lastHealthAlertAt = new Map<string, number>();
|
||||||
|
|
||||||
|
function canAlert(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const prev = lastHealthAlertAt.get(key) ?? 0;
|
||||||
|
if (now - prev < alertCooldownMs) return false;
|
||||||
|
lastHealthAlertAt.set(key, now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeHealth(): Promise<{
|
||||||
|
database: boolean;
|
||||||
|
redis: boolean | null;
|
||||||
|
emulator: boolean;
|
||||||
|
}> {
|
||||||
|
const database = await db
|
||||||
|
.execute(sql`SELECT 1`)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
let redisOk: boolean | null = null;
|
||||||
|
if (env.REDIS_URL) {
|
||||||
|
if (!redis) {
|
||||||
|
redisOk = false;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
redisOk = (await redis.ping()) === "PONG";
|
||||||
|
} catch {
|
||||||
|
redisOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emulator = await rcon.send("ping", null).catch(() => false);
|
||||||
|
return {
|
||||||
|
database,
|
||||||
|
redis: redisOk,
|
||||||
|
emulator: Boolean(emulator),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOpsHealth(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const health = await probeHealth();
|
||||||
|
const degraded =
|
||||||
|
!health.database || health.redis === false || !health.emulator;
|
||||||
|
if (!degraded) return;
|
||||||
|
|
||||||
|
if (!health.emulator && health.database && health.redis !== false) {
|
||||||
|
if (canAlert("emulator")) {
|
||||||
|
await emulatorOffline("jobs-worker RCON ping failed");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canAlert("health")) {
|
||||||
|
await healthDegraded(health);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
captureWorkerError(err, "Health probe failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function backupEmulatorJar(): Promise<void> {
|
async function backupEmulatorJar(): Promise<void> {
|
||||||
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
||||||
|
|
||||||
@@ -70,6 +138,82 @@ async function backupEmulatorJar(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optional mysqldump when DB_BACKUP_DIR is set (host must have mysqldump on PATH). */
|
||||||
|
async function backupDatabase(): Promise<void> {
|
||||||
|
if (!env.DB_BACKUP_DIR || !env.DATABASE_URL) return;
|
||||||
|
|
||||||
|
const { mkdirSync, readdirSync, unlinkSync, existsSync, createWriteStream } =
|
||||||
|
await import("node:fs");
|
||||||
|
const { resolve } = await import("node:path");
|
||||||
|
const { spawn } = await import("node:child_process");
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(env.DATABASE_URL);
|
||||||
|
} catch {
|
||||||
|
logger.error("Invalid DATABASE_URL for DB backup", { module: "jobs" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(env.DB_BACKUP_DIR)) {
|
||||||
|
mkdirSync(env.DB_BACKUP_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
||||||
|
const dbName =
|
||||||
|
decodeURIComponent(parsed.pathname.replace(/^\//, "")) || "cms";
|
||||||
|
const outFile = resolve(env.DB_BACKUP_DIR, `db-${dbName}-${timestamp}.sql`);
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
`-h${parsed.hostname}`,
|
||||||
|
`-P${parsed.port || "3306"}`,
|
||||||
|
`-u${decodeURIComponent(parsed.username)}`,
|
||||||
|
`--single-transaction`,
|
||||||
|
`--routines`,
|
||||||
|
`--databases`,
|
||||||
|
dbName,
|
||||||
|
];
|
||||||
|
if (parsed.password) {
|
||||||
|
args.splice(3, 0, `-p${decodeURIComponent(parsed.password)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolvePromise) => {
|
||||||
|
const child = spawn("mysqldump", args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const out = createWriteStream(outFile);
|
||||||
|
child.stdout.pipe(out);
|
||||||
|
let stderr = "";
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("error", (err) => {
|
||||||
|
captureWorkerError(err, "mysqldump spawn failed (is it on PATH?)");
|
||||||
|
resolvePromise();
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
out.end();
|
||||||
|
if (code !== 0) {
|
||||||
|
captureWorkerError(
|
||||||
|
new Error(stderr || `mysqldump exit ${code}`),
|
||||||
|
"DB backup failed",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info("Backed up database", { module: "jobs", outFile });
|
||||||
|
const keep = env.DB_BACKUP_KEEP ?? 7;
|
||||||
|
const files = readdirSync(env.DB_BACKUP_DIR)
|
||||||
|
.filter((f) => f.startsWith("db-") && f.endsWith(".sql"))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
for (let i = keep; i < files.length; i++) {
|
||||||
|
unlinkSync(resolve(env.DB_BACKUP_DIR, files[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolvePromise();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function cleanupOldLogs(): Promise<void> {
|
async function cleanupOldLogs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
@@ -107,6 +251,15 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (env.DB_BACKUP_DIR) {
|
||||||
|
new Cron("30 3 * * *", () => {
|
||||||
|
backupDatabase().catch((e) => captureWorkerError(e, "DB backup error"));
|
||||||
|
});
|
||||||
|
logger.info("Scheduled: mysqldump DB backup (daily 03:30)", {
|
||||||
|
module: "jobs",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
new Cron("0 4 * * *", () => {
|
new Cron("0 4 * * *", () => {
|
||||||
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
||||||
captureWorkerError(e, "Cleanup error"),
|
captureWorkerError(e, "Cleanup error"),
|
||||||
@@ -114,10 +267,16 @@ async function main() {
|
|||||||
});
|
});
|
||||||
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
||||||
|
|
||||||
|
new Cron("*/5 * * * *", () => {
|
||||||
|
checkOpsHealth().catch((e) => captureWorkerError(e, "Health check error"));
|
||||||
|
});
|
||||||
|
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
backupEmulatorJar(),
|
backupEmulatorJar(),
|
||||||
cleanupOldLogs(),
|
cleanupOldLogs(),
|
||||||
cleanupOldSessions(),
|
cleanupOldSessions(),
|
||||||
|
checkOpsHealth(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { AlertLogs, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
@@ -29,3 +31,17 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/alerts");
|
revalidatePath("/admin/alerts");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mark every unread ops alert as read. */
|
||||||
|
export async function markAllAlertsRead(): Promise<void> {
|
||||||
|
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(AlertLogs)
|
||||||
|
.set({ isRead: true, updatedAt: new Date() })
|
||||||
|
.where(eq(AlertLogs.isRead, false));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/alerts");
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../loading";
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { desc } from "drizzle-orm";
|
import { desc } from "drizzle-orm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { sendHotelAlert } from "@/actions/admin-alerts";
|
import { markAllAlertsRead, sendHotelAlert } from "@/actions/admin-alerts";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertLogs, db } from "@/lib/db";
|
import { AlertLogs, db } from "@/lib/db";
|
||||||
@@ -125,7 +125,16 @@ export default async function AdminAlerts() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="admin-card">
|
<section className="admin-card">
|
||||||
<h3 className="mt-0">{t("recentAlerts", { count: alerts.length })}</h3>
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="m-0">{t("recentAlerts", { count: alerts.length })}</h3>
|
||||||
|
{unread > 0 ? (
|
||||||
|
<form action={markAllAlertsRead}>
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
{t("markAllRead")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{alerts.length === 0 ? (
|
{alerts.length === 0 ? (
|
||||||
<div className="admin-empty">{t("noAlerts")}</div>
|
<div className="admin-empty">{t("noAlerts")}</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../loading";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../loading";
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
sendCredits,
|
sendCredits,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
} from "@/actions/users";
|
} from "@/actions/users";
|
||||||
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -46,6 +47,7 @@ export function UserQuickActions({
|
|||||||
isOnline,
|
isOnline,
|
||||||
}: UserQuickActionsProps) {
|
}: UserQuickActionsProps) {
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
// Reset password state
|
// Reset password state
|
||||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||||
@@ -60,13 +62,14 @@ export function UserQuickActions({
|
|||||||
// Credits state
|
// Credits state
|
||||||
const [creditAmount, setCreditAmount] = useState("1000");
|
const [creditAmount, setCreditAmount] = useState("1000");
|
||||||
|
|
||||||
function handleResetPassword() {
|
async function handleResetPassword() {
|
||||||
if (
|
const ok = await confirm({
|
||||||
!confirm(
|
title: "Reset password",
|
||||||
`Reset password for ${username}? A new random password will be generated.`,
|
description: `Reset password for ${username}? A new random password will be generated.`,
|
||||||
)
|
confirmLabel: "Reset",
|
||||||
)
|
variant: "danger",
|
||||||
return;
|
});
|
||||||
|
if (!ok) return;
|
||||||
run(() => resetPassword({ userId }), {
|
run(() => resetPassword({ userId }), {
|
||||||
successMessage: "Password reset successfully.",
|
successMessage: "Password reset successfully.",
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -121,6 +124,7 @@ export function UserQuickActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{confirmDialog}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Quick Actions</CardTitle>
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { redis } from "@/lib/redis";
|
import { redis } from "@/lib/redis";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
@@ -11,9 +13,21 @@ export const dynamic = "force-dynamic";
|
|||||||
* Ops health probe: database reachability, Redis (when configured), emulator
|
* Ops health probe: database reachability, Redis (when configured), emulator
|
||||||
* RCON, SMTP (when configured), and runtime info. Returns HTTP 200 always
|
* RCON, SMTP (when configured), and runtime info. Returns HTTP 200 always
|
||||||
* (read the `status`/`database` fields), so it's safe for uptime monitors that
|
* (read the `status`/`database` fields), so it's safe for uptime monitors that
|
||||||
* only care about reachability.
|
* only care about reachability. Rate-limited per client IP.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const ip = await clientIp();
|
||||||
|
const limit = await rateLimit(`health:${ip}`, 60, 60_000);
|
||||||
|
if (!limit.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: "rate_limited", retryAfter: limit.retryAfter },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Retry-After": String(limit.retryAfter) },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const database = await db
|
const database = await db
|
||||||
.execute(sql`SELECT 1`)
|
.execute(sql`SELECT 1`)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useTransition } from "react";
|
||||||
import { deleteArticle } from "@/actions/admin-articles";
|
import { deleteArticle } from "@/actions/admin-articles";
|
||||||
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
|
|
||||||
export function ArticleCard({
|
export function ArticleCard({
|
||||||
id,
|
id,
|
||||||
@@ -18,9 +21,29 @@ export function ArticleCard({
|
|||||||
author?: string;
|
author?: string;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.articles");
|
const t = useTranslations("pages.admin.articles");
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("delete"),
|
||||||
|
description: t("confirmDelete", { title }),
|
||||||
|
confirmLabel: t("delete"),
|
||||||
|
variant: "danger",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("id", id);
|
||||||
|
await deleteArticle(fd);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-card overflow-hidden flex flex-col">
|
<div className="admin-card overflow-hidden flex flex-col">
|
||||||
|
{confirmDialog}
|
||||||
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
|
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
@@ -56,20 +79,14 @@ export function ArticleCard({
|
|||||||
>
|
>
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
</Link>
|
</Link>
|
||||||
<form action={deleteArticle} className="m-0">
|
<button
|
||||||
<input type="hidden" name="id" value={id} />
|
type="button"
|
||||||
<button
|
className="btn btn-danger"
|
||||||
type="submit"
|
disabled={pending}
|
||||||
className="btn btn-danger"
|
onClick={handleDelete}
|
||||||
onClick={(e) => {
|
>
|
||||||
if (!window.confirm(t("confirmDelete", { title }))) {
|
{t("delete")}
|
||||||
e.preventDefault();
|
</button>
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("delete")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
|
import { deleteMedia, uploadMedia } from "@/actions/admin-media";
|
||||||
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
type MediaFile = {
|
type MediaFile = {
|
||||||
@@ -32,6 +33,7 @@ function extOf(name: string): string {
|
|||||||
|
|
||||||
export function AdminMediaGrid() {
|
export function AdminMediaGrid() {
|
||||||
const t = useTranslations("pages.admin.media");
|
const t = useTranslations("pages.admin.media");
|
||||||
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
const [files, setFiles] = useState<MediaFile[]>([]);
|
const [files, setFiles] = useState<MediaFile[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -103,7 +105,13 @@ export function AdminMediaGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(name: string) {
|
async function remove(name: string) {
|
||||||
if (!window.confirm(t("confirmDelete"))) return;
|
const ok = await confirm({
|
||||||
|
title: t("delete"),
|
||||||
|
description: t("confirmDelete"),
|
||||||
|
confirmLabel: t("delete"),
|
||||||
|
variant: "danger",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await deleteMedia(name);
|
await deleteMedia(name);
|
||||||
setFiles((prev) => prev.filter((f) => f.name !== name));
|
setFiles((prev) => prev.filter((f) => f.name !== name));
|
||||||
@@ -115,6 +123,7 @@ export function AdminMediaGrid() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
|
{confirmDialog}
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<label className="btn btn-primary cursor-pointer text-sm">
|
<label className="btn btn-primary cursor-pointer text-sm">
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ const schema = z
|
|||||||
EMULATOR_JAR_PATH: z.string().optional(),
|
EMULATOR_JAR_PATH: z.string().optional(),
|
||||||
EMULATOR_BACKUP_DIR: z.string().optional(),
|
EMULATOR_BACKUP_DIR: z.string().optional(),
|
||||||
EMULATOR_BACKUP_KEEP: z.coerce.number().int().positive().optional(),
|
EMULATOR_BACKUP_KEEP: z.coerce.number().int().positive().optional(),
|
||||||
|
// Optional mysqldump backup (jobs-worker); requires mysqldump on PATH.
|
||||||
|
DB_BACKUP_DIR: z.string().optional(),
|
||||||
|
DB_BACKUP_KEEP: z.coerce.number().int().positive().optional(),
|
||||||
|
// Minutes between repeat health-fail Discord/email alerts (jobs-worker).
|
||||||
|
HEALTH_ALERT_COOLDOWN_MIN: z.coerce.number().int().positive().optional(),
|
||||||
// Optional AI content moderation (comments / guestbook).
|
// Optional AI content moderation (comments / guestbook).
|
||||||
OPENAI_API_KEY: z.string().optional(),
|
OPENAI_API_KEY: z.string().optional(),
|
||||||
// Optional alerting (jobs worker / alert service).
|
// Optional alerting (jobs worker / alert service).
|
||||||
|
|||||||
@@ -232,6 +232,33 @@ export function emulatorOffline(detail?: string): Promise<SendAlertResult> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raise an ERROR alert when ops health is degraded (DB / Redis / emulator).
|
||||||
|
*/
|
||||||
|
export function healthDegraded(parts: {
|
||||||
|
database: boolean;
|
||||||
|
redis: boolean | null;
|
||||||
|
emulator: boolean;
|
||||||
|
}): Promise<SendAlertResult> {
|
||||||
|
const failed: string[] = [];
|
||||||
|
if (!parts.database) failed.push("database");
|
||||||
|
if (parts.redis === false) failed.push("redis");
|
||||||
|
if (!parts.emulator) failed.push("emulator");
|
||||||
|
return sendAlert({
|
||||||
|
type: "health",
|
||||||
|
severity: parts.database ? "error" : "critical",
|
||||||
|
message:
|
||||||
|
failed.length > 0
|
||||||
|
? `Ops health degraded: ${failed.join(", ")} unavailable.`
|
||||||
|
: "Ops health degraded.",
|
||||||
|
context: {
|
||||||
|
database: parts.database,
|
||||||
|
redis: parts.redis,
|
||||||
|
emulator: parts.emulator,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raise a WARNING alert that a possible DDoS / abusive request pattern was
|
* Raise a WARNING alert that a possible DDoS / abusive request pattern was
|
||||||
* detected from a single IP (count = requests seen in the sampling window).
|
* detected from a single IP (count = requests seen in the sampling window).
|
||||||
|
|||||||
@@ -179,6 +179,23 @@ describe("staff smoke contract", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ships ops health alerts, optional DB backup, and health rate limit", () => {
|
||||||
|
const worker = readFileSync("scripts/jobs-worker.ts", "utf8");
|
||||||
|
expect(worker).toContain("checkOpsHealth");
|
||||||
|
expect(worker).toContain("healthDegraded");
|
||||||
|
expect(worker).toContain("backupDatabase");
|
||||||
|
expect(readFileSync("src/lib/services/alert.ts", "utf8")).toContain(
|
||||||
|
"healthDegraded",
|
||||||
|
);
|
||||||
|
expect(readFileSync("src/app/api/health/route.ts", "utf8")).toContain(
|
||||||
|
"rateLimit",
|
||||||
|
);
|
||||||
|
expect(readFileSync("src/actions/admin-alerts.ts", "utf8")).toContain(
|
||||||
|
"markAllAlertsRead",
|
||||||
|
);
|
||||||
|
expect(readFileSync("src/env.ts", "utf8")).toContain("DB_BACKUP_DIR");
|
||||||
|
});
|
||||||
|
|
||||||
it("documents local-only photo file purge", () => {
|
it("documents local-only photo file purge", () => {
|
||||||
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
||||||
"purgeHint",
|
"purgeHint",
|
||||||
|
|||||||
+12
-2
@@ -2416,15 +2416,22 @@
|
|||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"title": "Alerts",
|
"title": "Alerts",
|
||||||
"subtitle": "Send hotel-wide alerts and view recent alerts",
|
"subtitle": "Send hotel-wide alerts and view recent ops alerts",
|
||||||
"sendAlert": "Send hotel alert",
|
"sendAlert": "Send hotel alert",
|
||||||
"recentAlerts": "Recent alerts",
|
"recentAlerts": "Recent alerts ({count})",
|
||||||
|
"noAlerts": "No recent alerts",
|
||||||
"noRecentAlerts": "No recent alerts",
|
"noRecentAlerts": "No recent alerts",
|
||||||
|
"alertsShown": "Shown",
|
||||||
|
"critical": "Critical",
|
||||||
|
"warnings": "Warnings",
|
||||||
|
"unread": "Unread",
|
||||||
|
"markAllRead": "Mark all read",
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Alert title",
|
"title": "Alert title",
|
||||||
"titlePlaceholder": "e.g. Server maintenance in 10 minutes",
|
"titlePlaceholder": "e.g. Server maintenance in 10 minutes",
|
||||||
"message": "Alert message",
|
"message": "Alert message",
|
||||||
"messagePlaceholder": "Write the alert message…",
|
"messagePlaceholder": "Write the alert message…",
|
||||||
|
"description": "Broadcasts via RCON <code>hotelalert</code> to online users.",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"typeHotel": "Hotel-wide alert",
|
"typeHotel": "Hotel-wide alert",
|
||||||
"typeStaff": "Staff only",
|
"typeStaff": "Staff only",
|
||||||
@@ -2437,6 +2444,9 @@
|
|||||||
"colSentBy": "Sent by",
|
"colSentBy": "Sent by",
|
||||||
"colType": "Type",
|
"colType": "Type",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
|
"colWhen": "When",
|
||||||
|
"colSeverity": "Severity",
|
||||||
|
"colMessage": "Message",
|
||||||
"colActions": "Actions"
|
"colActions": "Actions"
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
|
|||||||
+4
-4
@@ -24,10 +24,10 @@ export default defineConfig({
|
|||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text", "lcov", "html"],
|
reporter: ["text", "lcov", "html"],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 6,
|
statements: 7,
|
||||||
branches: 4,
|
branches: 5,
|
||||||
functions: 5,
|
functions: 6,
|
||||||
lines: 6,
|
lines: 7,
|
||||||
},
|
},
|
||||||
include: ["src/**/*.{ts,tsx}"],
|
include: ["src/**/*.{ts,tsx}"],
|
||||||
exclude: [
|
exclude: [
|
||||||
|
|||||||
Reference in new issue
Block a user