Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ops/inventory/prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ all:
route:
host: wp-veritas.epfl.ch
availability: public
tag: 7.0.3
tag: 7.0.4
2 changes: 1 addition & 1 deletion ops/inventory/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ all:
route:
host: wp-veritas-test.epfl.ch
availability: private
tag: 7.0.3
tag: 7.0.4
141 changes: 92 additions & 49 deletions src/app/(root)/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -22,21 +24,46 @@ 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";

export default function SiteListPage() {
const ability = useAbility();
const searchParams = useSearchParams();
const [sites, setSites] = useState<Site[]>([]);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());

useEffect(() => {
listSites().then(({ sites: data }) => {
if (data) setSites(data);
});
}, []);

const removeFromSet = (set: Set<string>, id: string): Set<string> => {
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") || "",
Expand Down Expand Up @@ -248,20 +275,36 @@ export default function SiteListPage() {
width: "w-[55%]",
align: "left",
sortable: true,
render: (site) => (
<div className="flex items-center justify-between gap-2">
<a href={site.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 font-medium text-blue-600 hover:underline">
<GlobeIcon className="size-6 shrink-0" />
<span className="text-base font-medium leading-relaxed">{site.url}</span>
</a>
{!site.managed && (
<div className="flex items-center gap-1 text-yellow-500 px-2 py-1">
<AlertTriangle className="size-5" />
<p className="font-medium">{translations.site("unmanaged")}</p>
</div>
)}
</div>
),
render: (site) => {
const isDeleting = deletingIds.has(site.id) || Boolean(site.deletedAt);
const isCreating = Boolean(site.creating);
return (
<div className="flex items-center justify-between gap-2">
<a href={site.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 font-medium text-blue-600 hover:underline">
<GlobeIcon className="size-6 shrink-0" />
<span className="text-base font-medium leading-relaxed">{site.url}</span>
</a>
{isDeleting ? (
<div className="flex items-center gap-1 text-red-500 px-2 py-1">
<Loader2 className="size-5 animate-spin" />
<p className="font-medium">{translations.site("deleting")}</p>
</div>
) : isCreating ? (
<div className="flex items-center gap-1 text-blue-500 px-2 py-1">
<Loader2 className="size-5 animate-spin" />
<p className="font-medium">{translations.site("creating")}</p>
</div>
) : (
!site.managed && (
<div className="flex items-center gap-1 text-yellow-500 px-2 py-1">
<AlertTriangle className="size-5" />
<p className="font-medium">{translations.site("unmanaged")}</p>
</div>
)
)}
</div>
);
},
},
{
key: "infrastructure",
Expand Down Expand Up @@ -300,42 +343,42 @@ export default function SiteListPage() {
width: "w-[15%]",
align: "left",
sortable: false,
render: (site) => (
<div className="flex gap-1.5 items-center py-1">
{ability.can("read", "Site") && (
<Button variant="outline" className="p-1 w-9 h-9 border-2 border-green-500 text-green-600 hover:text-green-600 hover:bg-green-100" asChild>
<Link prefetch={false} href={`/search?url=${site.url}`}>
render: (site) => {
const isDeleting = deletingIds.has(site.id) || Boolean(site.deletedAt);
const isCreating = Boolean(site.creating);
const locked = isDeleting || isCreating;
return (
<div className="flex gap-1.5 items-center py-1">
{ability.can("read", "Site") && (
<ActionLink href={`/search?url=${site.url}`} locked={locked} className="p-1 w-9 h-9 border-2 border-green-500 text-green-600 hover:text-green-600 hover:bg-green-100">
<Info strokeWidth={2.3} className="size-5" />
</Link>
</Button>
)}
{ability.can("update", "Site") && (
<Button variant="outline" className="p-1 w-9 h-9 border-2 border-blue-400 text-blue-600 hover:text-blue-600 hover:bg-blue-100" asChild>
<Link prefetch={false} href={`/sites/${site.id}/edit`}>
</ActionLink>
)}
{ability.can("update", "Site") && (
<ActionLink href={`/sites/${site.id}/edit`} locked={locked} className="p-1 w-9 h-9 border-2 border-blue-400 text-blue-600 hover:text-blue-600 hover:bg-blue-100">
<Pencil strokeWidth={2.3} className="size-5" />
</Link>
</Button>
)}
{ability.can("associate", "Tag") && (
<Button variant="outline" className="p-1 w-9 h-9 border-2 border-blue-400 text-blue-600 hover:text-blue-600 hover:bg-blue-100" asChild>
<Link prefetch={false} href={`/sites/${site.id}/tags`}>
</ActionLink>
)}
{ability.can("associate", "Tag") && (
<ActionLink href={`/sites/${site.id}/tags`} locked={locked} className="p-1 w-9 h-9 border-2 border-blue-400 text-blue-600 hover:text-blue-600 hover:bg-blue-100">
<Tags strokeWidth={2.3} className="size-5" />
</Link>
</Button>
)}
{ability.can("delete", "Site") && (
<DeleteDialog
icon={GlobeIcon}
displayName={site.url}
type="site"
onDelete={async () => {
await deleteSiteAction(site.id);
setSites((prev) => prev.filter((s) => s.id !== site.id));
}}
/>
)}
</div>
),
</ActionLink>
)}
{ability.can("delete", "Site") && (
<DeleteDialog
icon={GlobeIcon}
displayName={site.url}
type="site"
disabled={locked}
onDelete={async () => {
await deleteSiteAction(site.id);
setDeletingIds((prev) => new Set([...prev, site.id]));
}}
/>
)}
</div>
);
},
},
];

Expand Down
19 changes: 19 additions & 0 deletions src/app/api/sites/events/route.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
7 changes: 4 additions & 3 deletions src/components/dialog/delete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ interface DeleteDialogProps {
itemCount?: number;
triggerText?: string;
isPlural?: boolean;
disabled?: boolean;
}

export const DeleteDialog: React.FC<DeleteDialogProps> = ({ displayName, type, icon, onDelete, onBulkDelete, itemCount, triggerText, isPlural = false }) => {
export const DeleteDialog: React.FC<DeleteDialogProps> = ({ 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<string | null>(null);
Expand Down Expand Up @@ -54,12 +55,12 @@ export const DeleteDialog: React.FC<DeleteDialogProps> = ({ displayName, type, i
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{triggerText ? (
<Button variant="destructive" className="flex items-center space-x-1">
<Button variant="destructive" className="flex items-center space-x-1" disabled={disabled}>
<Trash2 className="w-4 h-4" />
<span>{triggerText}</span>
</Button>
) : (
<Button variant="outline" className="p-1 w-9 h-9 cursor-pointer border-2 border-red-400 text-red-600 hover:text-red-600 hover:bg-red-100">
<Button variant="outline" className="p-1 w-9 h-9 cursor-pointer border-2 border-red-400 text-red-600 hover:text-red-600 hover:bg-red-100" disabled={disabled}>
<Trash2 strokeWidth={2.3} className="size-5" />
</Button>
)}
Expand Down
27 changes: 27 additions & 0 deletions src/components/ui/action.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button variant="outline" className={className} disabled>
{children}
</Button>
);
}
return (
<Button variant="outline" className={className} asChild>
<Link prefetch={false} href={href}>
{children}
</Link>
</Button>
);
}
25 changes: 25 additions & 0 deletions src/hooks/useServerEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use client";
import { useEffect, useRef } from "react";

export function useServerEvents<E extends Record<string, unknown>>(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]);
}
24 changes: 23 additions & 1 deletion src/lib/kubernetes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -13,6 +13,7 @@ interface KubernetesSiteType {
namespace: string;
labels?: Record<string, string>;
creationTimestamp: string;
deletionTimestamp?: string;
};
spec: {
owner: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<KubernetesSiteExtraInfo> {
try {
const { k8sSite, error } = await findKubernetesSiteByUid(siteId);
Expand Down
Loading