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
@@ -0,0 +1 @@
|
||||
export { default } from "../loading";
|
||||
@@ -1,7 +1,7 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { AlertLogs, db } from "@/lib/db";
|
||||
@@ -125,7 +125,16 @@ export default async function AdminAlerts() {
|
||||
</form>
|
||||
|
||||
<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 ? (
|
||||
<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,
|
||||
unmuteUser,
|
||||
} from "@/actions/users";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -46,6 +47,7 @@ export function UserQuickActions({
|
||||
isOnline,
|
||||
}: UserQuickActionsProps) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
// Reset password state
|
||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||
@@ -60,13 +62,14 @@ export function UserQuickActions({
|
||||
// Credits state
|
||||
const [creditAmount, setCreditAmount] = useState("1000");
|
||||
|
||||
function handleResetPassword() {
|
||||
if (
|
||||
!confirm(
|
||||
`Reset password for ${username}? A new random password will be generated.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
async function handleResetPassword() {
|
||||
const ok = await confirm({
|
||||
title: "Reset password",
|
||||
description: `Reset password for ${username}? A new random password will be generated.`,
|
||||
confirmLabel: "Reset",
|
||||
variant: "danger",
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => resetPassword({ userId }), {
|
||||
successMessage: "Password reset successfully.",
|
||||
onSuccess: (data) => {
|
||||
@@ -121,6 +124,7 @@ export function UserQuickActions({
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env";
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { db } from "@/lib/db";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
@@ -11,9 +13,21 @@ export const dynamic = "force-dynamic";
|
||||
* Ops health probe: database reachability, Redis (when configured), emulator
|
||||
* RCON, SMTP (when configured), and runtime info. Returns HTTP 200 always
|
||||
* (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() {
|
||||
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
|
||||
.execute(sql`SELECT 1`)
|
||||
.then(() => true)
|
||||
|
||||
Reference in new issue
Block a user