diff --git a/ops/inventory/prod.yml b/ops/inventory/prod.yml index bddfa12e..05cd56cb 100644 --- a/ops/inventory/prod.yml +++ b/ops/inventory/prod.yml @@ -9,4 +9,4 @@ all: route: host: wp-veritas.epfl.ch availability: public - tag: 7.0.3 + tag: 7.0.4 diff --git a/ops/inventory/test.yml b/ops/inventory/test.yml index 6580c68d..6a1187b8 100644 --- a/ops/inventory/test.yml +++ b/ops/inventory/test.yml @@ -9,4 +9,4 @@ all: route: host: wp-veritas-test.epfl.ch availability: private - tag: 7.0.3 + tag: 7.0.4 diff --git a/src/app/(root)/page.tsx b/src/app/(root)/page.tsx index 631bfb34..5f56aef6 100644 --- a/src/app/(root)/page.tsx +++ b/src/app/(root)/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { AlertTriangle, ArrowLeft, BarChart3, Calendar, ChevronRight, Download, Filter, GlobeIcon, Info, Languages, Layers, MoreHorizontal, Pencil, Plus, Tags, Zap } from "lucide-react"; +import { AlertTriangle, ArrowLeft, BarChart3, Calendar, ChevronRight, Download, Filter, GlobeIcon, Info, Languages, Layers, Loader2, MoreHorizontal, Pencil, Plus, Tags, Zap } from "lucide-react"; import moment from "moment"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import React, { useEffect, useState } from "react"; +import { ActionLink } from "@/components/ui/action"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -13,6 +14,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, type TableColumn } from "@/components/ui/table"; import { useAbility } from "@/hooks/useAbility"; +import { useServerEvents } from "@/hooks/useServerEvents"; import "moment/locale/fr"; import { useSearchParams } from "next/navigation"; import { DeleteDialog } from "@/components/dialog/delete"; @@ -22,7 +24,7 @@ import { LANGUAGES } from "@/constants/languages"; import { THEMES } from "@/constants/theme"; import { deleteSiteAction, listSites } from "@/services/site"; import type { InfrastructureType } from "@/types/infrastructure"; -import type { Site, SiteListFilters } from "@/types/site"; +import type { Site, SiteEvent, SiteListFilters } from "@/types/site"; import { isDatabaseSite, isKubernetesSite, isNoneSite } from "@/types/site"; import type { ThemeType } from "@/types/theme"; @@ -30,6 +32,7 @@ export default function SiteListPage() { const ability = useAbility(); const searchParams = useSearchParams(); const [sites, setSites] = useState([]); + const [deletingIds, setDeletingIds] = useState>(new Set()); useEffect(() => { listSites().then(({ sites: data }) => { @@ -37,6 +40,30 @@ export default function SiteListPage() { }); }, []); + const removeFromSet = (set: Set, id: string): Set => { + if (!set.has(id)) return set; + const next = new Set(set); + next.delete(id); + return next; + }; + + useServerEvents<{ site: SiteEvent }>("/api/sites/events", { + site: (event) => { + switch (event.type) { + case "added": + case "modified": + // Upsert with the authoritative site; `creating`/`deletedAt` flags live on the object itself. + setSites((prev) => (prev.some((s) => s.id === event.id) ? prev.map((s) => (s.id === event.id ? event.site : s)) : [event.site, ...prev])); + if (!event.site.deletedAt) setDeletingIds((prev) => removeFromSet(prev, event.id)); + break; + case "deleted": + setSites((prev) => prev.filter((s) => s.id !== event.id)); + setDeletingIds((prev) => removeFromSet(prev, event.id)); + break; + } + }, + }); + const getInitialFilters = () => { const filters: SiteListFilters = { url: searchParams.get("url") || "", @@ -248,20 +275,36 @@ export default function SiteListPage() { width: "w-[55%]", align: "left", sortable: true, - render: (site) => ( -
- - - {site.url} - - {!site.managed && ( -
- -

{translations.site("unmanaged")}

-
- )} -
- ), + render: (site) => { + const isDeleting = deletingIds.has(site.id) || Boolean(site.deletedAt); + const isCreating = Boolean(site.creating); + return ( +
+ + + {site.url} + + {isDeleting ? ( +
+ +

{translations.site("deleting")}

+
+ ) : isCreating ? ( +
+ +

{translations.site("creating")}

+
+ ) : ( + !site.managed && ( +
+ +

{translations.site("unmanaged")}

+
+ ) + )} +
+ ); + }, }, { key: "infrastructure", @@ -300,42 +343,42 @@ export default function SiteListPage() { width: "w-[15%]", align: "left", sortable: false, - render: (site) => ( -
- {ability.can("read", "Site") && ( - - )} - {ability.can("update", "Site") && ( - - )} - {ability.can("associate", "Tag") && ( - - )} - {ability.can("delete", "Site") && ( - { - await deleteSiteAction(site.id); - setSites((prev) => prev.filter((s) => s.id !== site.id)); - }} - /> - )} -
- ), + + )} + {ability.can("delete", "Site") && ( + { + await deleteSiteAction(site.id); + setDeletingIds((prev) => new Set([...prev, site.id])); + }} + /> + )} + + ); + }, }, ]; diff --git a/src/app/api/sites/events/route.ts b/src/app/api/sites/events/route.ts new file mode 100644 index 00000000..da7cbd0a --- /dev/null +++ b/src/app/api/sites/events/route.ts @@ -0,0 +1,19 @@ +import type { NextRequest } from "next/server"; +import { watchKubernetesSites } from "@/lib/kubernetes"; +import { createSSEStream } from "@/lib/sse"; +import { auth } from "@/services/auth"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session?.user) return new Response("Unauthorized", { status: 401 }); + + return createSSEStream(request, async (connection) => { + const watch = await watchKubernetesSites( + (event) => connection.send("site", event), + () => connection.close(), + ); + return () => watch.abort(); + }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 856bae57..3b60b576 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,12 +15,12 @@ const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); const suisse = localFont({ src: [ { - path: "../../fonts/SuisseIntl-Regular-WebS.woff2", + path: "../fonts/SuisseIntl-Regular-WebS.woff2", weight: "400", style: "normal", }, { - path: "../../fonts/SuisseIntl-SemiBold-WebS.woff2", + path: "../fonts/SuisseIntl-SemiBold-WebS.woff2", weight: "600", style: "normal", }, diff --git a/src/components/dialog/delete.tsx b/src/components/dialog/delete.tsx index d50b7729..7672d6e3 100644 --- a/src/components/dialog/delete.tsx +++ b/src/components/dialog/delete.tsx @@ -15,9 +15,10 @@ interface DeleteDialogProps { itemCount?: number; triggerText?: string; isPlural?: boolean; + disabled?: boolean; } -export const DeleteDialog: React.FC = ({ displayName, type, icon, onDelete, onBulkDelete, itemCount, triggerText, isPlural = false }) => { +export const DeleteDialog: React.FC = ({ displayName, type, icon, onDelete, onBulkDelete, itemCount, triggerText, isPlural = false, disabled = false }) => { const [isOpen, setIsOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(null); @@ -54,12 +55,12 @@ export const DeleteDialog: React.FC = ({ displayName, type, i {triggerText ? ( - ) : ( - )} diff --git a/src/components/ui/action.tsx b/src/components/ui/action.tsx new file mode 100644 index 00000000..41b1b08d --- /dev/null +++ b/src/components/ui/action.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; +import type React from "react"; +import { Button } from "@/components/ui/button"; + +interface ActionLinkProps { + href: string; + locked: boolean; + className: string; + children: React.ReactNode; +} + +export function ActionLink({ href, locked, className, children }: ActionLinkProps) { + if (locked) { + return ( + + ); + } + return ( + + ); +} diff --git a/fonts/SuisseIntl-Regular-WebS.woff2 b/src/fonts/SuisseIntl-Regular-WebS.woff2 similarity index 100% rename from fonts/SuisseIntl-Regular-WebS.woff2 rename to src/fonts/SuisseIntl-Regular-WebS.woff2 diff --git a/fonts/SuisseIntl-SemiBold-WebS.woff2 b/src/fonts/SuisseIntl-SemiBold-WebS.woff2 similarity index 100% rename from fonts/SuisseIntl-SemiBold-WebS.woff2 rename to src/fonts/SuisseIntl-SemiBold-WebS.woff2 diff --git a/src/hooks/useServerEvents.ts b/src/hooks/useServerEvents.ts new file mode 100644 index 00000000..6d8c6e3f --- /dev/null +++ b/src/hooks/useServerEvents.ts @@ -0,0 +1,25 @@ +"use client"; +import { useEffect, useRef } from "react"; + +export function useServerEvents>(url: string, handlers: { [K in keyof E]: (data: E[K]) => void }, enabled = true): void { + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + + useEffect(() => { + if (!enabled) return; + + const source = new EventSource(url); + const events = Object.keys(handlersRef.current) as (keyof E & string)[]; + + const bound = events.map((event) => { + const listener = (e: MessageEvent) => handlersRef.current[event](JSON.parse(e.data)); + source.addEventListener(event, listener); + return { event, listener }; + }); + + return () => { + for (const { event, listener } of bound) source.removeEventListener(event, listener); + source.close(); + }; + }, [url, enabled]); +} diff --git a/src/lib/kubernetes.ts b/src/lib/kubernetes.ts index 6db406e2..f2a1c98c 100644 --- a/src/lib/kubernetes.ts +++ b/src/lib/kubernetes.ts @@ -3,7 +3,7 @@ import { INFRASTRUCTURES } from "@/constants/infrastructures"; import { getBackupConfig } from "@/services/backup"; import type { BackupEnvironment } from "@/types/backup"; import type { APIError } from "@/types/error"; -import type { KubernetesSite, KubernetesSiteExtraInfo, KubernetesSiteForm, SiteForm, WordPressPlugins } from "@/types/site"; +import type { KubernetesSite, KubernetesSiteExtraInfo, KubernetesSiteForm, SiteEvent, SiteForm, WordPressPlugins } from "@/types/site"; // Raw CRD representation as returned by the Kubernetes API interface KubernetesSiteType { @@ -13,6 +13,7 @@ interface KubernetesSiteType { namespace: string; labels?: Record; creationTimestamp: string; + deletionTimestamp?: string; }; spec: { owner: { @@ -172,6 +173,8 @@ function mapKubernetesToSite(item: KubernetesSiteType): KubernetesSite { categories: getCategoriesFromPlugins(item.spec.wordpress.plugins) || [], downloadsProtectionScript: Boolean(item.spec.wordpress.downloadsProtectionScript), managed: item.metadata.labels?.["app.kubernetes.io/managed-by"] === "wp-veritas" && !isTemporary, + deletedAt: item.metadata.deletionTimestamp ? new Date(item.metadata.deletionTimestamp) : undefined, + creating: !item.status?.wordpresssite, tags: [], ticket: undefined, comment: undefined, @@ -556,6 +559,25 @@ export async function getKubernetesSites(): Promise<{ ); // 8 minutes cache } +export async function watchKubernetesSites(onEvent: (event: SiteEvent) => void, onDone: (err?: unknown) => void): Promise<{ abort(): void }> { + const namespace = await getNamespace(); + const watch = new k8s.Watch(kc); + + return watch.watch( + `/apis/${WORDPRESS_GROUP}/${WORDPRESS_VERSION}/namespaces/${namespace}/${WORDPRESS_PLURAL}`, + {}, + (phase: string, obj: KubernetesSiteType) => { + const id = obj.metadata?.uid; + if (!id) return; + + if (phase === "ADDED") onEvent({ type: "added", id, site: mapKubernetesToSite(obj) }); + else if (phase === "MODIFIED") onEvent({ type: "modified", id, site: mapKubernetesToSite(obj) }); + else if (phase === "DELETED") onEvent({ type: "deleted", id }); + }, + onDone, + ); +} + export async function getKubernetesSiteExtraInfo(siteId: string): Promise { try { const { k8sSite, error } = await findKubernetesSiteByUid(siteId); diff --git a/src/lib/sse.ts b/src/lib/sse.ts new file mode 100644 index 00000000..d5d927dc --- /dev/null +++ b/src/lib/sse.ts @@ -0,0 +1,67 @@ +const SSE_KEEPALIVE_MS = 15000; + +const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", +} as const; + +export interface SSEConnection { + send(event: string, data: unknown): void; + comment(text: string): void; + close(): void; +} + +export type SSETeardown = () => void; +export type SSESetup = (connection: SSEConnection) => SSETeardown | undefined | Promise; + +export function createSSEStream(request: Request, setup: SSESetup): Response { + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + let closed = false; + let teardown: SSETeardown | undefined; + let keepAlive: ReturnType; + + const cleanup = () => { + if (closed) return; + closed = true; + clearInterval(keepAlive); + request.signal.removeEventListener("abort", cleanup); + try { + teardown?.(); + } catch {} + try { + controller.close(); + } catch {} + }; + + const write = (chunk: string) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(chunk)); + } catch { + cleanup(); + } + }; + + const connection: SSEConnection = { + send: (event, data) => write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + comment: (text) => write(`: ${text}\n\n`), + close: cleanup, + }; + + keepAlive = setInterval(() => connection.comment("keepalive"), SSE_KEEPALIVE_MS); + request.signal.addEventListener("abort", cleanup); + + try { + teardown = await setup(connection); + } catch { + cleanup(); + } + }, + }); + + return new Response(stream, { headers: SSE_HEADERS }); +} diff --git a/src/messages/en.json b/src/messages/en.json index 8c854741..da2eb313 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -40,6 +40,8 @@ "info": "Information", "editTags": "Edit Tags", "unmanaged": "Unmanaged", + "deleting": "Deleting", + "creating": "Creating", "create": { "label": "Create", "success": "Website created successfully", diff --git a/src/messages/fr.json b/src/messages/fr.json index 34820ed5..acd99e0f 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -40,6 +40,8 @@ "info": "Informations", "editTags": "Modifier les tags", "unmanaged": "Non géré", + "deleting": "En suppression", + "creating": "En création", "create": { "label": "Créer", "success": "Site créé avec succès", diff --git a/src/types/site.ts b/src/types/site.ts index 5f79d26c..8165361c 100644 --- a/src/types/site.ts +++ b/src/types/site.ts @@ -39,6 +39,8 @@ interface BaseSite extends SiteExtras { id: string; url: string; createdAt: Date; + deletedAt?: Date; + creating?: boolean; tags: string[]; managed: boolean; } @@ -63,6 +65,12 @@ export interface DatabaseSite extends BaseSite { export type Site = KubernetesSite | DatabaseSite; +// --- Real-time lifecycle events (streamed over SSE) --- +// Declarative: added/modified always carry the full site (with its `creating`/`deletedAt` +// flags), so the client reconciles state from the payload and self-heals on watch reconnect. + +export type SiteEvent = { type: "added"; id: string; site: Site } | { type: "modified"; id: string; site: Site } | { type: "deleted"; id: string }; + // --- Type guards --- // Temporary sites run in K8s (managed by wp-kleenex) but have persistence "none". // isKubernetesSite includes them since they share the same data shape.