feat: add deployment reload controls
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user