spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { EventDTO } from "@dci/core";4import { ArrowUp, RefreshCw } from "lucide-react";5import { useCallback, useEffect, useMemo, useRef, useState } from "react";6import { EventList } from "@/components/event-list";7import { Button } from "@/components/ui/button";8import { cn } from "@/components/ui/cn";9import { ErrorState } from "@/components/ui/states";10import { SkeletonRows } from "@/components/ui/skeleton";11import { apiGet, clientApiBase } from "@/lib/api";12import { useInterval } from "@/lib/hooks";1314export interface LiveQuery {15 type?: string;16 country?: string;17 operator?: string;18 min_significance?: number;19}2021interface Props {22 initial: EventDTO[];23 initialTotal: number;24 perPage: number;25 query: LiveQuery;26 /** SSR failed → show error state until polling succeeds */27 initialError?: string;28}2930/**31 * Change feed with polling. SSR passes the first page; the client polls /events?since= every 60 s,32 * shows an "N new updates" pill, and prepends them on demand. "Load more" pages with ?page=.33 */34export function LiveFeed({ initial, initialTotal, perPage, query, initialError }: Props) {35 const [events, setEvents] = useState<EventDTO[]>(initial);36 const [pending, setPending] = useState<EventDTO[]>([]);37 const [loadingMore, setLoadingMore] = useState(false);38 const [error, setError] = useState<string | null>(initialError ?? null);39 const [exhausted, setExhausted] = useState(initial.length >= initialTotal || initial.length < perPage);40 const [fresh, setFresh] = useState<Set<string>>(new Set());41 const sinceRef = useRef<string>(initial[0]?.detectedAt ?? new Date().toISOString());42 const seen = useRef(new Set(initial.map((e) => e.id)));4344 const [syncedInitial, setSyncedInitial] = useState(initial);45 if (syncedInitial !== initial) {46 setSyncedInitial(initial);47 setEvents(initial);48 setPending([]);49 setExhausted(initial.length >= initialTotal || initial.length < perPage);50 setError(initialError ?? null);51 }52 useEffect(() => {53 sinceRef.current = initial[0]?.detectedAt ?? new Date().toISOString();54 seen.current = new Set(initial.map((e) => e.id));55 }, [initial]);5657 const base = useMemo(() => clientApiBase(), []);5859 const poll = useCallback(async () => {60 if (typeof document !== "undefined" && document.hidden) return;61 const r = await apiGet<EventDTO[] | { items: EventDTO[] }>("/events", { ...query, since: sinceRef.current, per_page: 100 }, { base, timeoutMs: 8000 });62 if (!r.ok) return;63 const items = Array.isArray(r.data) ? r.data : (r.data.items ?? []);64 const news = items.filter((e) => !seen.current.has(e.id));65 if (!news.length) {66 setError(null);67 return;68 }69 for (const e of news) seen.current.add(e.id);70 const newest = news.reduce((a, e) => (e.detectedAt > a ? e.detectedAt : a), sinceRef.current);71 sinceRef.current = newest;72 setError(null);73 setPending((p) => [...news, ...p].sort((a, b) => (a.detectedAt < b.detectedAt ? 1 : -1)));74 }, [query, base]);7576 useInterval(poll, 60_000);77 useEffect(() => {78 if (initialError) void poll();79 }, [initialError, poll]);8081 const reveal = () => {82 setEvents((ev) => [...pending, ...ev]);83 setFresh(new Set(pending.map((e) => e.id)));84 setPending([]);85 window.scrollTo({ top: 0, behavior: "smooth" });86 setTimeout(() => setFresh(new Set()), 2500);87 };8889 const loadMore = async () => {90 setLoadingMore(true);91 const nextPage = Math.floor(events.length / perPage) + 1;92 const r = await apiGet<EventDTO[] | { items: EventDTO[] }>("/events", { ...query, page: nextPage, per_page: perPage }, { base });93 setLoadingMore(false);94 if (!r.ok) {95 setError(r.error);96 return;97 }98 const items = (Array.isArray(r.data) ? r.data : (r.data.items ?? [])).filter((e) => !seen.current.has(e.id));99 for (const e of items) seen.current.add(e.id);100 setEvents((ev) => [...ev, ...items]);101 if (items.length < perPage) setExhausted(true);102 };103104 return (105 <div className="relative">106 {pending.length > 0 && (107 <div className="sticky top-[5.5rem] z-[30] flex justify-center sm:top-[6rem]">108 <button type="button" onClick={reveal} className="inline-flex h-9 items-center gap-1.5 rounded-full border border-accent bg-accent px-4 text-[12.5px] font-medium text-white animate-fade-in">109 <ArrowUp className="size-3.5" /> {pending.length} new {pending.length === 1 ? "update" : "updates"}110 </button>111 </div>112 )}113 {error && events.length === 0 ? (114 <ErrorState compact detail={error} title="Change feed unavailable" description="Retrying automatically every minute." />115 ) : (116 <EventList events={events} groupByDay highlightIds={fresh} />117 )}118 <div className={cn("flex flex-col items-center gap-2 py-6", events.length === 0 && "hidden")}>119 {loadingMore ? (120 <SkeletonRows rows={3} className="w-full" />121 ) : exhausted ? (122 <span className="text-[12px] text-ink-4">End of feed</span>123 ) : (124 <Button onClick={loadMore} size="md">125 <RefreshCw className="size-3.5" /> Load more126 </Button>127 )}128 {error && events.length > 0 && <span className="text-[11.5px] text-warn">Could not load more ({error}).</span>}129 </div>130 </div>131 );132}133