feat: add deployment reload controls

This commit is contained in:
2026-08-23 14:33:26 +00:00 Unverified
parent 25dccdfa4f
commit a630e0cc2f
11 changed files with 341 additions and 32 deletions
+9
View File
@@ -1,10 +1,19 @@
"use server";
import { revalidateTag } from "next/cache";
import { requireAdmin } from "@/lib/auth/auth";
import { normalizeReloadPrefix } from "@/lib/realtime/deployment-refresh";
import { sse } from "@/lib/realtime/sse";
const validPlatforms = ["youtube", "roblox", "discord", "tiktok"];
type FollowerFetcher = () => Promise<unknown>;
export async function forceRefresh(prefix: string | null) {
await requireAdmin();
const normalizedPrefix = normalizeReloadPrefix(prefix);
await sse.active.pub("refresh", normalizedPrefix);
}
export async function refetchPlatform(
platform: string
): Promise<{ count: number | null }> {
+117 -29
View File
@@ -3,14 +3,24 @@
import { useState } from "react";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RefreshCw } from "lucide-react";
import { refetchPlatform } from "./actions";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Bomb, RefreshCw } from "lucide-react";
import { forceRefresh, refetchPlatform } from "./actions";
import { toast } from "sonner";
interface Platform {
@@ -28,6 +38,8 @@ function formatCount(n: number | null): string {
export function BackendClient({ platforms }: { platforms: Platform[] }) {
const [data, setData] = useState<Platform[]>(platforms);
const [loading, setLoading] = useState<string | null>(null);
const [pathPrefix, setPathPrefix] = useState("");
const [reloadTarget, setReloadTarget] = useState<"all" | "path" | null>(null);
const handleRefetch = async (platform: string) => {
setLoading(platform);
@@ -46,36 +58,112 @@ export function BackendClient({ platforms }: { platforms: Platform[] }) {
}
};
const handleForceRefresh = async (
target: "all" | "path",
prefix: string | null,
) => {
setReloadTarget(target);
try {
await forceRefresh(prefix);
toast.success(
prefix ? `Reload sent for ${prefix}` : "Reload sent to all clients",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to send reload");
} finally {
setReloadTarget(null);
}
};
const normalizedPathPrefix = pathPrefix.trim();
const pathIsValid = normalizedPathPrefix.startsWith("/");
return (
<div className="grid gap-4 sm:grid-cols-2">
{data.map((p) => (
<Card key={p.name}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium capitalize">
{p.name}
</CardTitle>
<Badge
variant={p.cachedCount !== null ? "default" : "secondary"}
>
{formatCount(p.cachedCount)}
</Badge>
</CardHeader>
<CardContent>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={loading !== null}
onClick={() => handleRefetch(p.name)}
>
<RefreshCw
className={`w-3 h-3 ${loading === p.name ? "animate-spin" : ""}`}
<div className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
{data.map((p) => (
<Card key={p.name}>
<CardHeader>
<CardTitle className="text-sm font-medium capitalize">
{p.name}
</CardTitle>
<CardAction>
<Badge
variant={p.cachedCount !== null ? "default" : "secondary"}
>
{formatCount(p.cachedCount)}
</Badge>
</CardAction>
</CardHeader>
<CardContent>
<Button
variant="outline"
size="sm"
disabled={loading !== null}
onClick={() => handleRefetch(p.name)}
>
{loading === p.name ? (
<Spinner data-icon="inline-start" />
) : (
<RefreshCw data-icon="inline-start" />
)}
Refetch
</Button>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Client reload</CardTitle>
<CardDescription>
Reload every connected client or only clients under a pathname prefix.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={reloadTarget !== null}
onClick={() => handleForceRefresh("all", null)}
>
{reloadTarget === "all" ? (
<Spinner data-icon="inline-start" />
) : (
<Bomb data-icon="inline-start" />
)}
Force reload all
</Button>
<FieldGroup>
<Field data-invalid={pathPrefix.length > 0 && !pathIsValid}>
<FieldLabel htmlFor="reload-path-prefix">Path prefix</FieldLabel>
<Input
id="reload-path-prefix"
value={pathPrefix}
onChange={(event) => setPathPrefix(event.target.value)}
placeholder="/admin"
aria-invalid={pathPrefix.length > 0 && !pathIsValid}
/>
Refetch
<FieldDescription>
Reloads clients whose pathname starts with this value.
</FieldDescription>
</Field>
<Button
variant="destructive"
disabled={reloadTarget !== null || !pathIsValid}
onClick={() => handleForceRefresh("path", normalizedPathPrefix)}
>
{reloadTarget === "path" ? (
<Spinner data-icon="inline-start" />
) : (
<Bomb data-icon="inline-start" />
)}
Reload matching path
</Button>
</CardContent>
</Card>
))}
</FieldGroup>
</CardContent>
</Card>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { sse } from "@/lib/realtime/sse";
export const dynamic = "force-dynamic";
async function getDeploymentVersion() {
if (process.env.NODE_ENV === "development") return "DEV";
try {
const version = await readFile(path.join(process.cwd(), ".version"), "utf8");
return version.trim() || null;
} catch {
return null;
}
}
export async function GET(request: Request) {
const version = await getDeploymentVersion();
if (!version) {
return new Response("Deployment version unavailable", { status: 503 });
}
return sse.active.stream({
signal: request.signal,
motd: { event: "version", data: version },
});
}
+2
View File
@@ -69,6 +69,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider";
import { PageProgress } from "@/components/page-progress";
import { VersionCheck } from "@/components/version-check";
export default function RootLayout({
children,
@@ -96,6 +97,7 @@ export default function RootLayout({
>
<TooltipProvider>
<Toaster richColors closeButton position="bottom-right" theme="dark" />
<VersionCheck />
{children}
</TooltipProvider>
</ThemeProvider>