@hgdc/ui 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-shell.d.ts +7 -1
- package/dist/app-shell.js +3 -2
- package/dist/resource-list.d.ts +1 -1
- package/dist/resource-list.js +1 -1
- package/dist/service-status.d.ts +54 -0
- package/dist/service-status.js +199 -0
- package/package.json +1 -1
package/dist/app-shell.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export declare function SidebarNavItem({ icon, label, active, href, render, }: {
|
|
|
33
33
|
/** Lien du routeur : `render={<Link to="…" />}`. */
|
|
34
34
|
render?: React.ReactElement;
|
|
35
35
|
}): React.JSX.Element;
|
|
36
|
-
export declare function AppShell({ product, organization, productRail, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating, mainClassName, }: {
|
|
36
|
+
export declare function AppShell({ product, organization, productRail, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating, mainClassName, statusPage, }: {
|
|
37
37
|
/** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
|
|
38
38
|
product: FamilyProduct;
|
|
39
39
|
/** `OrgSwitcher` : en haut de la barre des produits, en haut du tiroir sur téléphone. */
|
|
@@ -81,4 +81,10 @@ export declare function AppShell({ product, organization, productRail, accountHr
|
|
|
81
81
|
* une page étroite passe par `Page` (`@hgdc/ui/page-header`), pas par la coque.
|
|
82
82
|
*/
|
|
83
83
|
mainClassName?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Page de statut du produit, dont le bandeau d'état se pose en tête du contenu (ADR 0018) :
|
|
86
|
+
* `https://status.hgdc.ch/<produit>` par défaut, une autre adresse (prévisualisation sur une page
|
|
87
|
+
* de test), ou `false` pour ne rien lire.
|
|
88
|
+
*/
|
|
89
|
+
statusPage?: string | false | undefined;
|
|
84
90
|
}): React.JSX.Element;
|
package/dist/app-shell.js
CHANGED
|
@@ -17,6 +17,7 @@ import { HGDC_ACCOUNT, HGDC_PRODUCTS } from "./products.js";
|
|
|
17
17
|
import { ProductRailShownContext, ShellLeaveContext } from "./shell-placement.js";
|
|
18
18
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
|
|
19
19
|
import { cn } from "./cn.js";
|
|
20
|
+
import { ServiceStatus, statusPageUrlFor } from "./service-status.js";
|
|
20
21
|
// ── Rail : la barre latérale réduite aux icônes, mémorisée par navigateur ──────────────
|
|
21
22
|
const RAIL_KEY = "sidebar";
|
|
22
23
|
const RAIL_EVENT = "sidebarchange";
|
|
@@ -173,7 +174,7 @@ function useContentLoading({ navigating, pending }) {
|
|
|
173
174
|
}, [active, visible]);
|
|
174
175
|
return visible;
|
|
175
176
|
}
|
|
176
|
-
export function AppShell({ product, organization, productRail = true, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating = false, mainClassName, }) {
|
|
177
|
+
export function AppShell({ product, organization, productRail = true, accountHref, nav, sidebarFooter, header, onSearch, searchLabel, children, contentKey, navigating = false, mainClassName, statusPage, }) {
|
|
177
178
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
178
179
|
// Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
|
|
179
180
|
const [railPeek, setRailPeek] = useState(false);
|
|
@@ -211,5 +212,5 @@ export function AppShell({ product, organization, productRail = true, accountHre
|
|
|
211
212
|
if (loading && contentRef.current)
|
|
212
213
|
contentRef.current.scrollTop = 0;
|
|
213
214
|
}, [loading]);
|
|
214
|
-
return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)]" : "lg:grid-cols-[auto_minmax(0,1fr)]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }),
|
|
215
|
+
return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false) }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)]" : "lg:grid-cols-[auto_minmax(0,1fr)]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek })) : null, _jsxs("aside", { "aria-label": "Barre lat\u00E9rale", className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, sidebarFooter ? _jsx("div", { className: "grid gap-0.5 px-3 pt-1 pb-3", children: sidebarFooter }) : null] }), _jsx("div", { className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: productName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsxs("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: [_jsx(ServiceStatus, { pageUrl: statusPage === false ? null : (statusPage ?? statusPageUrlFor(product)), className: "mb-6" }), _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) })] })] }) }), hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, sidebarFooter ? (_jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)]", children: sidebarFooter })) : null] }) })] }) }) }) }));
|
|
215
216
|
}
|
package/dist/resource-list.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export declare function ResourceItem({ icon, title, meta, badge, actions, href,
|
|
|
15
15
|
/** Icône, favicon ou marque, dans une tuile de 40 px. */
|
|
16
16
|
icon?: ReactNode;
|
|
17
17
|
title: ReactNode;
|
|
18
|
-
/**
|
|
18
|
+
/** Contexte : adresse, date, auteur. Deux lignes au plus, puis tronquée. */
|
|
19
19
|
meta?: ReactNode;
|
|
20
20
|
badge?: ReactNode;
|
|
21
21
|
/** Boutons-icônes ou menu « … ». */
|
package/dist/resource-list.js
CHANGED
|
@@ -17,7 +17,7 @@ export function ResourceList({ className, surface = true, ...props }) {
|
|
|
17
17
|
*/
|
|
18
18
|
export function ResourceItem({ icon, title, meta, badge, actions, href, render, className, }) {
|
|
19
19
|
const linked = href !== undefined || render !== undefined;
|
|
20
|
-
return (_jsxs("li", { "data-slot": "resource-item", className: cn("relative flex min-h-16 items-center gap-3 px-4 py-3 transition-colors duration-(--duration-fast) ease-out not-first:border-t not-first:border-foreground/5 sm:gap-4 sm:px-5", linked && "hover:bg-foreground/[0.03] has-focus-visible:bg-foreground/[0.03]", className), children: [icon ? (_jsx("span", { "aria-hidden": true, className: "grid size-10 shrink-0 place-items-center overflow-hidden rounded-xl bg-background text-muted-foreground dark:bg-muted [&_svg:not([class*='size-'])]:size-5", children: icon })) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-2 sm:gap-x-4", children: [_jsxs("div", { className: "min-w-0 flex-[1_1_12rem]", children: [_jsx("p", { className: "
|
|
20
|
+
return (_jsxs("li", { "data-slot": "resource-item", className: cn("relative flex min-h-16 items-center gap-3 px-4 py-3 transition-colors duration-(--duration-fast) ease-out not-first:border-t not-first:border-foreground/5 sm:gap-4 sm:px-5", linked && "hover:bg-foreground/[0.03] has-focus-visible:bg-foreground/[0.03]", className), children: [icon ? (_jsx("span", { "aria-hidden": true, className: "grid size-10 shrink-0 place-items-center overflow-hidden rounded-xl bg-background text-muted-foreground dark:bg-muted [&_svg:not([class*='size-'])]:size-5", children: icon })) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-2 sm:gap-x-4", children: [_jsxs("div", { className: "min-w-0 flex-[1_1_12rem]", children: [_jsx("p", { className: "text-sm font-medium break-words", children: linked ? (_jsx(ResourceLink, { href: href, render: render, children: title })) : (title) }), meta ? _jsx("p", { className: "mt-0.5 line-clamp-2 text-xs break-words text-muted-foreground", children: meta }) : null] }), badge || actions ? (_jsxs("div", { "data-slot": "resource-item-trailing", className: "flex min-w-0 max-w-full flex-wrap items-center gap-x-3 gap-y-2 sm:gap-x-4", children: [badge ? _jsx("div", { className: "max-w-full min-w-0", children: badge }) : null, actions ? (_jsx("div", { className: "relative z-1 flex max-w-full min-w-0 flex-wrap items-center gap-1 [&>:not([class*=size-])]:h-auto [&>:not([class*=size-])]:min-h-9 [&>:not([class*=size-])]:py-1.5 [&>:not([class*=size-])]:text-center [&>:not([class*=size-])]:whitespace-normal [&>*]:max-w-full", children: actions })) : null] })) : null] })] }));
|
|
21
21
|
}
|
|
22
22
|
function ResourceLink({ href, render, children, }) {
|
|
23
23
|
return useRender({
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { FamilyProduct } from "./family-svg.ts";
|
|
2
|
+
import { type ServiceStatus } from "./status.tsx";
|
|
3
|
+
/**
|
|
4
|
+
* Bandeau d'état d'un produit (ADR 0018) : lit `summary.json` de sa page de statut (format
|
|
5
|
+
* Statuspage, CORS ouvert) depuis le navigateur, après le premier rendu, puis toutes les
|
|
6
|
+
* 2 minutes quand l'onglet est visible. Il ne retarde jamais le produit, et une lecture qui
|
|
7
|
+
* échoue n'affiche rien : jamais de fausse alerte. Pas de liseré : le point et les mots portent l'état.
|
|
8
|
+
*/
|
|
9
|
+
/** Page de statut d'un produit de la famille. Docs vit à `/documentation` (`/docs` sert l'aide de Status). */
|
|
10
|
+
export declare function statusPageUrlFor(product: FamilyProduct): string;
|
|
11
|
+
export type ServiceEvent = {
|
|
12
|
+
id: string;
|
|
13
|
+
kind: "incident" | "maintenance" | "upcoming";
|
|
14
|
+
status: ServiceStatus;
|
|
15
|
+
title: string;
|
|
16
|
+
/** « Cause identifiée · mis à jour à 14:15 », « mardi 21 octobre, de 22:00 à 23:30 ». */
|
|
17
|
+
detail: string;
|
|
18
|
+
href: string;
|
|
19
|
+
/** Change à chaque mise à jour : un bandeau fermé revient avec la suivante. */
|
|
20
|
+
version: string;
|
|
21
|
+
};
|
|
22
|
+
/** Une maintenance annoncée s'affiche 24 h avant son début. */
|
|
23
|
+
export declare const UPCOMING_NOTICE_MS: number;
|
|
24
|
+
/**
|
|
25
|
+
* Lien d'un événement, lu dans `summary.json` : jamais rendu tel quel. Seule une adresse `https:`
|
|
26
|
+
* de la même origine que la page de statut est gardée ; sinon, la page elle-même.
|
|
27
|
+
*/
|
|
28
|
+
export declare function safeEventHref(candidate: unknown, pageUrl: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Événements à montrer, d'après un `summary.json` : incidents ouverts, maintenances en cours,
|
|
31
|
+
* maintenances qui commencent dans les 24 h. Pannes d'abord. Les liens restent sur `pageUrl` (`safeEventHref`) :
|
|
32
|
+
* l'adresse annoncée par le fichier lui-même n'est jamais crue. Tout champ manquant ou d'une autre
|
|
33
|
+
* forme écarte l'événement, jamais le bandeau entier.
|
|
34
|
+
*/
|
|
35
|
+
export declare function eventsFromSummary(summary: unknown, now: number, pageUrl: string): ServiceEvent[];
|
|
36
|
+
/**
|
|
37
|
+
* Événements en cours d'une page de statut, lus après le premier rendu puis toutes les
|
|
38
|
+
* 2 minutes, onglet visible. `pageUrl` nul : rien n'est lu. Une lecture ratée vide la liste.
|
|
39
|
+
*/
|
|
40
|
+
export declare function useServiceStatus(pageUrl: string | null): {
|
|
41
|
+
events: ServiceEvent[];
|
|
42
|
+
dismiss: (event: ServiceEvent) => void;
|
|
43
|
+
};
|
|
44
|
+
/** Une ligne : point de couleur, nature et titre de l'événement, lien vers la page, fermeture. */
|
|
45
|
+
export declare function ServiceStatusBanner({ event, onDismiss, className, }: {
|
|
46
|
+
event: ServiceEvent;
|
|
47
|
+
onDismiss?: () => void;
|
|
48
|
+
className?: string;
|
|
49
|
+
}): React.JSX.Element;
|
|
50
|
+
/** Bandeaux d'un produit, posés par `AppShell` en tête du contenu. Rien tant que tout va bien. */
|
|
51
|
+
export declare function ServiceStatus({ pageUrl, className, }: {
|
|
52
|
+
pageUrl: string | null;
|
|
53
|
+
className?: string;
|
|
54
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import ArrowRight01Icon from "@hugeicons/core-free-icons/ArrowRight01Icon";
|
|
3
|
+
import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
|
|
4
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
5
|
+
import { useCallback, useEffect, useState } from "react";
|
|
6
|
+
import { cn } from "./cn.js";
|
|
7
|
+
import { StatusDot } from "./status.js";
|
|
8
|
+
/**
|
|
9
|
+
* Bandeau d'état d'un produit (ADR 0018) : lit `summary.json` de sa page de statut (format
|
|
10
|
+
* Statuspage, CORS ouvert) depuis le navigateur, après le premier rendu, puis toutes les
|
|
11
|
+
* 2 minutes quand l'onglet est visible. Il ne retarde jamais le produit, et une lecture qui
|
|
12
|
+
* échoue n'affiche rien : jamais de fausse alerte. Pas de liseré : le point et les mots portent l'état.
|
|
13
|
+
*/
|
|
14
|
+
/** Page de statut d'un produit de la famille. Docs vit à `/documentation` (`/docs` sert l'aide de Status). */
|
|
15
|
+
export function statusPageUrlFor(product) {
|
|
16
|
+
return `https://status.hgdc.ch/${product === "docs" ? "documentation" : product}`;
|
|
17
|
+
}
|
|
18
|
+
/** Une maintenance annoncée s'affiche 24 h avant son début. */
|
|
19
|
+
export const UPCOMING_NOTICE_MS = 24 * 3_600_000;
|
|
20
|
+
const STATUS_WORDS = {
|
|
21
|
+
investigating: "Analyse en cours",
|
|
22
|
+
identified: "Cause identifiée",
|
|
23
|
+
monitoring: "Sous surveillance",
|
|
24
|
+
in_progress: "En cours",
|
|
25
|
+
verifying: "Vérification",
|
|
26
|
+
};
|
|
27
|
+
const time = new Intl.DateTimeFormat("fr-CH", { timeZone: "Europe/Zurich", hour: "2-digit", minute: "2-digit" });
|
|
28
|
+
const dayMonth = new Intl.DateTimeFormat("fr-CH", {
|
|
29
|
+
timeZone: "Europe/Zurich",
|
|
30
|
+
weekday: "long",
|
|
31
|
+
day: "numeric",
|
|
32
|
+
month: "long",
|
|
33
|
+
});
|
|
34
|
+
const text = (v) => (typeof v === "string" ? v : "");
|
|
35
|
+
/**
|
|
36
|
+
* Lien d'un événement, lu dans `summary.json` : jamais rendu tel quel. Seule une adresse `https:`
|
|
37
|
+
* de la même origine que la page de statut est gardée ; sinon, la page elle-même.
|
|
38
|
+
*/
|
|
39
|
+
export function safeEventHref(candidate, pageUrl) {
|
|
40
|
+
try {
|
|
41
|
+
const page = new URL(pageUrl);
|
|
42
|
+
const url = new URL(text(candidate), page);
|
|
43
|
+
if (url.protocol === "https:" && url.origin === page.origin)
|
|
44
|
+
return url.href;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Adresse illisible : la page.
|
|
48
|
+
}
|
|
49
|
+
return pageUrl;
|
|
50
|
+
}
|
|
51
|
+
const instant = (v) => (typeof v === "string" ? Date.parse(v) : Number.NaN);
|
|
52
|
+
function incidentTone(impact) {
|
|
53
|
+
return impact === "critical" ? "outage" : "degraded";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Événements à montrer, d'après un `summary.json` : incidents ouverts, maintenances en cours,
|
|
57
|
+
* maintenances qui commencent dans les 24 h. Pannes d'abord. Les liens restent sur `pageUrl` (`safeEventHref`) :
|
|
58
|
+
* l'adresse annoncée par le fichier lui-même n'est jamais crue. Tout champ manquant ou d'une autre
|
|
59
|
+
* forme écarte l'événement, jamais le bandeau entier.
|
|
60
|
+
*/
|
|
61
|
+
export function eventsFromSummary(summary, now, pageUrl) {
|
|
62
|
+
if (!summary || typeof summary !== "object")
|
|
63
|
+
return [];
|
|
64
|
+
const root = summary;
|
|
65
|
+
const out = [];
|
|
66
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
67
|
+
for (const incident of list(root.incidents)) {
|
|
68
|
+
const id = text(incident.id);
|
|
69
|
+
const status = text(incident.status);
|
|
70
|
+
if (!id || !text(incident.name) || status === "resolved" || status === "postmortem")
|
|
71
|
+
continue;
|
|
72
|
+
const updatedAt = instant(incident.updated_at);
|
|
73
|
+
out.push({
|
|
74
|
+
id,
|
|
75
|
+
kind: "incident",
|
|
76
|
+
status: incidentTone(text(incident.impact)),
|
|
77
|
+
title: text(incident.name),
|
|
78
|
+
detail: [STATUS_WORDS[status], Number.isNaN(updatedAt) ? null : `mis à jour à ${time.format(updatedAt)}`]
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join(" · "),
|
|
81
|
+
href: safeEventHref(incident.shortlink, pageUrl),
|
|
82
|
+
version: `${status}:${text(incident.updated_at)}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
for (const m of list(root.scheduled_maintenances)) {
|
|
86
|
+
const id = text(m.id);
|
|
87
|
+
const status = text(m.status);
|
|
88
|
+
const from = instant(m.scheduled_for);
|
|
89
|
+
const until = instant(m.scheduled_until);
|
|
90
|
+
if (!id || !text(m.name) || Number.isNaN(from) || Number.isNaN(until))
|
|
91
|
+
continue;
|
|
92
|
+
const inProgress = status === "in_progress" || status === "verifying";
|
|
93
|
+
const upcoming = status === "scheduled" && from > now && from - now <= UPCOMING_NOTICE_MS;
|
|
94
|
+
if (!inProgress && !upcoming)
|
|
95
|
+
continue;
|
|
96
|
+
const sameDay = dayMonth.format(from) === dayMonth.format(until);
|
|
97
|
+
out.push({
|
|
98
|
+
id,
|
|
99
|
+
kind: inProgress ? "maintenance" : "upcoming",
|
|
100
|
+
status: "maintenance",
|
|
101
|
+
title: text(m.name),
|
|
102
|
+
detail: inProgress
|
|
103
|
+
? `jusqu'à ${time.format(until)}`
|
|
104
|
+
: sameDay
|
|
105
|
+
? `${dayMonth.format(from)}, de ${time.format(from)} à ${time.format(until)}`
|
|
106
|
+
: `du ${dayMonth.format(from)}, ${time.format(from)}, au ${dayMonth.format(until)}, ${time.format(until)}`,
|
|
107
|
+
href: safeEventHref(m.shortlink, pageUrl),
|
|
108
|
+
version: `${status}:${text(m.updated_at)}`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const order = { incident: 0, maintenance: 1, upcoming: 2 };
|
|
112
|
+
return out.sort((a, b) => order[a.kind] - order[b.kind]);
|
|
113
|
+
}
|
|
114
|
+
const REFRESH_MS = 2 * 60_000;
|
|
115
|
+
const DISMISSED = "hgdc-service-status:";
|
|
116
|
+
function dismissedKey(event) {
|
|
117
|
+
return `${DISMISSED}${event.id}:${event.version}`;
|
|
118
|
+
}
|
|
119
|
+
function isDismissed(event) {
|
|
120
|
+
try {
|
|
121
|
+
return window.sessionStorage.getItem(dismissedKey(event)) === "1";
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Événements en cours d'une page de statut, lus après le premier rendu puis toutes les
|
|
129
|
+
* 2 minutes, onglet visible. `pageUrl` nul : rien n'est lu. Une lecture ratée vide la liste.
|
|
130
|
+
*/
|
|
131
|
+
export function useServiceStatus(pageUrl) {
|
|
132
|
+
const [events, setEvents] = useState([]);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!pageUrl)
|
|
135
|
+
return;
|
|
136
|
+
let cancelled = false;
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
const read = async () => {
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch(`${pageUrl}/api/v2/summary.json`, {
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
headers: { accept: "application/json" },
|
|
143
|
+
});
|
|
144
|
+
if (!response.ok)
|
|
145
|
+
throw new Error(String(response.status));
|
|
146
|
+
const next = eventsFromSummary(await response.json(), Date.now(), pageUrl);
|
|
147
|
+
if (!cancelled)
|
|
148
|
+
setEvents(next);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
if (!cancelled)
|
|
152
|
+
setEvents([]);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
void read();
|
|
156
|
+
const id = window.setInterval(() => {
|
|
157
|
+
if (document.visibilityState === "visible")
|
|
158
|
+
void read();
|
|
159
|
+
}, REFRESH_MS);
|
|
160
|
+
return () => {
|
|
161
|
+
cancelled = true;
|
|
162
|
+
controller.abort();
|
|
163
|
+
window.clearInterval(id);
|
|
164
|
+
};
|
|
165
|
+
}, [pageUrl]);
|
|
166
|
+
const dismiss = useCallback((event) => {
|
|
167
|
+
try {
|
|
168
|
+
window.sessionStorage.setItem(dismissedKey(event), "1");
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Stockage indisponible (navigation privée) : fermé pour cette page seulement.
|
|
172
|
+
}
|
|
173
|
+
setEvents((list) => list.filter((e) => e.id !== event.id));
|
|
174
|
+
}, []);
|
|
175
|
+
return { events: events.filter((e) => !isDismissed(e)), dismiss };
|
|
176
|
+
}
|
|
177
|
+
const LEAD = {
|
|
178
|
+
incident: "Incident en cours",
|
|
179
|
+
maintenance: "Maintenance en cours",
|
|
180
|
+
upcoming: "Maintenance prévue",
|
|
181
|
+
};
|
|
182
|
+
const TINT = {
|
|
183
|
+
outage: "bg-status-outage/8",
|
|
184
|
+
degraded: "bg-status-degraded/12",
|
|
185
|
+
maintenance: "bg-status-maintenance/10",
|
|
186
|
+
operational: "bg-status-operational/10",
|
|
187
|
+
unknown: "bg-foreground/5",
|
|
188
|
+
};
|
|
189
|
+
/** Une ligne : point de couleur, nature et titre de l'événement, lien vers la page, fermeture. */
|
|
190
|
+
export function ServiceStatusBanner({ event, onDismiss, className, }) {
|
|
191
|
+
return (_jsxs("div", { role: "status", "data-slot": "service-status", className: cn("flex items-start gap-3 rounded-2xl px-4 py-2.5 text-sm sm:items-center", TINT[event.status], className), children: [_jsx(StatusDot, { status: event.status, live: event.kind !== "upcoming", className: "mt-1.5 size-2 shrink-0 sm:mt-0" }), _jsxs("p", { className: "min-w-0 flex-1 text-pretty", children: [_jsx("span", { className: "font-medium", children: LEAD[event.kind] }), _jsx("span", { className: "text-muted-foreground", children: " \u00B7 " }), event.title, event.detail ? (_jsx("span", { className: "block text-xs text-muted-foreground sm:ml-2 sm:inline", children: event.detail })) : null] }), _jsxs("a", { href: event.href, target: "_blank", rel: "noopener", className: "inline-flex shrink-0 items-center gap-1 rounded-lg font-medium underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", children: [_jsx("span", { className: "max-sm:sr-only", children: "\u00C9tat du service" }), _jsx(HugeiconsIcon, { icon: ArrowRight01Icon, className: "size-4", "aria-hidden": true })] }), onDismiss ? (_jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Masquer jusqu'\u00E0 la prochaine mise \u00E0 jour", className: "-my-1 -mr-1.5 grid size-7 shrink-0 place-items-center rounded-lg text-muted-foreground outline-none hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", children: _jsx(HugeiconsIcon, { icon: Cancel01Icon, className: "size-4", "aria-hidden": true }) })) : null] }));
|
|
192
|
+
}
|
|
193
|
+
/** Bandeaux d'un produit, posés par `AppShell` en tête du contenu. Rien tant que tout va bien. */
|
|
194
|
+
export function ServiceStatus({ pageUrl, className, }) {
|
|
195
|
+
const { events, dismiss } = useServiceStatus(pageUrl);
|
|
196
|
+
if (!events.length)
|
|
197
|
+
return null;
|
|
198
|
+
return (_jsx("div", { "data-slot": "service-status-list", className: cn("grid gap-2", className), children: events.map((event) => (_jsx(ServiceStatusBanner, { event: event, onDismiss: () => dismiss(event) }, event.id))) }));
|
|
199
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Interface de la H Collection : jetons de la DA, thèmes, polices et composants.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",
|