@sia-ui/cli 0.7.0 → 0.8.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/CHANGELOG.md +12 -0
- package/README.md +12 -0
- package/dist/registry/package/components/AccountMenu/index.tsx +107 -0
- package/dist/registry/package/components/AccountMenu/styles.css +30 -0
- package/dist/registry/package/components/ActivityLog/index.tsx +96 -25
- package/dist/registry/package/components/AmountDisplay/index.tsx +5 -2
- package/dist/registry/package/components/AppShell/index.tsx +44 -8
- package/dist/registry/package/components/AppShell/tab-bar.tsx +6 -3
- package/dist/registry/package/components/AsyncState/index.tsx +29 -7
- package/dist/registry/package/components/AuditMeta/index.tsx +12 -2
- package/dist/registry/package/components/Avatar/index.tsx +4 -9
- package/dist/registry/package/components/Badge/index.tsx +3 -2
- package/dist/registry/package/components/Badge/styles.css +4 -4
- package/dist/registry/package/components/BrandIcon/index.tsx +100 -0
- package/dist/registry/package/components/BrandIcon/styles.css +31 -0
- package/dist/registry/package/components/Breadcrumb/index.tsx +14 -2
- package/dist/registry/package/components/Button/index.tsx +3 -2
- package/dist/registry/package/components/Button/styles.css +4 -4
- package/dist/registry/package/components/Calendar/index.tsx +7 -3
- package/dist/registry/package/components/Card/index.tsx +14 -4
- package/dist/registry/package/components/ColorModeToggle/index.tsx +44 -0
- package/dist/registry/package/components/CommandPalette/index.tsx +3 -2
- package/dist/registry/package/components/ConfirmDialog/index.tsx +24 -4
- package/dist/registry/package/components/CrudPage/dialogs.tsx +53 -8
- package/dist/registry/package/components/CrudPage/index.tsx +99 -25
- package/dist/registry/package/components/CurrencyInput/index.tsx +5 -2
- package/dist/registry/package/components/DataTable/index.tsx +53 -18
- package/dist/registry/package/components/DataTable/row-actions.tsx +20 -8
- package/dist/registry/package/components/DataTable/types.ts +100 -0
- package/dist/registry/package/components/DatePicker/index.tsx +14 -6
- package/dist/registry/package/components/DateRangeFilter/index.tsx +12 -3
- package/dist/registry/package/components/DateRangePicker/index.tsx +13 -4
- package/dist/registry/package/components/DateTimePicker/index.tsx +3 -1
- package/dist/registry/package/components/Drawer/index.tsx +16 -6
- package/dist/registry/package/components/DropdownMenu/index.tsx +37 -4
- package/dist/registry/package/components/DropdownMenu/styles.css +10 -0
- package/dist/registry/package/components/DurationDisplay/index.tsx +20 -2
- package/dist/registry/package/components/EntityMeta/index.tsx +14 -9
- package/dist/registry/package/components/ErrorState/index.tsx +9 -3
- package/dist/registry/package/components/ErrorState/styles.css +1 -1
- package/dist/registry/package/components/EventCalendar/index.tsx +657 -9
- package/dist/registry/package/components/EventCalendar/styles.css +360 -8
- package/dist/registry/package/components/EventManager/index.tsx +456 -0
- package/dist/registry/package/components/EventManager/styles.css +12 -0
- package/dist/registry/package/components/Field/index.tsx +74 -27
- package/dist/registry/package/components/FileUpload/index.tsx +13 -3
- package/dist/registry/package/components/FiltersBar/index.tsx +8 -3
- package/dist/registry/package/components/FiltersBar/styles.css +7 -0
- package/dist/registry/package/components/Form/index.tsx +118 -14
- package/dist/registry/package/components/Form/styles.css +3 -0
- package/dist/registry/package/components/HoverCard/index.tsx +8 -4
- package/dist/registry/package/components/Icons/index.tsx +18 -0
- package/dist/registry/package/components/ImageUpload/index.tsx +17 -2
- package/dist/registry/package/components/Input/index.tsx +3 -2
- package/dist/registry/package/components/JobProgress/index.tsx +289 -0
- package/dist/registry/package/components/JobProgress/styles.css +58 -0
- package/dist/registry/package/components/JsonEditor/index.tsx +18 -3
- package/dist/registry/package/components/JsonView/index.tsx +282 -0
- package/dist/registry/package/components/JsonView/styles.css +99 -0
- package/dist/registry/package/components/LiveIndicator/index.tsx +43 -0
- package/dist/registry/package/components/LiveIndicator/styles.css +41 -0
- package/dist/registry/package/components/LogStream/index.tsx +545 -0
- package/dist/registry/package/components/LogStream/styles.css +152 -0
- package/dist/registry/package/components/MarkdownEditor/index.tsx +14 -3
- package/dist/registry/package/components/Modal/index.tsx +7 -1
- package/dist/registry/package/components/MultiSelect/index.tsx +14 -3
- package/dist/registry/package/components/OtpInput/index.tsx +2 -1
- package/dist/registry/package/components/Pagination/index.tsx +13 -2
- package/dist/registry/package/components/PasswordInput/index.tsx +23 -9
- package/dist/registry/package/components/PhoneInput/index.tsx +66 -30
- package/dist/registry/package/components/Popover/index.tsx +19 -4
- package/dist/registry/package/components/QrCode/index.tsx +3 -2
- package/dist/registry/package/components/RadioGroup/styles.css +3 -0
- package/dist/registry/package/components/Rating/index.tsx +6 -2
- package/dist/registry/package/components/ReferenceSelect/index.tsx +8 -1
- package/dist/registry/package/components/RelativeTime/index.tsx +5 -1
- package/dist/registry/package/components/RequireSession/index.tsx +10 -4
- package/dist/registry/package/components/Resource/index.tsx +70 -17
- package/dist/registry/package/components/RichTextEditor/index.tsx +2 -1
- package/dist/registry/package/components/SearchInput/index.tsx +4 -1
- package/dist/registry/package/components/SecretFields/index.tsx +70 -17
- package/dist/registry/package/components/Select/index.tsx +44 -12
- package/dist/registry/package/components/Select/styles.css +6 -3
- package/dist/registry/package/components/Sidebar/index.tsx +22 -6
- package/dist/registry/package/components/Slider/index.tsx +26 -5
- package/dist/registry/package/components/Spinner/index.tsx +3 -2
- package/dist/registry/package/components/StatCard/index.tsx +11 -3
- package/dist/registry/package/components/Statistic/index.tsx +5 -1
- package/dist/registry/package/components/Steps/styles.css +2 -2
- package/dist/registry/package/components/TagsInput/index.tsx +2 -1
- package/dist/registry/package/components/TimePicker/index.tsx +10 -4
- package/dist/registry/package/components/Toast/index.tsx +20 -5
- package/dist/registry/package/components/Tour/index.tsx +2 -1
- package/dist/registry/package/components/Transfer/index.tsx +2 -1
- package/dist/registry/package/components/Tree/index.tsx +3 -2
- package/dist/registry/package/components/TreeSelect/index.tsx +17 -4
- package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +34 -3
- package/dist/registry/registry.json +257 -75
- package/dist/registry/styles/sia-ui.css +23 -4
- package/package.json +5 -5
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isValidElement,
|
|
3
|
+
memo,
|
|
4
|
+
useDeferredValue,
|
|
5
|
+
useId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { cn } from "@sia-ui/utils";
|
|
13
|
+
import { plural, useSiaLocale, type LogStreamMessages } from "@sia-ui/headless";
|
|
14
|
+
import { useControllableState, useCopyToClipboard } from "@sia-ui/react";
|
|
15
|
+
import { Button, type ButtonProps } from "../Button";
|
|
16
|
+
import { EmptyState, type EmptyStateProps } from "../EmptyState";
|
|
17
|
+
import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "../Icons";
|
|
18
|
+
import { SearchInput, type SearchInputProps } from "../SearchInput";
|
|
19
|
+
import { Spinner, type SpinnerProps } from "../Spinner";
|
|
20
|
+
import {
|
|
21
|
+
Toggle,
|
|
22
|
+
ToggleGroup,
|
|
23
|
+
type ToggleGroupProps,
|
|
24
|
+
type ToggleProps,
|
|
25
|
+
} from "../Toggle";
|
|
26
|
+
import "./styles.css";
|
|
27
|
+
|
|
28
|
+
/** La gravité d'une ligne. */
|
|
29
|
+
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
|
30
|
+
|
|
31
|
+
/** Une ligne de journal. */
|
|
32
|
+
export interface LogLine {
|
|
33
|
+
/**
|
|
34
|
+
* Un identifiant stable. Facultatif, mais conseillé : sans lui, la ligne
|
|
35
|
+
* est repérée par sa position, et un tableau raccourci par le début
|
|
36
|
+
* obligerait à redessiner toutes les lignes.
|
|
37
|
+
*/
|
|
38
|
+
id?: string;
|
|
39
|
+
/** Quand : une date ISO, un horodatage ou une `Date`. */
|
|
40
|
+
time: string | number | Date;
|
|
41
|
+
level: LogLevel;
|
|
42
|
+
message: ReactNode;
|
|
43
|
+
/** L'essai, l'étape ou la tâche : « Essai 1 », « Essai 2 ». */
|
|
44
|
+
group?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Les niveaux, dans l'ordre de la barre de filtres. */
|
|
48
|
+
const NIVEAUX: LogLevel[] = ["debug", "info", "warn", "error", "success"];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Les libellés par défaut, lus dans la locale. Courts et de longueur
|
|
52
|
+
* voisine : ils tiennent dans une colonne à largeur fixe et restent lisibles
|
|
53
|
+
* une fois copiés en texte.
|
|
54
|
+
*/
|
|
55
|
+
function libellesNiveaux(m: LogStreamMessages): Record<LogLevel, string> {
|
|
56
|
+
return {
|
|
57
|
+
debug: m.levelDebug,
|
|
58
|
+
info: m.levelInfo,
|
|
59
|
+
warn: m.levelWarn,
|
|
60
|
+
error: m.levelError,
|
|
61
|
+
success: m.levelSuccess,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** En pixels : la marge sous laquelle on considère la zone « en bas ». */
|
|
66
|
+
const SEUIL_BAS = 16;
|
|
67
|
+
|
|
68
|
+
export interface LogStreamProps {
|
|
69
|
+
lines: LogLine[];
|
|
70
|
+
/**
|
|
71
|
+
* Le nombre de lignes gardées. Au-delà, les plus anciennes tombent.
|
|
72
|
+
*
|
|
73
|
+
* C'est la réponse à un journal qui grossit sans fin, plutôt qu'une
|
|
74
|
+
* virtualisation : mille lignes se dessinent sans peine, et une liste
|
|
75
|
+
* virtuelle casserait la recherche du navigateur et la sélection au
|
|
76
|
+
* clavier. `1000` par défaut.
|
|
77
|
+
*/
|
|
78
|
+
maxLines?: number;
|
|
79
|
+
/** La hauteur de la zone défilante. `20rem` par défaut. */
|
|
80
|
+
height?: number | string;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Défiler jusqu'à la dernière ligne à chaque arrivée. Contrôlé quand il
|
|
84
|
+
* est fourni ; remonter dans le journal appelle alors `onFollowChange(false)`.
|
|
85
|
+
*/
|
|
86
|
+
follow?: boolean;
|
|
87
|
+
/** Vrai par défaut : un journal d'exécution se lit par la fin. */
|
|
88
|
+
defaultFollow?: boolean;
|
|
89
|
+
onFollowChange?: (follow: boolean) => void;
|
|
90
|
+
|
|
91
|
+
/** Les niveaux affichés. Contrôlé quand il est fourni. */
|
|
92
|
+
levels?: LogLevel[];
|
|
93
|
+
/** Tous par défaut. */
|
|
94
|
+
defaultLevels?: LogLevel[];
|
|
95
|
+
onLevelsChange?: (levels: LogLevel[]) => void;
|
|
96
|
+
/** Les libellés des niveaux, dans la colonne comme dans la copie. */
|
|
97
|
+
levelLabels?: Partial<Record<LogLevel, string>>;
|
|
98
|
+
|
|
99
|
+
/** Les groupes repliés au premier affichage. */
|
|
100
|
+
defaultCollapsedGroups?: string[];
|
|
101
|
+
|
|
102
|
+
/** Le premier chargement : rien n'est encore arrivé. */
|
|
103
|
+
loading?: boolean;
|
|
104
|
+
/** Des lignes arrivent encore. Un indicateur discret le dit. */
|
|
105
|
+
streaming?: boolean;
|
|
106
|
+
streamingLabel?: string;
|
|
107
|
+
loadingLabel?: string;
|
|
108
|
+
|
|
109
|
+
/** La langue du format horaire. Par défaut, celle de la locale SIA. */
|
|
110
|
+
locale?: string;
|
|
111
|
+
/** Sans barre d'outils : ni filtre, ni recherche, ni copie. */
|
|
112
|
+
toolbar?: boolean;
|
|
113
|
+
/** Le nom de la zone pour les lecteurs d'écran. */
|
|
114
|
+
ariaLabel?: string;
|
|
115
|
+
searchPlaceholder?: string;
|
|
116
|
+
copyLabel?: string;
|
|
117
|
+
copiedLabel?: string;
|
|
118
|
+
followLabel?: string;
|
|
119
|
+
resumeLabel?: string;
|
|
120
|
+
className?: string;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Le filtre par niveau. Sans ses options ni sa valeur, tirées des niveaux,
|
|
124
|
+
* ni son type : on en retient plusieurs à la fois.
|
|
125
|
+
*/
|
|
126
|
+
levelFilterProps?: Partial<
|
|
127
|
+
Omit<ToggleGroupProps, "options" | "type" | "value" | "defaultValue" | "onValueChange">
|
|
128
|
+
>;
|
|
129
|
+
/** La recherche. Sa valeur reste celle du journal, qui filtre avec. */
|
|
130
|
+
searchInputProps?: Partial<Omit<SearchInputProps, "value" | "defaultValue" | "onValueChange">>;
|
|
131
|
+
/** Le bouton de copie. Son action et son libellé suivent la copie. */
|
|
132
|
+
copyButtonProps?: Partial<Omit<ButtonProps, "onClick" | "children">>;
|
|
133
|
+
/** La bascule « Suivre la fin ». Son état est celui du suivi. */
|
|
134
|
+
followToggleProps?: Partial<Omit<ToggleProps, "pressed" | "defaultPressed" | "onPressedChange">>;
|
|
135
|
+
/** Le bouton « Reprendre le suivi », affiché quand le suivi est en pause. */
|
|
136
|
+
resumeButtonProps?: Partial<Omit<ButtonProps, "onClick" | "children">>;
|
|
137
|
+
/**
|
|
138
|
+
* L'en-tête repliable de chaque groupe. `aria-expanded` et
|
|
139
|
+
* `aria-controls` restent calculés groupe par groupe.
|
|
140
|
+
*/
|
|
141
|
+
groupButtonProps?: Partial<
|
|
142
|
+
Omit<ButtonProps, "onClick" | "children" | "aria-expanded" | "aria-controls">
|
|
143
|
+
>;
|
|
144
|
+
/** L'état vide : pas encore de ligne, ou aucune qui corresponde. */
|
|
145
|
+
emptyStateProps?: Partial<EmptyStateProps>;
|
|
146
|
+
/** Les indicateurs de chargement et de flux en cours. Sans `label`. */
|
|
147
|
+
spinnerProps?: Partial<Omit<SpinnerProps, "label">>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Une ligne retenue, avec sa clé et sa position parmi les lignes gardées. */
|
|
151
|
+
interface Entree {
|
|
152
|
+
line: LogLine;
|
|
153
|
+
key: string;
|
|
154
|
+
pos: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Le texte d'un message, pour la recherche et la copie.
|
|
159
|
+
*
|
|
160
|
+
* Un message est un nœud React : on descend dans les enfants pour en tirer
|
|
161
|
+
* le texte. Le résultat est gardé par ligne — une ligne ne change pas une
|
|
162
|
+
* fois émise, et refaire ce parcours à chaque frappe sur mille lignes se
|
|
163
|
+
* sentirait.
|
|
164
|
+
*/
|
|
165
|
+
const textes = new WeakMap<LogLine, { brut: string; bas: string }>();
|
|
166
|
+
|
|
167
|
+
function texteDe(node: ReactNode): string {
|
|
168
|
+
if (node === null || node === undefined || typeof node === "boolean") return "";
|
|
169
|
+
if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
|
|
170
|
+
return String(node);
|
|
171
|
+
}
|
|
172
|
+
if (Array.isArray(node)) return node.map((n: ReactNode) => texteDe(n)).join("");
|
|
173
|
+
if (isValidElement<{ children?: ReactNode }>(node)) return texteDe(node.props.children);
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function texteLigne(line: LogLine) {
|
|
178
|
+
let t = textes.get(line);
|
|
179
|
+
if (!t) {
|
|
180
|
+
const brut = texteDe(line.message);
|
|
181
|
+
t = { brut, bas: brut.toLowerCase() };
|
|
182
|
+
textes.set(line, t);
|
|
183
|
+
}
|
|
184
|
+
return t;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function heure(format: Intl.DateTimeFormat, time: LogLine["time"]) {
|
|
188
|
+
const date = time instanceof Date ? time : new Date(time);
|
|
189
|
+
return Number.isNaN(date.getTime()) ? "--:--:--" : format.format(date);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface LigneProps {
|
|
193
|
+
line: LogLine;
|
|
194
|
+
format: Intl.DateTimeFormat;
|
|
195
|
+
label: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Une ligne. Mémoïsée : quand une ligne arrive, seules les nouvelles se
|
|
200
|
+
* dessinent, les mille précédentes gardent leur rendu.
|
|
201
|
+
*/
|
|
202
|
+
const Ligne = memo(function Ligne({ line, format, label }: LigneProps) {
|
|
203
|
+
const date = line.time instanceof Date ? line.time : new Date(line.time);
|
|
204
|
+
const valide = !Number.isNaN(date.getTime());
|
|
205
|
+
return (
|
|
206
|
+
<div className={cn("sia-log-stream__line", `sia-log-stream__line--${line.level}`)}>
|
|
207
|
+
<time
|
|
208
|
+
className="sia-log-stream__time"
|
|
209
|
+
{...(valide ? { dateTime: date.toISOString() } : {})}
|
|
210
|
+
>
|
|
211
|
+
{heure(format, line.time)}
|
|
212
|
+
</time>
|
|
213
|
+
{/*
|
|
214
|
+
Un marqueur texte, pas un `Badge` : il tient dans la colonne à chasse
|
|
215
|
+
fixe, se copie tel quel, et ne coûte pas un composant par ligne.
|
|
216
|
+
*/}
|
|
217
|
+
<span className="sia-log-stream__level">{label}</span>
|
|
218
|
+
<span className="sia-log-stream__message">{line.message}</span>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Un journal d'exécution qui se lit en direct.
|
|
225
|
+
*
|
|
226
|
+
* `Timeline` raconte des événements ; ici, on suit une exécution : heure en
|
|
227
|
+
* colonne, niveau, police à chasse fixe, essais repliables. Le suivi de la
|
|
228
|
+
* fin se met en pause dès qu'on remonte — lire une erreur pendant que le
|
|
229
|
+
* journal défile sous les yeux est impossible — et un bouton dit combien de
|
|
230
|
+
* lignes sont arrivées entre-temps.
|
|
231
|
+
*/
|
|
232
|
+
export function LogStream({
|
|
233
|
+
lines,
|
|
234
|
+
maxLines = 1000,
|
|
235
|
+
height = "20rem",
|
|
236
|
+
follow,
|
|
237
|
+
defaultFollow = true,
|
|
238
|
+
onFollowChange,
|
|
239
|
+
levels: levelsProp,
|
|
240
|
+
defaultLevels = NIVEAUX,
|
|
241
|
+
onLevelsChange,
|
|
242
|
+
levelLabels,
|
|
243
|
+
defaultCollapsedGroups,
|
|
244
|
+
loading = false,
|
|
245
|
+
streaming = false,
|
|
246
|
+
streamingLabel: streamingLabelProp,
|
|
247
|
+
loadingLabel: loadingLabelProp,
|
|
248
|
+
locale: localeProp,
|
|
249
|
+
toolbar = true,
|
|
250
|
+
ariaLabel: ariaLabelProp,
|
|
251
|
+
searchPlaceholder: searchPlaceholderProp,
|
|
252
|
+
copyLabel: copyLabelProp,
|
|
253
|
+
copiedLabel: copiedLabelProp,
|
|
254
|
+
followLabel: followLabelProp,
|
|
255
|
+
resumeLabel: resumeLabelProp,
|
|
256
|
+
className,
|
|
257
|
+
levelFilterProps,
|
|
258
|
+
searchInputProps,
|
|
259
|
+
copyButtonProps,
|
|
260
|
+
followToggleProps,
|
|
261
|
+
resumeButtonProps,
|
|
262
|
+
groupButtonProps,
|
|
263
|
+
emptyStateProps,
|
|
264
|
+
spinnerProps,
|
|
265
|
+
}: LogStreamProps) {
|
|
266
|
+
const messages = useSiaLocale();
|
|
267
|
+
const m = messages.logStream;
|
|
268
|
+
const locale = localeProp ?? messages.language;
|
|
269
|
+
const streamingLabel = streamingLabelProp ?? m.streaming;
|
|
270
|
+
const loadingLabel = loadingLabelProp ?? m.loading;
|
|
271
|
+
const ariaLabel = ariaLabelProp ?? m.ariaLabel;
|
|
272
|
+
const searchPlaceholder = searchPlaceholderProp ?? m.searchPlaceholder;
|
|
273
|
+
const copyLabel = copyLabelProp ?? messages.copy;
|
|
274
|
+
const copiedLabel = copiedLabelProp ?? messages.copied;
|
|
275
|
+
const followLabel = followLabelProp ?? m.follow;
|
|
276
|
+
const resumeLabel = resumeLabelProp ?? m.resume;
|
|
277
|
+
const id = useId();
|
|
278
|
+
const zone = useRef<HTMLDivElement>(null);
|
|
279
|
+
const { copy, copied } = useCopyToClipboard();
|
|
280
|
+
|
|
281
|
+
const [following, setFollowing] = useControllableState({
|
|
282
|
+
value: follow,
|
|
283
|
+
defaultValue: defaultFollow,
|
|
284
|
+
onChange: onFollowChange,
|
|
285
|
+
});
|
|
286
|
+
const [levels, setLevels] = useControllableState<LogLevel[]>({
|
|
287
|
+
value: levelsProp,
|
|
288
|
+
defaultValue: defaultLevels,
|
|
289
|
+
onChange: onLevelsChange,
|
|
290
|
+
});
|
|
291
|
+
const [search, setSearch] = useState("");
|
|
292
|
+
const [collapsed, setCollapsed] = useState(() => new Set(defaultCollapsedGroups));
|
|
293
|
+
|
|
294
|
+
// La frappe reste immédiate ; le filtrage de mille lignes passe après.
|
|
295
|
+
const needle = useDeferredValue(search).trim().toLowerCase();
|
|
296
|
+
|
|
297
|
+
const labels = useMemo(() => ({ ...libellesNiveaux(m), ...levelLabels }), [m, levelLabels]);
|
|
298
|
+
|
|
299
|
+
// Un seul format pour tout le journal : `Intl.DateTimeFormat` coûte cher à
|
|
300
|
+
// construire, pas à utiliser.
|
|
301
|
+
const format = useMemo(
|
|
302
|
+
() =>
|
|
303
|
+
new Intl.DateTimeFormat(locale, {
|
|
304
|
+
hour: "2-digit",
|
|
305
|
+
minute: "2-digit",
|
|
306
|
+
second: "2-digit",
|
|
307
|
+
hourCycle: "h23",
|
|
308
|
+
}),
|
|
309
|
+
[locale],
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Les lignes gardées, avec une clé stable : l'identifiant, sinon la
|
|
313
|
+
// position dans le tableau complet — elle ne bouge pas tant qu'on ajoute
|
|
314
|
+
// à la fin.
|
|
315
|
+
const kept = useMemo(() => {
|
|
316
|
+
const debut = Math.max(0, lines.length - Math.max(0, maxLines));
|
|
317
|
+
const out: Entree[] = [];
|
|
318
|
+
for (let i = debut; i < lines.length; i++) {
|
|
319
|
+
const line = lines[i]!;
|
|
320
|
+
out.push({ line, key: line.id ?? `#${i}`, pos: i - debut });
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}, [lines, maxLines]);
|
|
324
|
+
|
|
325
|
+
const levelSet = useMemo(() => new Set(levels), [levels]);
|
|
326
|
+
|
|
327
|
+
const visible = useMemo(
|
|
328
|
+
() =>
|
|
329
|
+
kept.filter(
|
|
330
|
+
({ line }) =>
|
|
331
|
+
levelSet.has(line.level) && (!needle || texteLigne(line).bas.includes(needle)),
|
|
332
|
+
),
|
|
333
|
+
[kept, levelSet, needle],
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// Les groupes, dans l'ordre de leur première ligne. Les lignes sans groupe
|
|
337
|
+
// forment une section sans en-tête.
|
|
338
|
+
const sections = useMemo(() => {
|
|
339
|
+
const map = new Map<string, Entree[]>();
|
|
340
|
+
for (const entree of visible) {
|
|
341
|
+
const g = entree.line.group ?? "";
|
|
342
|
+
const list = map.get(g);
|
|
343
|
+
if (list) list.push(entree);
|
|
344
|
+
else map.set(g, [entree]);
|
|
345
|
+
}
|
|
346
|
+
return [...map];
|
|
347
|
+
}, [visible]);
|
|
348
|
+
|
|
349
|
+
// Le repère de la pause : la dernière ligne vue au moment où l'on a
|
|
350
|
+
// remonté. Ajusté pendant le rendu, comme le recommande React pour un état
|
|
351
|
+
// dérivé, plutôt que dans un effet qui dessinerait une image de trop.
|
|
352
|
+
const derniere = kept.length > 0 ? kept[kept.length - 1]!.key : "";
|
|
353
|
+
const [repere, setRepere] = useState<string | null>(following ? null : derniere);
|
|
354
|
+
if (following && repere !== null) setRepere(null);
|
|
355
|
+
if (!following && repere === null) setRepere(derniere);
|
|
356
|
+
|
|
357
|
+
const nouvelles = useMemo(() => {
|
|
358
|
+
if (following || repere === null) return 0;
|
|
359
|
+
let position = -1;
|
|
360
|
+
for (let i = kept.length - 1; i >= 0; i--) {
|
|
361
|
+
if (kept[i]!.key === repere) {
|
|
362
|
+
position = kept[i]!.pos;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
let n = 0;
|
|
367
|
+
for (let i = visible.length - 1; i >= 0 && visible[i]!.pos > position; i--) n++;
|
|
368
|
+
return n;
|
|
369
|
+
}, [following, repere, kept, visible]);
|
|
370
|
+
|
|
371
|
+
// Avant la peinture : l'utilisateur ne voit jamais la ligne apparaître
|
|
372
|
+
// puis la zone sauter.
|
|
373
|
+
const derniereVisible = visible.length > 0 ? visible[visible.length - 1]!.key : "";
|
|
374
|
+
useLayoutEffect(() => {
|
|
375
|
+
const node = zone.current;
|
|
376
|
+
if (following && node) node.scrollTop = node.scrollHeight;
|
|
377
|
+
}, [following, derniereVisible, visible.length, collapsed]);
|
|
378
|
+
|
|
379
|
+
const onScroll = () => {
|
|
380
|
+
const node = zone.current;
|
|
381
|
+
if (!node) return;
|
|
382
|
+
const enBas = node.scrollHeight - node.scrollTop - node.clientHeight <= SEUIL_BAS;
|
|
383
|
+
// Remonter met en pause ; revenir tout en bas reprend le suivi.
|
|
384
|
+
if (enBas !== following) setFollowing(enBas);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const basculer = (groupe: string) =>
|
|
388
|
+
setCollapsed((prev) => {
|
|
389
|
+
const next = new Set(prev);
|
|
390
|
+
if (next.has(groupe)) next.delete(groupe);
|
|
391
|
+
else next.add(groupe);
|
|
392
|
+
return next;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Construit au clic seulement : personne ne paie la copie qu'il ne fait pas.
|
|
396
|
+
const copier = () => {
|
|
397
|
+
const out: string[] = [];
|
|
398
|
+
for (const [groupe, entrees] of sections) {
|
|
399
|
+
if (groupe) out.push(`[${groupe}]`);
|
|
400
|
+
for (const { line } of entrees) {
|
|
401
|
+
out.push(`${heure(format, line.time)} ${labels[line.level]} ${texteLigne(line).brut}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
void copy(out.join("\n"));
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const vide = kept.length === 0;
|
|
408
|
+
|
|
409
|
+
let contenu: ReactNode;
|
|
410
|
+
if (vide && loading) {
|
|
411
|
+
contenu = (
|
|
412
|
+
<div className="sia-log-stream__loading">
|
|
413
|
+
<Spinner size="sm" {...spinnerProps} label={loadingLabel} />
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
} else if (visible.length === 0) {
|
|
417
|
+
contenu = (
|
|
418
|
+
<EmptyState
|
|
419
|
+
compact
|
|
420
|
+
{...(vide
|
|
421
|
+
? { title: m.emptyTitle }
|
|
422
|
+
: {
|
|
423
|
+
title: m.noMatchTitle,
|
|
424
|
+
description: m.noMatchDescription,
|
|
425
|
+
})}
|
|
426
|
+
{...emptyStateProps}
|
|
427
|
+
/>
|
|
428
|
+
);
|
|
429
|
+
} else {
|
|
430
|
+
contenu = sections.map(([groupe, entrees], index) => {
|
|
431
|
+
const rendu = entrees.map(({ line, key }) => (
|
|
432
|
+
<Ligne key={key} line={line} format={format} label={labels[line.level]} />
|
|
433
|
+
));
|
|
434
|
+
if (!groupe) return <div key="">{rendu}</div>;
|
|
435
|
+
const ouvert = !collapsed.has(groupe);
|
|
436
|
+
const corps = `${id}-groupe-${index}`;
|
|
437
|
+
return (
|
|
438
|
+
<div key={`g:${groupe}`} className="sia-log-stream__group">
|
|
439
|
+
<Button
|
|
440
|
+
variant="ghost"
|
|
441
|
+
size="sm"
|
|
442
|
+
leftIcon={ouvert ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
|
443
|
+
{...groupButtonProps}
|
|
444
|
+
className={cn("sia-log-stream__group-header", groupButtonProps?.className)}
|
|
445
|
+
aria-expanded={ouvert}
|
|
446
|
+
aria-controls={corps}
|
|
447
|
+
onClick={() => basculer(groupe)}
|
|
448
|
+
>
|
|
449
|
+
{groupe}
|
|
450
|
+
<span className="sia-log-stream__group-count">
|
|
451
|
+
{plural(m, "lines", entrees.length, locale)}
|
|
452
|
+
</span>
|
|
453
|
+
</Button>
|
|
454
|
+
<div id={corps} hidden={!ouvert}>
|
|
455
|
+
{ouvert && rendu}
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<div className={cn("sia-log-stream", className)}>
|
|
464
|
+
{toolbar && (
|
|
465
|
+
<div className="sia-log-stream__toolbar">
|
|
466
|
+
<ToggleGroup
|
|
467
|
+
size="md"
|
|
468
|
+
ariaLabel={m.levelsLabel}
|
|
469
|
+
{...levelFilterProps}
|
|
470
|
+
className={cn("sia-log-stream__levels", levelFilterProps?.className)}
|
|
471
|
+
type="multiple"
|
|
472
|
+
options={NIVEAUX.map((niveau) => ({ value: niveau, label: labels[niveau] }))}
|
|
473
|
+
value={levels}
|
|
474
|
+
onValueChange={(next) => setLevels((Array.isArray(next) ? next : [next]) as LogLevel[])}
|
|
475
|
+
/>
|
|
476
|
+
<SearchInput
|
|
477
|
+
placeholder={searchPlaceholder}
|
|
478
|
+
{...searchInputProps}
|
|
479
|
+
className={cn("sia-log-stream__search", searchInputProps?.className)}
|
|
480
|
+
value={search}
|
|
481
|
+
onValueChange={setSearch}
|
|
482
|
+
/>
|
|
483
|
+
<div className="sia-log-stream__actions">
|
|
484
|
+
{streaming && (
|
|
485
|
+
<span className="sia-log-stream__streaming">
|
|
486
|
+
<Spinner size="xs" {...spinnerProps} label={streamingLabel} />
|
|
487
|
+
<span aria-hidden="true">{streamingLabel}</span>
|
|
488
|
+
</span>
|
|
489
|
+
)}
|
|
490
|
+
<Toggle
|
|
491
|
+
size="md"
|
|
492
|
+
variant="outline"
|
|
493
|
+
{...followToggleProps}
|
|
494
|
+
pressed={following}
|
|
495
|
+
onPressedChange={setFollowing}
|
|
496
|
+
>
|
|
497
|
+
{followLabel}
|
|
498
|
+
</Toggle>
|
|
499
|
+
<Button
|
|
500
|
+
variant="outline"
|
|
501
|
+
size="md"
|
|
502
|
+
{...(copied ? { leftIcon: <CheckIcon /> } : {})}
|
|
503
|
+
disabled={visible.length === 0}
|
|
504
|
+
{...copyButtonProps}
|
|
505
|
+
onClick={copier}
|
|
506
|
+
>
|
|
507
|
+
{copied ? copiedLabel : copyLabel}
|
|
508
|
+
</Button>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
)}
|
|
512
|
+
<div className="sia-log-stream__viewport">
|
|
513
|
+
{/*
|
|
514
|
+
Une `div` et non `ScrollArea` : le suivi lit et règle la position de
|
|
515
|
+
défilement, et `ScrollArea` n'expose ni sa référence ni son
|
|
516
|
+
événement de défilement.
|
|
517
|
+
*/}
|
|
518
|
+
<div
|
|
519
|
+
ref={zone}
|
|
520
|
+
className="sia-log-stream__scroll"
|
|
521
|
+
style={{ height }}
|
|
522
|
+
role="log"
|
|
523
|
+
aria-live="polite"
|
|
524
|
+
aria-label={ariaLabel}
|
|
525
|
+
aria-busy={loading || undefined}
|
|
526
|
+
tabIndex={0}
|
|
527
|
+
onScroll={onScroll}
|
|
528
|
+
>
|
|
529
|
+
{contenu}
|
|
530
|
+
</div>
|
|
531
|
+
{!following && (
|
|
532
|
+
<Button
|
|
533
|
+
size="sm"
|
|
534
|
+
{...resumeButtonProps}
|
|
535
|
+
className={cn("sia-log-stream__resume", resumeButtonProps?.className)}
|
|
536
|
+
onClick={() => setFollowing(true)}
|
|
537
|
+
>
|
|
538
|
+
{resumeLabel}
|
|
539
|
+
{nouvelles > 0 && plural(m, "newLines", nouvelles, locale)}
|
|
540
|
+
</Button>
|
|
541
|
+
)}
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/* Un journal se lit en colonnes : heure, niveau, message. La chasse fixe
|
|
2
|
+
garde les colonnes alignées d'une ligne à l'autre, et un message long
|
|
3
|
+
passe à la ligne sous lui-même sans déborder sous l'heure. */
|
|
4
|
+
.sia-log-stream {
|
|
5
|
+
container: sia-log-stream / inline-size;
|
|
6
|
+
display: grid;
|
|
7
|
+
gap: .5rem;
|
|
8
|
+
min-width: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.sia-log-stream__toolbar {
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-wrap: wrap;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: .5rem;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.sia-log-stream__search { flex: 1 1 12rem; min-width: 0; }
|
|
19
|
+
|
|
20
|
+
/* Étirés : la bascule, moins haute que les boutons à taille égale, prend la
|
|
21
|
+
hauteur de la barre et s'aligne sur la recherche. */
|
|
22
|
+
.sia-log-stream__actions {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-wrap: wrap;
|
|
25
|
+
align-items: stretch;
|
|
26
|
+
gap: .5rem;
|
|
27
|
+
margin-inline-start: auto;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.sia-log-stream__streaming {
|
|
31
|
+
align-self: center;
|
|
32
|
+
display: inline-flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
gap: .375rem;
|
|
35
|
+
color: var(--sia-muted);
|
|
36
|
+
font-size: .8rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.sia-log-stream__viewport { position: relative; min-width: 0; }
|
|
40
|
+
|
|
41
|
+
.sia-log-stream__scroll {
|
|
42
|
+
overflow: auto;
|
|
43
|
+
overscroll-behavior: contain;
|
|
44
|
+
padding: .375rem 0;
|
|
45
|
+
border: 1px solid var(--sia-border);
|
|
46
|
+
border-radius: var(--sia-radius);
|
|
47
|
+
color: var(--sia-foreground);
|
|
48
|
+
background: var(--sia-surface);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.sia-log-stream__scroll:focus-visible {
|
|
52
|
+
outline: 2px solid color-mix(in srgb, var(--sia-primary) 45%, transparent);
|
|
53
|
+
outline-offset: 2px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Heure et niveau en `ch` : leur largeur ne dépend que du nombre de
|
|
57
|
+
caractères, jamais du contenu d'une ligne. */
|
|
58
|
+
.sia-log-stream__line {
|
|
59
|
+
display: grid;
|
|
60
|
+
grid-template-columns: 8ch 6ch minmax(0, 1fr);
|
|
61
|
+
column-gap: 1.25ch;
|
|
62
|
+
padding: .0625rem .75rem;
|
|
63
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
64
|
+
font-size: .8rem;
|
|
65
|
+
line-height: 1.5;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.sia-log-stream__line:hover { background: color-mix(in srgb, var(--sia-foreground) 4%, transparent); }
|
|
69
|
+
|
|
70
|
+
.sia-log-stream__time { color: var(--sia-muted); font-variant-numeric: tabular-nums; }
|
|
71
|
+
|
|
72
|
+
.sia-log-stream__level { font-weight: 600; }
|
|
73
|
+
|
|
74
|
+
.sia-log-stream__message {
|
|
75
|
+
min-width: 0;
|
|
76
|
+
white-space: pre-wrap;
|
|
77
|
+
overflow-wrap: anywhere;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.sia-log-stream__line--debug .sia-log-stream__level,
|
|
81
|
+
.sia-log-stream__line--debug .sia-log-stream__message { color: var(--sia-muted); }
|
|
82
|
+
.sia-log-stream__line--info .sia-log-stream__level { color: var(--sia-info); }
|
|
83
|
+
.sia-log-stream__line--success .sia-log-stream__level { color: var(--sia-success); }
|
|
84
|
+
.sia-log-stream__line--warn .sia-log-stream__level { color: var(--sia-warning); }
|
|
85
|
+
.sia-log-stream__line--error .sia-log-stream__level { color: var(--sia-danger); }
|
|
86
|
+
|
|
87
|
+
/* Un fond léger sur ce qui demande l'œil : on retrouve une erreur en
|
|
88
|
+
parcourant, sans lire chaque niveau. */
|
|
89
|
+
.sia-log-stream__line--warn { background: color-mix(in srgb, var(--sia-warning) 7%, transparent); }
|
|
90
|
+
.sia-log-stream__line--error { background: color-mix(in srgb, var(--sia-danger) 8%, transparent); }
|
|
91
|
+
|
|
92
|
+
.sia-log-stream__group:not(:first-child) {
|
|
93
|
+
margin-top: .25rem;
|
|
94
|
+
border-top: 1px solid var(--sia-border);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.sia-log-stream__group-header.sia-log-stream__group-header {
|
|
98
|
+
width: 100%;
|
|
99
|
+
justify-content: flex-start;
|
|
100
|
+
border-radius: 0;
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.sia-log-stream__group-count {
|
|
105
|
+
margin-inline-start: .75ch;
|
|
106
|
+
color: var(--sia-muted);
|
|
107
|
+
font-weight: 400;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.sia-log-stream__loading {
|
|
111
|
+
display: grid;
|
|
112
|
+
place-items: center;
|
|
113
|
+
min-height: 8rem;
|
|
114
|
+
color: var(--sia-muted);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.sia-log-stream__scroll .sia-empty-state { margin: .375rem .75rem; }
|
|
118
|
+
|
|
119
|
+
/* Posé sur la zone, en bas : il est là où l'œil attend la suite. */
|
|
120
|
+
.sia-log-stream__resume.sia-log-stream__resume {
|
|
121
|
+
position: absolute;
|
|
122
|
+
bottom: .75rem;
|
|
123
|
+
left: 50%;
|
|
124
|
+
translate: -50% 0;
|
|
125
|
+
box-shadow: var(--sia-elevation-overlay);
|
|
126
|
+
animation: sia-log-stream-resume var(--sia-duration-fast) var(--sia-easing-entrance);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@keyframes sia-log-stream-resume {
|
|
130
|
+
from { opacity: 0; translate: -50% .5rem; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@container sia-log-stream (max-width: 30rem) {
|
|
134
|
+
.sia-log-stream__line { grid-template-columns: 8ch minmax(0, 1fr); }
|
|
135
|
+
/* En étroit, le niveau passe sous l'heure : le message garde la largeur. */
|
|
136
|
+
.sia-log-stream__level { grid-column: 1; grid-row: 2; font-size: .7rem; }
|
|
137
|
+
.sia-log-stream__message { grid-column: 2; grid-row: 1 / span 2; }
|
|
138
|
+
.sia-log-stream__actions { margin-inline-start: 0; }
|
|
139
|
+
/* Les cinq niveaux se partagent la largeur plutôt que de déborder. */
|
|
140
|
+
.sia-log-stream__levels.sia-log-stream__levels { display: flex; width: 100%; }
|
|
141
|
+
.sia-log-stream__levels .sia-toggle-group__item {
|
|
142
|
+
flex: 1 1 0;
|
|
143
|
+
min-width: 0;
|
|
144
|
+
justify-content: center;
|
|
145
|
+
padding-inline: .25rem;
|
|
146
|
+
font-size: .75rem;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@media (prefers-reduced-motion: reduce) {
|
|
151
|
+
.sia-log-stream__resume.sia-log-stream__resume { animation: none; }
|
|
152
|
+
}
|