@imenam/mcp-messenger 1.0.18 → 1.0.20
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/README.md +46 -21
- package/dist/src/core/store.d.ts +11 -0
- package/dist/src/core/store.js +21 -0
- package/dist/src/server/app.js +50 -13
- package/dist/src/server/ui/app.js +1444 -1172
- package/dist/src/server/ui/index.html +48 -6
- package/dist/src/server/ui/styles.css +172 -6
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,1172 +1,1444 @@
|
|
|
1
|
-
// Console de supervision : les routes /admin du serveur, en lecture — à
|
|
2
|
-
// près,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
|
|
7
|
-
// jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
|
|
8
|
-
// écrites en dur dans ce fichier. Le corps des messages passe par `renderMarkdown`, qui
|
|
9
|
-
// bâtit lui aussi des nœuds un par un et n'ouvre donc pas non plus de brèche.
|
|
10
|
-
//
|
|
11
|
-
// L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
|
|
12
|
-
// ce script ne voit jamais le jeton et n'écrit rien dans le navigateur.
|
|
13
|
-
|
|
14
|
-
import { renderMarkdown } from "./markdown.js";
|
|
15
|
-
|
|
16
|
-
const REFRESH_MS = 5000;
|
|
17
|
-
|
|
18
|
-
// Une adresse ne dit rien à qui lit la console : elle sert de graine à la couleur de
|
|
19
|
-
// l'avatar, pour que la même boîte garde la même teinte d'un écran à l'autre.
|
|
20
|
-
const AVATAR_COLORS = 8;
|
|
21
|
-
|
|
22
|
-
const ICONS = {
|
|
23
|
-
copy: ["M9 3h9a2 2 0 0 1 2 2v9", "M5 7h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"],
|
|
24
|
-
check: ["M20 6 9 17l-5-5"],
|
|
25
|
-
clock: ["M12 6v6l4 2", "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"],
|
|
26
|
-
close: ["M18 6 6 18", "m6 6 12 12"],
|
|
27
|
-
link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
|
|
28
|
-
chevron: ["m9 18 6-6-6-6"],
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
element
|
|
127
|
-
element.
|
|
128
|
-
element.
|
|
129
|
-
element
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
*
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
);
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
row.
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
item
|
|
571
|
-
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
);
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if (
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
row.appendChild(
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
function
|
|
953
|
-
const
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
el.
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1
|
+
// Console de supervision : les routes /admin du serveur, en lecture — à trois exceptions
|
|
2
|
+
// près, les suppressions de boîtes, seules commandes de cette page qui écrivent, et qui
|
|
3
|
+
// détruisent. Elles passent toutes par la même fenêtre : un aperçu de ce qui va partir,
|
|
4
|
+
// puis une confirmation en deux clics.
|
|
5
|
+
//
|
|
6
|
+
// Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
|
|
7
|
+
// jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
|
|
8
|
+
// écrites en dur dans ce fichier. Le corps des messages passe par `renderMarkdown`, qui
|
|
9
|
+
// bâtit lui aussi des nœuds un par un et n'ouvre donc pas non plus de brèche.
|
|
10
|
+
//
|
|
11
|
+
// L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
|
|
12
|
+
// ce script ne voit jamais le jeton et n'écrit rien dans le navigateur.
|
|
13
|
+
|
|
14
|
+
import { renderMarkdown } from "./markdown.js";
|
|
15
|
+
|
|
16
|
+
const REFRESH_MS = 5000;
|
|
17
|
+
|
|
18
|
+
// Une adresse ne dit rien à qui lit la console : elle sert de graine à la couleur de
|
|
19
|
+
// l'avatar, pour que la même boîte garde la même teinte d'un écran à l'autre.
|
|
20
|
+
const AVATAR_COLORS = 8;
|
|
21
|
+
|
|
22
|
+
const ICONS = {
|
|
23
|
+
copy: ["M9 3h9a2 2 0 0 1 2 2v9", "M5 7h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"],
|
|
24
|
+
check: ["M20 6 9 17l-5-5"],
|
|
25
|
+
clock: ["M12 6v6l4 2", "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"],
|
|
26
|
+
close: ["M18 6 6 18", "m6 6 12 12"],
|
|
27
|
+
link: ["M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1", "M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"],
|
|
28
|
+
chevron: ["m9 18 6-6-6-6"],
|
|
29
|
+
trash: ["M3 6h18", "M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2", "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const state = {
|
|
33
|
+
/**
|
|
34
|
+
* Ce sur quoi la liste des fils est filtrée.
|
|
35
|
+
*
|
|
36
|
+
* `{ kind: "all" }` ne filtre rien ; `{ kind: "agent", key }` retient toutes les boîtes
|
|
37
|
+
* d'un projet ; `{ kind: "box", key }` une seule session. Deux niveaux, parce qu'un
|
|
38
|
+
* agent tient une boîte par session ouverte et que les deux questions se posent.
|
|
39
|
+
*/
|
|
40
|
+
selection: { kind: "all", key: null },
|
|
41
|
+
/** Noms d'agents dépliés dans la colonne de gauche. Tout est replié au départ. */
|
|
42
|
+
expanded: new Set(),
|
|
43
|
+
/** Agents dont on a demandé à voir les boîtes vides. Elles sont cachées au départ. */
|
|
44
|
+
showEmpty: new Set(),
|
|
45
|
+
/**
|
|
46
|
+
* Faut-il montrer les agents dont aucune session n'a rien échangé ? Non par défaut,
|
|
47
|
+
* comme les boîtes vides à l'intérieur d'un agent : le serveur alloue une boîte par
|
|
48
|
+
* session ouverte, et un projet entier peut n'avoir jamais rien dit.
|
|
49
|
+
*/
|
|
50
|
+
showEmptyAgents: false,
|
|
51
|
+
/** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
|
|
52
|
+
filter: "all",
|
|
53
|
+
search: "",
|
|
54
|
+
threadId: null,
|
|
55
|
+
/** Message dont le volet de détail est ouvert, ou null si le volet est fermé. */
|
|
56
|
+
messageId: null,
|
|
57
|
+
collapsed: false,
|
|
58
|
+
/** Annuaire des boîtes, indexé par adresse : rempli par chaque `/admin/overview`. */
|
|
59
|
+
directory: new Map(),
|
|
60
|
+
/** Boîtes regroupées par nom d'agent, dans l'ordre de l'annuaire. */
|
|
61
|
+
groups: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const el = {
|
|
65
|
+
status: document.getElementById("status"),
|
|
66
|
+
statusLabel: document.getElementById("status-label"),
|
|
67
|
+
searchBox: document.getElementById("search-box"),
|
|
68
|
+
search: document.getElementById("search"),
|
|
69
|
+
controls: document.getElementById("controls"),
|
|
70
|
+
autoRefresh: document.getElementById("auto-refresh"),
|
|
71
|
+
refresh: document.getElementById("refresh"),
|
|
72
|
+
logout: document.getElementById("logout"),
|
|
73
|
+
login: document.getElementById("login"),
|
|
74
|
+
loginForm: document.getElementById("login-form"),
|
|
75
|
+
loginOrigin: document.getElementById("login-origin"),
|
|
76
|
+
tokenField: document.querySelector("#login-form .field"),
|
|
77
|
+
token: document.getElementById("token"),
|
|
78
|
+
tokenReveal: document.getElementById("token-reveal"),
|
|
79
|
+
tokenError: document.getElementById("token-error"),
|
|
80
|
+
console: document.getElementById("console"),
|
|
81
|
+
agentsPane: document.getElementById("agents-pane"),
|
|
82
|
+
agents: document.getElementById("agents"),
|
|
83
|
+
agentsFoot: document.getElementById("agents-foot"),
|
|
84
|
+
collapse: document.getElementById("collapse"),
|
|
85
|
+
threadsScope: document.getElementById("threads-scope"),
|
|
86
|
+
// Le panneau, pas la liste qu'il contient : c'est lui que le séparateur redimensionne.
|
|
87
|
+
threadsPane: document.querySelector(".threads"),
|
|
88
|
+
chips: document.getElementById("chips"),
|
|
89
|
+
threads: document.getElementById("threads"),
|
|
90
|
+
reader: document.getElementById("reader"),
|
|
91
|
+
sheet: document.getElementById("sheet"),
|
|
92
|
+
purge: document.getElementById("purge"),
|
|
93
|
+
purgeOpen: document.getElementById("purge-open"),
|
|
94
|
+
purgeClose: document.getElementById("purge-close"),
|
|
95
|
+
purgeCancel: document.getElementById("purge-cancel"),
|
|
96
|
+
purgeBefore: document.getElementById("purge-before"),
|
|
97
|
+
purgePreview: document.getElementById("purge-preview"),
|
|
98
|
+
purgePreviewEmpty: document.getElementById("purge-preview-empty"),
|
|
99
|
+
purgeResult: document.getElementById("purge-result"),
|
|
100
|
+
purgeConfirm: document.getElementById("purge-confirm"),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// --- Fabrique de noeuds ----------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function node(tag, className, text) {
|
|
106
|
+
const element = document.createElement(tag);
|
|
107
|
+
if (className) element.className = className;
|
|
108
|
+
if (text !== undefined) element.textContent = text;
|
|
109
|
+
return element;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function icon(name) {
|
|
113
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
114
|
+
svg.setAttribute("class", "icon");
|
|
115
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
116
|
+
svg.setAttribute("aria-hidden", "true");
|
|
117
|
+
for (const d of ICONS[name]) {
|
|
118
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
119
|
+
path.setAttribute("d", d);
|
|
120
|
+
svg.appendChild(path);
|
|
121
|
+
}
|
|
122
|
+
return svg;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function button(className, label, onClick) {
|
|
126
|
+
const element = node("button", className, label);
|
|
127
|
+
element.type = "button";
|
|
128
|
+
element.addEventListener("click", onClick);
|
|
129
|
+
return element;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Bouton de copie : les adresses et les identifiants ne se recopient pas à la main. */
|
|
133
|
+
function copyButton(value, label) {
|
|
134
|
+
const element = node("button", "copy");
|
|
135
|
+
element.type = "button";
|
|
136
|
+
element.title = label;
|
|
137
|
+
element.setAttribute("aria-label", label);
|
|
138
|
+
element.appendChild(icon("copy"));
|
|
139
|
+
element.addEventListener("click", async (event) => {
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
try {
|
|
142
|
+
await navigator.clipboard.writeText(value);
|
|
143
|
+
element.replaceChildren(icon("check"));
|
|
144
|
+
setTimeout(() => element.replaceChildren(icon("copy")), 1200);
|
|
145
|
+
} catch {
|
|
146
|
+
// Presse-papiers refusé (contexte non sécurisé, permission) : la valeur reste
|
|
147
|
+
// affichée à côté, donc sélectionnable. Rien d'autre à signaler.
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return element;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function tag(className, label, iconName) {
|
|
154
|
+
const element = node("span", className);
|
|
155
|
+
if (iconName) element.appendChild(icon(iconName));
|
|
156
|
+
element.appendChild(document.createTextNode(label));
|
|
157
|
+
return element;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- Annuaire des boîtes ---------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Deux noms par boîte, deux questions : l'agent dit d'où l'on écrit (le projet), la boîte
|
|
164
|
+
* de quoi cette session s'occupe. La console montre les deux, et n'affiche l'adresse que
|
|
165
|
+
* là où elle sert à être copiée.
|
|
166
|
+
*
|
|
167
|
+
* Un nom d'agent ne désigne personne : plusieurs sessions du même projet le portent. Dès
|
|
168
|
+
* qu'il est partagé, `name` lui accole le nom de la boîte — sans quoi un fil afficherait
|
|
169
|
+
* « Documentor Dev → Documentor Dev » sans dire de quelles sessions il parle.
|
|
170
|
+
*/
|
|
171
|
+
function party(agentId) {
|
|
172
|
+
const known = state.directory.get(agentId);
|
|
173
|
+
const agentName = (known && known.agent_name) || null;
|
|
174
|
+
const boxName = (known && known.display_name) || null;
|
|
175
|
+
const shared = agentName ? state.groups.some((g) => g.key === agentName && g.boxes.length > 1) : false;
|
|
176
|
+
// Parenthèses, et non un séparateur plat : les participants d'un fil sont eux-mêmes
|
|
177
|
+
// listés avec un point médian, qui ne doit pas se confondre avec cette précision.
|
|
178
|
+
const name = agentName
|
|
179
|
+
? shared
|
|
180
|
+
? `${agentName} (${boxName || shortAddress(agentId)})`
|
|
181
|
+
: agentName
|
|
182
|
+
: shortAddress(agentId);
|
|
183
|
+
return {
|
|
184
|
+
id: agentId,
|
|
185
|
+
name,
|
|
186
|
+
agentName,
|
|
187
|
+
box: boxName,
|
|
188
|
+
registered: !known || known.registered,
|
|
189
|
+
unread: (known && known.unread) || 0,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Nom de la boîte, ou son adresse à défaut.
|
|
195
|
+
*
|
|
196
|
+
* Une boîte n'est nommée que si l'agent l'a fait lui-même (`rename_mailbox`) ; tant qu'il
|
|
197
|
+
* ne l'a pas fait, seule l'adresse la distingue de ses sœurs, et c'est donc elle qu'on lit.
|
|
198
|
+
*/
|
|
199
|
+
function boxLabel(box) {
|
|
200
|
+
return box.display_name || box.id;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function shortAddress(agentId) {
|
|
204
|
+
const id = String(agentId || "");
|
|
205
|
+
return id.length > 12 ? `${id.slice(0, 10)}…` : id || "—";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function initials(label) {
|
|
209
|
+
const words = String(label).split(/[\s_·-]+/).filter(Boolean);
|
|
210
|
+
const letters = words.length >= 2 ? words[0][0] + words[1][0] : String(label).slice(0, 2);
|
|
211
|
+
return letters.toUpperCase();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Teinte stable : la même graine donne toujours la même couleur d'avatar. */
|
|
215
|
+
function colorIndex(seed) {
|
|
216
|
+
let hash = 0;
|
|
217
|
+
for (const char of String(seed)) hash = (hash * 31 + char.charCodeAt(0)) % 100003;
|
|
218
|
+
return hash % AVATAR_COLORS;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Pastille d'initiales. `seed` teinte, `label` écrit, `title` explique au survol. */
|
|
222
|
+
function avatarFor(seed, label, title, extraClass, absent) {
|
|
223
|
+
const classes = ["avatar"];
|
|
224
|
+
if (extraClass) classes.push(extraClass);
|
|
225
|
+
classes.push(absent ? "avatar--absent" : `avatar--c${colorIndex(seed)}`);
|
|
226
|
+
const element = node("span", classes.join(" "), initials(label));
|
|
227
|
+
element.title = title;
|
|
228
|
+
return element;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function avatar(agentId, extraClass) {
|
|
232
|
+
const who = party(agentId);
|
|
233
|
+
const label = who.box || who.agentName || shortAddress(agentId);
|
|
234
|
+
const title = [who.agentName, who.box, agentId].filter(Boolean).join(" — ");
|
|
235
|
+
return avatarFor(agentId, label, title, extraClass, !who.registered);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- Dates -----------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
function formatDate(iso) {
|
|
241
|
+
const date = new Date(iso);
|
|
242
|
+
return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleString();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Heure seule aujourd'hui, « hier » la veille, date courte au-delà : la liste doit se survoler. */
|
|
246
|
+
function formatShort(iso) {
|
|
247
|
+
const date = new Date(iso);
|
|
248
|
+
if (Number.isNaN(date.getTime())) return String(iso);
|
|
249
|
+
const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
250
|
+
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
251
|
+
const today = new Date();
|
|
252
|
+
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
253
|
+
const days = Math.round((midnight - day) / 86400000);
|
|
254
|
+
if (days === 0) return time;
|
|
255
|
+
if (days === 1) return `hier ${time}`;
|
|
256
|
+
return date.toLocaleDateString([], { day: "2-digit", month: "2-digit" });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Jour seul, sans heure : une purge se raisonne en jours, pas en minutes. */
|
|
260
|
+
function formatDay(iso) {
|
|
261
|
+
const date = new Date(iso);
|
|
262
|
+
return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleDateString();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function plural(count, singular, pluralForm) {
|
|
266
|
+
return `${count} ${count > 1 ? pluralForm || `${singular}s` : singular}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Accès au serveur ------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
async function api(path, params) {
|
|
272
|
+
const url = new URL(path, window.location.origin);
|
|
273
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
274
|
+
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, value);
|
|
275
|
+
}
|
|
276
|
+
const response = await fetch(url, { headers: { accept: "application/json" }, credentials: "same-origin" });
|
|
277
|
+
if (response.status === 401) {
|
|
278
|
+
setLoggedIn(false);
|
|
279
|
+
throw new Error("unauthorized");
|
|
280
|
+
}
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
const payload = await response.json().catch(() => ({}));
|
|
283
|
+
throw new Error(payload.error || response.statusText);
|
|
284
|
+
}
|
|
285
|
+
return response.json();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Envoi d'une action à la console. Les seules qui existent suppriment : elles passent par ici. */
|
|
289
|
+
async function post(path, body) {
|
|
290
|
+
return action("POST", path, body);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Suppression d'une ressource nommée par son adresse : pas de corps, l'URL suffit. */
|
|
294
|
+
async function remove(path) {
|
|
295
|
+
return action("DELETE", path, null);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function action(method, path, body) {
|
|
299
|
+
const response = await fetch(path, {
|
|
300
|
+
method,
|
|
301
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
302
|
+
credentials: "same-origin",
|
|
303
|
+
body: body === null ? undefined : JSON.stringify(body),
|
|
304
|
+
});
|
|
305
|
+
if (response.status === 401) {
|
|
306
|
+
setLoggedIn(false);
|
|
307
|
+
throw new Error("unauthorized");
|
|
308
|
+
}
|
|
309
|
+
const payload = await response.json().catch(() => ({}));
|
|
310
|
+
if (!response.ok) throw new Error(payload.error || response.statusText);
|
|
311
|
+
return payload;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function setLoggedIn(loggedIn) {
|
|
315
|
+
el.login.hidden = loggedIn;
|
|
316
|
+
el.console.hidden = !loggedIn;
|
|
317
|
+
el.controls.hidden = !loggedIn;
|
|
318
|
+
el.searchBox.hidden = !loggedIn;
|
|
319
|
+
if (!loggedIn) {
|
|
320
|
+
setStatus("idle", "non connecté");
|
|
321
|
+
el.token.focus();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function setStatus(kind, label) {
|
|
326
|
+
el.status.className = `pill pill--${kind}`;
|
|
327
|
+
el.statusLabel.textContent = label;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- Colonne des agents ----------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Regroupe les boîtes sous leur nom d'agent.
|
|
334
|
+
*
|
|
335
|
+
* L'agent dit d'où l'on écrit, la boîte quelle session parle : un projet qui a deux
|
|
336
|
+
* sessions ouvertes tient deux boîtes, et la colonne doit montrer cette imbrication plutôt
|
|
337
|
+
* que d'aligner des boîtes qui portent toutes le même nom. Une boîte sans nom d'agent est
|
|
338
|
+
* son propre groupe : rien ne permet de la rattacher à un projet.
|
|
339
|
+
*
|
|
340
|
+
* L'annuaire arrive déjà classé par activité : le groupe hérite du rang de sa boîte la
|
|
341
|
+
* plus récente, sans nouveau tri.
|
|
342
|
+
*/
|
|
343
|
+
function groupByAgent(boxes) {
|
|
344
|
+
const groups = new Map();
|
|
345
|
+
for (const box of boxes) {
|
|
346
|
+
const key = box.agent_name || box.id;
|
|
347
|
+
if (!groups.has(key)) {
|
|
348
|
+
groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0, filled: 0, activity: "" });
|
|
349
|
+
}
|
|
350
|
+
const group = groups.get(key);
|
|
351
|
+
group.boxes.push(box);
|
|
352
|
+
group.unread += box.unread;
|
|
353
|
+
if (!isEmptyBox(box)) group.filled += 1;
|
|
354
|
+
// L'agent hérite de l'activité de sa session la plus vivante.
|
|
355
|
+
if (activityOf(box) > group.activity) group.activity = activityOf(box);
|
|
356
|
+
}
|
|
357
|
+
const list = [...groups.values()];
|
|
358
|
+
for (const group of list) group.boxes.sort(byBox);
|
|
359
|
+
// Le classement est refait ici plutôt que laissé à l'ordre d'arrivée de l'annuaire :
|
|
360
|
+
// ce que la colonne promet ne doit pas dépendre du hasard d'un tri fait ailleurs. Un
|
|
361
|
+
// agent dont aucune session n'a rien échangé passe derrière les autres, comme ses boîtes.
|
|
362
|
+
list.sort(
|
|
363
|
+
(a, b) =>
|
|
364
|
+
compareEmpty(a.filled === 0, b.filled === 0) ||
|
|
365
|
+
compareActivity(a.activity, b.activity) ||
|
|
366
|
+
(a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
|
|
367
|
+
);
|
|
368
|
+
return list;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Une boîte est vide tant que rien n'y est passé, ni envoi ni réception.
|
|
373
|
+
*
|
|
374
|
+
* Le serveur alloue une boîte à chaque session ouverte : beaucoup n'échangeront jamais
|
|
375
|
+
* rien et n'ont donc rien à dire à qui supervise. Une boîte vide n'a par construction
|
|
376
|
+
* aucun non-lu — `received` à zéro l'implique — la masquer ne cache donc jamais de
|
|
377
|
+
* courrier en attente.
|
|
378
|
+
*/
|
|
379
|
+
function isEmptyBox(box) {
|
|
380
|
+
return box.sent + box.received === 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Dernière activité d'une boîte, ou chaîne vide quand elle n'a jamais rien fait. */
|
|
384
|
+
function activityOf(box) {
|
|
385
|
+
return box.last_activity_at || "";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Compare deux dates ISO, la plus récente d'abord.
|
|
390
|
+
*
|
|
391
|
+
* Les dates ISO se comparent comme du texte, et la chaîne vide — une boîte qui n'a jamais
|
|
392
|
+
* vécu — est plus petite que toute date : elle finit donc en queue, sans cas particulier.
|
|
393
|
+
*/
|
|
394
|
+
function compareActivity(left, right) {
|
|
395
|
+
if (left === right) return 0;
|
|
396
|
+
return left < right ? 1 : -1;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Le plein avant le vide. Premier critère du classement, avant même la date. */
|
|
400
|
+
function compareEmpty(left, right) {
|
|
401
|
+
if (left === right) return 0;
|
|
402
|
+
return left ? 1 : -1;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Deux classements emboîtés : d'abord les boîtes qui ont servi, puis les vides ; dans
|
|
407
|
+
* chacun des deux, la plus récemment active en tête. À égalité, l'adresse départage, pour
|
|
408
|
+
* que l'ordre ne bouge pas d'un rafraîchissement à l'autre.
|
|
409
|
+
*/
|
|
410
|
+
function byBox(a, b) {
|
|
411
|
+
return (
|
|
412
|
+
compareEmpty(isEmptyBox(a), isEmptyBox(b)) ||
|
|
413
|
+
compareActivity(activityOf(a), activityOf(b)) ||
|
|
414
|
+
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isSelected(kind, key) {
|
|
419
|
+
return state.selection.kind === kind && state.selection.key === key;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function select(kind, key) {
|
|
423
|
+
// Recliquer sur la sélection courante la relâche : le filtre ne s'emprisonne pas.
|
|
424
|
+
state.selection = isSelected(kind, key) ? { kind: "all", key: null } : { kind, key };
|
|
425
|
+
state.threadId = null;
|
|
426
|
+
state.messageId = null;
|
|
427
|
+
el.sheet.hidden = true;
|
|
428
|
+
el.console.classList.remove("has-sheet");
|
|
429
|
+
refresh();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function renderAgents(overview) {
|
|
433
|
+
state.directory = new Map(overview.agents.map((box) => [box.id, box]));
|
|
434
|
+
state.groups = groupByAgent(overview.agents);
|
|
435
|
+
el.agentsFoot.textContent = [
|
|
436
|
+
plural(state.groups.length, "agent"),
|
|
437
|
+
plural(overview.agents.length, "boîte"),
|
|
438
|
+
plural(overview.threads, "fil"),
|
|
439
|
+
].join(" · ");
|
|
440
|
+
|
|
441
|
+
el.agents.replaceChildren();
|
|
442
|
+
el.agents.appendChild(allRow(overview));
|
|
443
|
+
// Les agents sans le moindre échange sont derrière les autres dans `state.groups` :
|
|
444
|
+
// les montrer allonge la liste par la fin, sans jamais en réordonner le début.
|
|
445
|
+
const silent = state.groups.filter((group) => group.filled === 0).length;
|
|
446
|
+
for (const group of state.groups) {
|
|
447
|
+
if (group.filled === 0 && !state.showEmptyAgents) continue;
|
|
448
|
+
el.agents.appendChild(groupRow(group));
|
|
449
|
+
}
|
|
450
|
+
if (silent > 0) el.agents.appendChild(silentAgentsToggleRow(silent));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Dernière ligne de la colonne : ouvre ou referme les agents dont aucune session n'a rien
|
|
455
|
+
* échangé. Même geste que pour les boîtes vides d'un agent, un cran plus haut.
|
|
456
|
+
*/
|
|
457
|
+
function silentAgentsToggleRow(silent) {
|
|
458
|
+
const item = node("li");
|
|
459
|
+
const what = silent > 1 ? `les ${silent} agents sans échange` : "l'agent sans échange";
|
|
460
|
+
const toggle = button(
|
|
461
|
+
"box-empty-toggle agents-empty-toggle",
|
|
462
|
+
`${state.showEmptyAgents ? "Masquer" : "Afficher"} ${what}`,
|
|
463
|
+
() => {
|
|
464
|
+
state.showEmptyAgents = !state.showEmptyAgents;
|
|
465
|
+
renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
|
|
466
|
+
}
|
|
467
|
+
);
|
|
468
|
+
toggle.setAttribute("aria-expanded", state.showEmptyAgents ? "true" : "false");
|
|
469
|
+
item.appendChild(toggle);
|
|
470
|
+
return item;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function allRow(overview) {
|
|
474
|
+
const item = node("li");
|
|
475
|
+
const unread = overview.agents.reduce((total, box) => total + box.unread, 0);
|
|
476
|
+
const row = node("button", `agent${isSelected("all", null) ? " is-selected" : ""}`);
|
|
477
|
+
row.type = "button";
|
|
478
|
+
|
|
479
|
+
const star = node("span", "avatar avatar--absent", "∗");
|
|
480
|
+
star.title = "Tous les agents";
|
|
481
|
+
row.appendChild(star);
|
|
482
|
+
|
|
483
|
+
const text = node("div", "agent-text");
|
|
484
|
+
text.appendChild(node("div", "agent-name", "Tous les agents"));
|
|
485
|
+
text.appendChild(node("div", "agent-box", plural(overview.threads, "fil")));
|
|
486
|
+
row.appendChild(text);
|
|
487
|
+
if (unread > 0) row.appendChild(node("span", "count count--accent", String(unread)));
|
|
488
|
+
|
|
489
|
+
row.addEventListener("click", () => select("all", null));
|
|
490
|
+
item.appendChild(row);
|
|
491
|
+
return item;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Un agent et, dépliées sous lui, ses boîtes.
|
|
496
|
+
*
|
|
497
|
+
* Deux zones de clic, deux gestes distincts : le chevron ouvre ou ferme, le nom filtre sur
|
|
498
|
+
* l'ensemble des sessions du projet. Filtrer déplie au passage, pour que la sélection
|
|
499
|
+
* montre toujours ce qu'elle recouvre.
|
|
500
|
+
*/
|
|
501
|
+
function groupRow(group) {
|
|
502
|
+
const item = node("li", "agent-group");
|
|
503
|
+
const open = state.expanded.has(group.key);
|
|
504
|
+
|
|
505
|
+
const header = node("div", `agent-head${open ? " is-open" : ""}`);
|
|
506
|
+
|
|
507
|
+
const toggle = node("button", "agent-toggle");
|
|
508
|
+
toggle.type = "button";
|
|
509
|
+
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
510
|
+
toggle.setAttribute("aria-label", `${open ? "Replier" : "Déplier"} les boîtes de ${group.name}`);
|
|
511
|
+
toggle.appendChild(icon("chevron"));
|
|
512
|
+
toggle.addEventListener("click", () => {
|
|
513
|
+
if (open) state.expanded.delete(group.key);
|
|
514
|
+
else state.expanded.add(group.key);
|
|
515
|
+
renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
|
|
516
|
+
});
|
|
517
|
+
header.appendChild(toggle);
|
|
518
|
+
|
|
519
|
+
const classes = ["agent"];
|
|
520
|
+
if (isSelected("agent", group.key)) classes.push("is-selected");
|
|
521
|
+
if (group.unread > 0) classes.push("has-unread");
|
|
522
|
+
if (group.boxes.every((box) => !box.registered)) classes.push("agent--muted");
|
|
523
|
+
const row = node("button", classes.join(" "));
|
|
524
|
+
row.type = "button";
|
|
525
|
+
row.appendChild(
|
|
526
|
+
avatarFor(
|
|
527
|
+
group.key,
|
|
528
|
+
group.name,
|
|
529
|
+
group.name,
|
|
530
|
+
null,
|
|
531
|
+
group.boxes.every((box) => !box.registered)
|
|
532
|
+
)
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const empty = group.boxes.length - group.filled;
|
|
536
|
+
// Le décompte annonce ce que le dépli va montrer : sans quoi « 5 boîtes » suivi de deux
|
|
537
|
+
// lignes se lirait comme un bug plutôt que comme des boîtes vides mises de côté.
|
|
538
|
+
const counts = empty > 0 ? `${plural(group.filled, "boîte")} · ${plural(empty, "vide")}` : plural(group.filled, "boîte");
|
|
539
|
+
|
|
540
|
+
const text = node("div", "agent-text");
|
|
541
|
+
text.appendChild(node("div", "agent-name", group.name));
|
|
542
|
+
text.appendChild(node("div", "agent-box", counts));
|
|
543
|
+
row.appendChild(text);
|
|
544
|
+
if (group.unread > 0) row.appendChild(node("span", "count count--accent", String(group.unread)));
|
|
545
|
+
row.title = `${group.name} · ${counts}`;
|
|
546
|
+
row.addEventListener("click", () => {
|
|
547
|
+
state.expanded.add(group.key);
|
|
548
|
+
select("agent", group.key);
|
|
549
|
+
});
|
|
550
|
+
header.appendChild(row);
|
|
551
|
+
item.appendChild(header);
|
|
552
|
+
|
|
553
|
+
if (open) {
|
|
554
|
+
const list = node("ul", "box-list");
|
|
555
|
+
// Les vides sont derrière dans `group.boxes` : les montrer, c'est allonger la liste,
|
|
556
|
+
// jamais en réordonner le début.
|
|
557
|
+
const revealed = state.showEmpty.has(group.key);
|
|
558
|
+
for (const box of group.boxes) {
|
|
559
|
+
if (isEmptyBox(box) && !revealed) continue;
|
|
560
|
+
list.appendChild(boxRow(box));
|
|
561
|
+
}
|
|
562
|
+
if (empty > 0) list.appendChild(emptyToggleRow(group, empty, revealed));
|
|
563
|
+
item.appendChild(list);
|
|
564
|
+
}
|
|
565
|
+
return item;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Dernière ligne du dépli : ouvre ou referme les boîtes qui n'ont jamais rien échangé. */
|
|
569
|
+
function emptyToggleRow(group, empty, revealed) {
|
|
570
|
+
const item = node("li");
|
|
571
|
+
const what = empty > 1 ? `les ${empty} boîtes vides` : "la boîte vide";
|
|
572
|
+
const toggle = button("box-empty-toggle", `${revealed ? "Masquer" : "Afficher"} ${what}`, () => {
|
|
573
|
+
if (revealed) state.showEmpty.delete(group.key);
|
|
574
|
+
else state.showEmpty.add(group.key);
|
|
575
|
+
renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
|
|
576
|
+
});
|
|
577
|
+
toggle.setAttribute("aria-expanded", revealed ? "true" : "false");
|
|
578
|
+
item.appendChild(toggle);
|
|
579
|
+
return item;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function boxRow(box) {
|
|
583
|
+
const item = node("li");
|
|
584
|
+
const classes = ["agent", "box"];
|
|
585
|
+
if (isEmptyBox(box)) classes.push("is-empty");
|
|
586
|
+
if (isSelected("box", box.id)) classes.push("is-selected");
|
|
587
|
+
if (box.unread > 0) classes.push("has-unread");
|
|
588
|
+
if (!box.registered) classes.push("agent--muted");
|
|
589
|
+
const row = node("button", classes.join(" "));
|
|
590
|
+
row.type = "button";
|
|
591
|
+
|
|
592
|
+
const label = boxLabel(box);
|
|
593
|
+
row.appendChild(avatarFor(box.id, label, box.id, "avatar--sm", !box.registered));
|
|
594
|
+
|
|
595
|
+
const text = node("div", "agent-text");
|
|
596
|
+
// Sans nom donné par l'agent, l'adresse tient lieu de nom : elle est alors la seule
|
|
597
|
+
// chose qui distingue cette session de ses sœurs.
|
|
598
|
+
const name = node("div", "agent-name", label);
|
|
599
|
+
if (!box.display_name) name.classList.add("mono", "is-absent");
|
|
600
|
+
text.appendChild(name);
|
|
601
|
+
// Les sessions d'un agent sont classées sur cette date : sans elle, leur ordre
|
|
602
|
+
// paraîtrait arbitraire. Les compteurs passent dans l'info-bulle, faute de place.
|
|
603
|
+
const activity = box.last_activity_at
|
|
604
|
+
? `activité ${formatShort(box.last_activity_at)}`
|
|
605
|
+
: box.registered
|
|
606
|
+
? "aucune activité"
|
|
607
|
+
: "jamais connectée";
|
|
608
|
+
const meta = node("div", "agent-box", activity);
|
|
609
|
+
if (!box.last_activity_at) meta.classList.add("is-absent");
|
|
610
|
+
text.appendChild(meta);
|
|
611
|
+
row.appendChild(text);
|
|
612
|
+
|
|
613
|
+
if (box.unread > 0) row.appendChild(node("span", "count count--accent", String(box.unread)));
|
|
614
|
+
row.title = `${box.id}\n${plural(box.sent, "envoyé")} · ${plural(box.received, "reçu")}`;
|
|
615
|
+
row.addEventListener("click", () => select("box", box.id));
|
|
616
|
+
|
|
617
|
+
// La ligne et son bouton de suppression sont deux boutons voisins, jamais imbriqués :
|
|
618
|
+
// un bouton dans un bouton n'est pas du HTML, et le clic n'y serait pas dissociable.
|
|
619
|
+
const line = node("div", "box-line");
|
|
620
|
+
line.appendChild(row);
|
|
621
|
+
line.appendChild(deleteBoxButton(box));
|
|
622
|
+
item.appendChild(line);
|
|
623
|
+
return item;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Suppression d'une boîte, à la main.
|
|
628
|
+
*
|
|
629
|
+
* Le bouton n'efface rien : il ouvre la fenêtre de nettoyage sur cette seule boîte, avec
|
|
630
|
+
* ce qu'elle contient sous les yeux et la même double confirmation que les nettoyages en
|
|
631
|
+
* lot. Un geste destructeur à un clic d'une liste ne peut pas être plus court que ça.
|
|
632
|
+
*/
|
|
633
|
+
function deleteBoxButton(box) {
|
|
634
|
+
const label = `Supprimer la boîte ${boxLabel(box)}`;
|
|
635
|
+
const remove = node("button", "box-delete");
|
|
636
|
+
remove.type = "button";
|
|
637
|
+
remove.title = label;
|
|
638
|
+
remove.setAttribute("aria-label", label);
|
|
639
|
+
remove.appendChild(icon("trash"));
|
|
640
|
+
remove.addEventListener("click", (event) => {
|
|
641
|
+
event.stopPropagation();
|
|
642
|
+
openPurgeFor(box);
|
|
643
|
+
});
|
|
644
|
+
return remove;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Nombre de fils affiché en pied de colonne, conservé entre deux rendus locaux. */
|
|
648
|
+
function currentThreadCount() {
|
|
649
|
+
return state.threadCount || 0;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Adresses que la sélection courante recouvre, ou null quand elle ne filtre rien. */
|
|
653
|
+
function selectedAddresses() {
|
|
654
|
+
if (state.selection.kind === "box") return [state.selection.key];
|
|
655
|
+
if (state.selection.kind === "agent") {
|
|
656
|
+
const group = state.groups.find((g) => g.key === state.selection.key);
|
|
657
|
+
return group ? group.boxes.map((box) => box.id) : [];
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function selectionLabel() {
|
|
663
|
+
if (state.selection.kind === "box") {
|
|
664
|
+
const box = state.directory.get(state.selection.key);
|
|
665
|
+
return box ? boxLabel(box) : shortAddress(state.selection.key);
|
|
666
|
+
}
|
|
667
|
+
if (state.selection.kind === "agent") return state.selection.key;
|
|
668
|
+
return "Toutes les boîtes";
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// --- Colonne des fils ------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
function renderThreads(threads) {
|
|
674
|
+
state.threadCount = threads.length;
|
|
675
|
+
el.threadsScope.textContent = `${selectionLabel()} · ${plural(threads.length, "fil")}`;
|
|
676
|
+
|
|
677
|
+
el.threads.replaceChildren();
|
|
678
|
+
if (threads.length === 0) {
|
|
679
|
+
const empty = node("li", "empty");
|
|
680
|
+
empty.appendChild(node("span", "empty-title", "Aucun fil"));
|
|
681
|
+
empty.appendChild(document.createTextNode(emptyReason()));
|
|
682
|
+
el.threads.appendChild(empty);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
for (const thread of threads) el.threads.appendChild(threadRow(thread));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function emptyReason() {
|
|
689
|
+
if (state.search) return "Aucun fil ne correspond à cette recherche.";
|
|
690
|
+
if (state.filter === "unread") return "Tout est lu de ce côté.";
|
|
691
|
+
if (state.filter === "high") return "Aucun message en priorité haute.";
|
|
692
|
+
if (state.selection.kind === "box") return "Cette boîte n'a encore échangé avec personne.";
|
|
693
|
+
if (state.selection.kind === "agent") return "Aucune session de cet agent n'a encore échangé.";
|
|
694
|
+
return "Aucun agent n'a encore écrit sur ce serveur.";
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function threadRow(thread) {
|
|
698
|
+
const item = node("li");
|
|
699
|
+
const classes = ["thread"];
|
|
700
|
+
if (state.threadId === thread.thread_id) classes.push("is-selected");
|
|
701
|
+
if (thread.unread > 0) classes.push("is-unread");
|
|
702
|
+
const row = node("button", classes.join(" "));
|
|
703
|
+
row.type = "button";
|
|
704
|
+
|
|
705
|
+
const top = node("div", "thread-top");
|
|
706
|
+
// Le titre du fil prime ; tant que personne ne l'a nommé, le sujet d'ouverture en tient lieu.
|
|
707
|
+
top.appendChild(node("span", "thread-title", thread.title || thread.subject));
|
|
708
|
+
if (thread.priority === "high") top.appendChild(tag("tag tag--high", "haute"));
|
|
709
|
+
top.appendChild(node("span", "thread-time", formatShort(thread.last_message_at)));
|
|
710
|
+
row.appendChild(top);
|
|
711
|
+
|
|
712
|
+
row.appendChild(threadParticipants(thread));
|
|
713
|
+
|
|
714
|
+
const bottom = node("div", "thread-bottom");
|
|
715
|
+
bottom.appendChild(node("span", "thread-preview", thread.last_message_preview));
|
|
716
|
+
const count = node("span", thread.unread > 0 ? "count count--accent" : "count", String(thread.messages));
|
|
717
|
+
count.title = thread.unread > 0 ? `${plural(thread.messages, "message")}, ${thread.unread} non lu(s)` : plural(thread.messages, "message");
|
|
718
|
+
bottom.appendChild(count);
|
|
719
|
+
row.appendChild(bottom);
|
|
720
|
+
|
|
721
|
+
row.addEventListener("click", () => openThread(thread.thread_id));
|
|
722
|
+
item.appendChild(row);
|
|
723
|
+
return item;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Qui parle dans ce fil : les pastilles d'abord, les noms ensuite.
|
|
728
|
+
*
|
|
729
|
+
* Un fil lie exactement deux boîtes, la pastille suffit donc à les distinguer d'un coup
|
|
730
|
+
* d'œil, sans compter ni déborder. Chaque pastille garde la couleur de son adresse d'un
|
|
731
|
+
* écran à l'autre, et dit au survol tout ce qu'on peut vouloir savoir de la boîte : le
|
|
732
|
+
* projet d'où elle écrit, ce dont cette session s'occupe, son adresse, et si c'est elle
|
|
733
|
+
* qui a écrit en dernier.
|
|
734
|
+
*/
|
|
735
|
+
function threadParticipants(thread) {
|
|
736
|
+
const who = node("div", "thread-who");
|
|
737
|
+
|
|
738
|
+
const stack = node("div", "avatar-stack");
|
|
739
|
+
for (const id of thread.participants) {
|
|
740
|
+
const person = party(id);
|
|
741
|
+
const badge = avatar(id, "avatar--sm avatar--stack");
|
|
742
|
+
badge.title = [
|
|
743
|
+
person.agentName || "agent sans nom",
|
|
744
|
+
person.box || (person.registered ? "boîte sans nom" : "boîte jamais connectée"),
|
|
745
|
+
id,
|
|
746
|
+
id === thread.last_message_from ? "a écrit le dernier message" : null,
|
|
747
|
+
]
|
|
748
|
+
.filter(Boolean)
|
|
749
|
+
.join("\n");
|
|
750
|
+
stack.appendChild(badge);
|
|
751
|
+
}
|
|
752
|
+
who.appendChild(stack);
|
|
753
|
+
|
|
754
|
+
who.appendChild(node("span", "thread-names", thread.participants.map((id) => party(id).name).join(" · ")));
|
|
755
|
+
return who;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// --- Lecteur du fil --------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
function renderEmptyReader() {
|
|
761
|
+
const empty = node("div", "empty");
|
|
762
|
+
empty.appendChild(node("span", "empty-title", "Aucun fil ouvert"));
|
|
763
|
+
empty.appendChild(document.createTextNode("Choisissez un fil pour lire la conversation."));
|
|
764
|
+
el.reader.replaceChildren(empty);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function renderThread(data) {
|
|
768
|
+
const thread = data.thread || {};
|
|
769
|
+
const messages = data.messages || [];
|
|
770
|
+
const container = document.createDocumentFragment();
|
|
771
|
+
|
|
772
|
+
const head = node("div", "reader-head");
|
|
773
|
+
const opening = messages.length > 0 ? messages[0].subject : "";
|
|
774
|
+
head.appendChild(node("h2", null, thread.title || opening || data.thread_id));
|
|
775
|
+
if (thread.description) head.appendChild(node("p", "reader-desc", thread.description));
|
|
776
|
+
|
|
777
|
+
const participants = [];
|
|
778
|
+
for (const message of messages) {
|
|
779
|
+
for (const id of [message.from, message.to]) {
|
|
780
|
+
if (id && !participants.includes(id)) participants.push(id);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const meta = node("div", "reader-meta");
|
|
785
|
+
const stack = node("div", "avatar-stack");
|
|
786
|
+
for (const id of participants) stack.appendChild(avatar(id, "avatar--sm avatar--stack"));
|
|
787
|
+
meta.appendChild(stack);
|
|
788
|
+
meta.appendChild(
|
|
789
|
+
node(
|
|
790
|
+
"span",
|
|
791
|
+
"reader-participants",
|
|
792
|
+
`${participants.map((id) => party(id).name).join(", ")} · ${plural(messages.length, "message")}`
|
|
793
|
+
)
|
|
794
|
+
);
|
|
795
|
+
const threadId = node("span", "mono muted", data.thread_id);
|
|
796
|
+
meta.appendChild(threadId);
|
|
797
|
+
meta.appendChild(copyButton(data.thread_id, "Copier l'identifiant du fil"));
|
|
798
|
+
head.appendChild(meta);
|
|
799
|
+
container.appendChild(head);
|
|
800
|
+
|
|
801
|
+
const list = node("ul", "messages");
|
|
802
|
+
messages.forEach((message, index) => {
|
|
803
|
+
list.appendChild(messageRow(message, index === messages.length - 1));
|
|
804
|
+
});
|
|
805
|
+
container.appendChild(list);
|
|
806
|
+
|
|
807
|
+
el.reader.replaceChildren(container);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function messageRow(message, isLast) {
|
|
811
|
+
const item = node("li");
|
|
812
|
+
const classes = ["message"];
|
|
813
|
+
if (state.messageId === message.message_id) classes.push("is-open");
|
|
814
|
+
else if (isLast) classes.push("is-last");
|
|
815
|
+
const row = node("div", classes.join(" "));
|
|
816
|
+
|
|
817
|
+
row.appendChild(avatar(message.from));
|
|
818
|
+
|
|
819
|
+
const column = node("div", "message-body-col");
|
|
820
|
+
const head = node("div", "message-head");
|
|
821
|
+
head.appendChild(node("span", "message-from", party(message.from).name));
|
|
822
|
+
head.appendChild(node("span", "message-to", `→ ${party(message.to).name}`));
|
|
823
|
+
if (message.priority === "high") head.appendChild(tag("tag tag--high", "priorité haute"));
|
|
824
|
+
if (message.priority === "low") head.appendChild(tag("tag", "priorité basse"));
|
|
825
|
+
head.appendChild(node("span", "message-time", formatShort(message.created_at)));
|
|
826
|
+
column.appendChild(head);
|
|
827
|
+
|
|
828
|
+
// Le sujet n'est répété que s'il diffère du nom du fil : sinon c'est du bruit.
|
|
829
|
+
if (message.subject && message.subject !== message.thread_title) {
|
|
830
|
+
column.appendChild(node("p", "message-subject", message.subject));
|
|
831
|
+
}
|
|
832
|
+
column.appendChild(messageText(message));
|
|
833
|
+
|
|
834
|
+
const foot = node("div", "message-foot");
|
|
835
|
+
foot.appendChild(
|
|
836
|
+
message.read
|
|
837
|
+
? tag("tag tag--ok", `lu par ${party(message.to).name}`, "check")
|
|
838
|
+
: tag("tag tag--warn", `non lu par ${party(message.to).name}`, "clock")
|
|
839
|
+
);
|
|
840
|
+
const open = state.messageId === message.message_id;
|
|
841
|
+
foot.appendChild(
|
|
842
|
+
button("btn btn--link", open ? "détails ouverts" : "détails", () => {
|
|
843
|
+
if (open) closeSheet();
|
|
844
|
+
else openMessage(message.message_id);
|
|
845
|
+
})
|
|
846
|
+
);
|
|
847
|
+
column.appendChild(foot);
|
|
848
|
+
|
|
849
|
+
row.appendChild(column);
|
|
850
|
+
item.appendChild(row);
|
|
851
|
+
return item;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Le corps d'un message, rendu comme du markdown : c'est la langue dans laquelle les
|
|
856
|
+
* agents s'écrivent. L'aperçu, lui, arrive déjà débarrassé de sa syntaxe par le serveur
|
|
857
|
+
* et reste donc du texte simple.
|
|
858
|
+
*/
|
|
859
|
+
function messageText(message) {
|
|
860
|
+
if (!message.body) return node("p", "message-text", message.preview || "");
|
|
861
|
+
const container = node("div", "message-text markdown");
|
|
862
|
+
container.appendChild(renderMarkdown(message.body));
|
|
863
|
+
return container;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// --- Volet de détail -------------------------------------------------------
|
|
867
|
+
|
|
868
|
+
function renderSheet(message) {
|
|
869
|
+
const container = document.createDocumentFragment();
|
|
870
|
+
|
|
871
|
+
const head = node("div", "sheet-head");
|
|
872
|
+
const titles = node("div");
|
|
873
|
+
titles.appendChild(node("h3", null, "Détails du message"));
|
|
874
|
+
titles.appendChild(node("p", "sheet-subject", message.subject));
|
|
875
|
+
head.appendChild(titles);
|
|
876
|
+
const close = node("button", "btn btn--icon btn--ghost");
|
|
877
|
+
close.type = "button";
|
|
878
|
+
close.title = "Fermer";
|
|
879
|
+
close.setAttribute("aria-label", "Fermer le volet");
|
|
880
|
+
close.appendChild(icon("close"));
|
|
881
|
+
close.addEventListener("click", closeSheet);
|
|
882
|
+
head.appendChild(close);
|
|
883
|
+
container.appendChild(head);
|
|
884
|
+
|
|
885
|
+
const ids = node("div", "sheet-section");
|
|
886
|
+
ids.appendChild(node("span", "section-label", "Message"));
|
|
887
|
+
ids.appendChild(kvCopy("Identifiant", message.message_id));
|
|
888
|
+
ids.appendChild(kvCopy("Fil", message.thread_id));
|
|
889
|
+
ids.appendChild(kv("Envoyé le", node("span", "kv-value", formatDate(message.created_at))));
|
|
890
|
+
ids.appendChild(kv("Priorité", priorityTag(message.priority)));
|
|
891
|
+
container.appendChild(ids);
|
|
892
|
+
|
|
893
|
+
container.appendChild(partySection("De", message.from));
|
|
894
|
+
container.appendChild(partySection("À", message.to));
|
|
895
|
+
|
|
896
|
+
const deliveries = node("div", "sheet-section");
|
|
897
|
+
deliveries.appendChild(node("span", "section-label", "Distribution"));
|
|
898
|
+
deliveries.appendChild(deliveriesTable(message.deliveries || []));
|
|
899
|
+
container.appendChild(deliveries);
|
|
900
|
+
|
|
901
|
+
el.sheet.replaceChildren(container);
|
|
902
|
+
el.sheet.hidden = false;
|
|
903
|
+
el.console.classList.add("has-sheet");
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function kv(key, valueNode) {
|
|
907
|
+
const row = node("div", "kv");
|
|
908
|
+
row.appendChild(node("span", "kv-key", key));
|
|
909
|
+
row.appendChild(valueNode);
|
|
910
|
+
row.appendChild(node("span"));
|
|
911
|
+
return row;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function kvCopy(key, value) {
|
|
915
|
+
const row = node("div", "kv");
|
|
916
|
+
row.appendChild(node("span", "kv-key", key));
|
|
917
|
+
row.appendChild(node("span", "kv-value mono", value));
|
|
918
|
+
row.appendChild(copyButton(value, `Copier ${key.toLowerCase()}`));
|
|
919
|
+
return row;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function priorityTag(priority) {
|
|
923
|
+
const labels = { high: "haute", normal: "normale", low: "basse" };
|
|
924
|
+
return tag(priority === "high" ? "tag tag--high" : "tag", labels[priority] || priority);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Une boîte, telle qu'elle doit se lire : le nom de l'agent, ce dont la session s'occupe,
|
|
929
|
+
* et seulement ensuite l'adresse — la seule chose qui identifie vraiment la session.
|
|
930
|
+
*/
|
|
931
|
+
function partySection(label, agentId) {
|
|
932
|
+
const who = party(agentId);
|
|
933
|
+
const section = node("div", "sheet-section");
|
|
934
|
+
section.appendChild(node("span", "section-label", label));
|
|
935
|
+
|
|
936
|
+
const block = node("div", "party");
|
|
937
|
+
block.appendChild(avatar(agentId, "avatar--sm"));
|
|
938
|
+
|
|
939
|
+
const text = node("div", "agent-text");
|
|
940
|
+
text.appendChild(node("div", "party-name", who.name));
|
|
941
|
+
text.appendChild(node("div", "party-box", who.box || (who.registered ? "boîte sans nom" : "jamais connectée")));
|
|
942
|
+
const addr = node("div", "party-addr");
|
|
943
|
+
addr.appendChild(node("span", "mono", agentId));
|
|
944
|
+
addr.appendChild(copyButton(agentId, "Copier l'adresse"));
|
|
945
|
+
text.appendChild(addr);
|
|
946
|
+
|
|
947
|
+
block.appendChild(text);
|
|
948
|
+
section.appendChild(block);
|
|
949
|
+
return section;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function deliveriesTable(deliveries) {
|
|
953
|
+
const table = node("table", "deliveries");
|
|
954
|
+
const head = node("tr");
|
|
955
|
+
for (const label of ["Agent", "Rôle", "Dossier", "État"]) head.appendChild(node("th", null, label));
|
|
956
|
+
table.appendChild(head);
|
|
957
|
+
|
|
958
|
+
for (const delivery of deliveries) {
|
|
959
|
+
const row = node("tr");
|
|
960
|
+
const who = node("td");
|
|
961
|
+
who.appendChild(document.createTextNode(party(delivery.agent_id).name));
|
|
962
|
+
row.appendChild(who);
|
|
963
|
+
row.appendChild(node("td", "role", delivery.role === "sender" ? "expéditeur" : "destinataire"));
|
|
964
|
+
row.appendChild(node("td", "folder", delivery.folder));
|
|
965
|
+
const stateCell = node("td");
|
|
966
|
+
stateCell.appendChild(
|
|
967
|
+
delivery.read ? tag("tag tag--ok", "lu") : tag("tag tag--warn", delivery.role === "sender" ? "—" : "non lu")
|
|
968
|
+
);
|
|
969
|
+
row.appendChild(stateCell);
|
|
970
|
+
table.appendChild(row);
|
|
971
|
+
}
|
|
972
|
+
return table;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function closeSheet() {
|
|
976
|
+
state.messageId = null;
|
|
977
|
+
el.sheet.hidden = true;
|
|
978
|
+
el.sheet.replaceChildren();
|
|
979
|
+
el.console.classList.remove("has-sheet");
|
|
980
|
+
if (state.threadId) refreshThread();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// --- Navigation ------------------------------------------------------------
|
|
984
|
+
|
|
985
|
+
async function openThread(threadId) {
|
|
986
|
+
state.threadId = threadId;
|
|
987
|
+
state.messageId = null;
|
|
988
|
+
el.sheet.hidden = true;
|
|
989
|
+
el.console.classList.remove("has-sheet");
|
|
990
|
+
await refreshThread();
|
|
991
|
+
await refresh({ keepThread: true });
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function refreshThread() {
|
|
995
|
+
if (!state.threadId) {
|
|
996
|
+
renderEmptyReader();
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
try {
|
|
1000
|
+
renderThread(await api(`/admin/threads/${encodeURIComponent(state.threadId)}`));
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
if (error.message !== "unauthorized") el.reader.replaceChildren(node("div", "banner", error.message));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function openMessage(messageId) {
|
|
1007
|
+
state.messageId = messageId;
|
|
1008
|
+
try {
|
|
1009
|
+
renderSheet(await api(`/admin/messages/${encodeURIComponent(messageId)}`));
|
|
1010
|
+
await refreshThread();
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
if (error.message !== "unauthorized") el.sheet.replaceChildren(node("div", "banner", error.message));
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// --- Boucle de rafraîchissement --------------------------------------------
|
|
1017
|
+
|
|
1018
|
+
async function refresh(options) {
|
|
1019
|
+
try {
|
|
1020
|
+
const [overview, list] = await Promise.all([
|
|
1021
|
+
api("/admin/overview"),
|
|
1022
|
+
api("/admin/threads", {
|
|
1023
|
+
// Une sélection d'agent recouvre toutes ses sessions : la route prend la liste.
|
|
1024
|
+
agent: (selectedAddresses() || []).join(","),
|
|
1025
|
+
search: state.search,
|
|
1026
|
+
unread_only: state.filter === "unread" ? "true" : undefined,
|
|
1027
|
+
priority: state.filter === "high" ? "high" : undefined,
|
|
1028
|
+
limit: 100,
|
|
1029
|
+
}),
|
|
1030
|
+
]);
|
|
1031
|
+
setLoggedIn(true);
|
|
1032
|
+
setStatus("ok", `connecté · ${window.location.host}`);
|
|
1033
|
+
renderAgents(overview);
|
|
1034
|
+
renderThreads(list.threads);
|
|
1035
|
+
if (!options || !options.keepThread) {
|
|
1036
|
+
if (state.threadId) await refreshThread();
|
|
1037
|
+
else renderEmptyReader();
|
|
1038
|
+
}
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
if (error.message === "unauthorized") {
|
|
1041
|
+
setStatus("idle", "authentification requise");
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
setStatus("ko", `erreur : ${error.message}`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// --- Largeur des panneaux --------------------------------------------------
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Ce que chaque séparateur commande.
|
|
1052
|
+
*
|
|
1053
|
+
* `edge` dit de quel côté se trouve le panneau réglé : les deux premiers bordent le
|
|
1054
|
+
* panneau qui les précède, celui du volet de détail borde le panneau qui le suit. Sans
|
|
1055
|
+
* cette distinction, tirer vers la droite élargirait un volet ancré à droite.
|
|
1056
|
+
*/
|
|
1057
|
+
const PANELS = {
|
|
1058
|
+
"resize-agents": { name: "--w-agents", panel: "agentsPane", edge: "before", min: 180, max: 520, initial: 240 },
|
|
1059
|
+
"resize-threads": { name: "--w-threads", panel: "threadsPane", edge: "before", min: 260, max: 720, initial: 400 },
|
|
1060
|
+
"resize-sheet": { name: "--w-sheet", panel: "sheet", edge: "after", min: 280, max: 720, initial: 360 },
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
const LAYOUT_KEY = "messenger.layout";
|
|
1064
|
+
|
|
1065
|
+
function setPanelWidth(id, pixels) {
|
|
1066
|
+
const panel = PANELS[id];
|
|
1067
|
+
const width = Math.round(Math.min(panel.max, Math.max(panel.min, pixels)));
|
|
1068
|
+
// Passer par le CSSOM plutôt que par un attribut `style` : la CSP interdit le second.
|
|
1069
|
+
el.console.style.setProperty(panel.name, `${width}px`);
|
|
1070
|
+
return width;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function panelWidth(id) {
|
|
1074
|
+
const current = parseInt(el.console.style.getPropertyValue(PANELS[id].name), 10);
|
|
1075
|
+
return Number.isFinite(current) ? current : PANELS[id].initial;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Les largeurs suivent l'utilisateur d'une visite à l'autre : elles vivent dans son navigateur. */
|
|
1079
|
+
function saveLayout() {
|
|
1080
|
+
try {
|
|
1081
|
+
const widths = {};
|
|
1082
|
+
for (const id of Object.keys(PANELS)) widths[id] = panelWidth(id);
|
|
1083
|
+
window.localStorage.setItem(LAYOUT_KEY, JSON.stringify(widths));
|
|
1084
|
+
} catch {
|
|
1085
|
+
// Navigation privée, stockage refusé : la mise en page reste bonne pour cette visite.
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function loadLayout() {
|
|
1090
|
+
let saved = null;
|
|
1091
|
+
try {
|
|
1092
|
+
saved = JSON.parse(window.localStorage.getItem(LAYOUT_KEY) || "null");
|
|
1093
|
+
} catch {
|
|
1094
|
+
saved = null;
|
|
1095
|
+
}
|
|
1096
|
+
if (!saved) return;
|
|
1097
|
+
for (const [id, width] of Object.entries(saved)) {
|
|
1098
|
+
// Une largeur venue du stockage est une donnée comme une autre : elle est bornée
|
|
1099
|
+
// comme si elle sortait d'un glissé, sinon une valeur aberrante casserait la page.
|
|
1100
|
+
if (PANELS[id] && Number.isFinite(Number(width))) setPanelWidth(id, Number(width));
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function startResize(event, id) {
|
|
1105
|
+
const panel = PANELS[id];
|
|
1106
|
+
const handle = document.getElementById(id);
|
|
1107
|
+
const rect = el[panel.panel].getBoundingClientRect();
|
|
1108
|
+
const measure = (x) => (panel.edge === "before" ? x - rect.left : rect.right - x);
|
|
1109
|
+
|
|
1110
|
+
el.console.classList.add("is-resizing");
|
|
1111
|
+
handle.classList.add("is-active");
|
|
1112
|
+
handle.setPointerCapture?.(event.pointerId);
|
|
1113
|
+
|
|
1114
|
+
const onMove = (move) => setPanelWidth(id, measure(move.clientX));
|
|
1115
|
+
const onUp = () => {
|
|
1116
|
+
window.removeEventListener("pointermove", onMove);
|
|
1117
|
+
window.removeEventListener("pointerup", onUp);
|
|
1118
|
+
el.console.classList.remove("is-resizing");
|
|
1119
|
+
handle.classList.remove("is-active");
|
|
1120
|
+
saveLayout();
|
|
1121
|
+
};
|
|
1122
|
+
window.addEventListener("pointermove", onMove);
|
|
1123
|
+
window.addEventListener("pointerup", onUp);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** Au clavier, les flèches déplacent le séparateur ; le pas est plus large avec Maj. */
|
|
1127
|
+
function nudgeResize(event, id) {
|
|
1128
|
+
const step = (event.shiftKey ? 48 : 16) * (event.key === "ArrowLeft" ? -1 : 1);
|
|
1129
|
+
const towards = PANELS[id].edge === "before" ? step : -step;
|
|
1130
|
+
setPanelWidth(id, panelWidth(id) + towards);
|
|
1131
|
+
saveLayout();
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
for (const id of Object.keys(PANELS)) {
|
|
1135
|
+
const handle = document.getElementById(id);
|
|
1136
|
+
handle.addEventListener("pointerdown", (event) => {
|
|
1137
|
+
event.preventDefault();
|
|
1138
|
+
startResize(event, id);
|
|
1139
|
+
});
|
|
1140
|
+
// Un double-clic rend sa largeur d'origine : c'est le moyen de sortir d'un réglage raté.
|
|
1141
|
+
handle.addEventListener("dblclick", () => {
|
|
1142
|
+
setPanelWidth(id, PANELS[id].initial);
|
|
1143
|
+
saveLayout();
|
|
1144
|
+
});
|
|
1145
|
+
handle.addEventListener("keydown", (event) => {
|
|
1146
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
1147
|
+
event.preventDefault();
|
|
1148
|
+
nudgeResize(event, id);
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
loadLayout();
|
|
1153
|
+
|
|
1154
|
+
// --- Purge des boîtes inactives --------------------------------------------
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Ce que la fenêtre de nettoyage a sous la main : à quel titre les boîtes sont
|
|
1158
|
+
* supprimables (`empty` ou une date), celles que le serveur a désignées, celles qui
|
|
1159
|
+
* restent cochées, et si la suppression a déjà été demandée une fois. Le second clic est
|
|
1160
|
+
* ce qui la déclenche.
|
|
1161
|
+
*/
|
|
1162
|
+
const purge = { mode: null, before: null, mailboxes: [], selected: new Set(), armed: false };
|
|
1163
|
+
|
|
1164
|
+
function openPurge() {
|
|
1165
|
+
const month = new Date(Date.now() - 30 * 86_400_000);
|
|
1166
|
+
el.purgeBefore.value = month.toISOString().slice(0, 10);
|
|
1167
|
+
el.purgeBefore.max = new Date().toISOString().slice(0, 10);
|
|
1168
|
+
resetPurge();
|
|
1169
|
+
el.purge.showModal();
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Ouvre la fenêtre sur une seule boîte, celle qu'on vient de désigner dans la colonne.
|
|
1174
|
+
*
|
|
1175
|
+
* Rien à demander au serveur : l'annuaire vient d'être lu, et ce qu'il en dit est ce que
|
|
1176
|
+
* la ligne cliquée affiche. La liste est donc dressée sur place, avec la même mise en
|
|
1177
|
+
* forme et la même confirmation que les nettoyages en lot.
|
|
1178
|
+
*/
|
|
1179
|
+
function openPurgeFor(box) {
|
|
1180
|
+
openPurge();
|
|
1181
|
+
purge.mode = "one";
|
|
1182
|
+
purge.mailboxes = [
|
|
1183
|
+
{
|
|
1184
|
+
id: box.id,
|
|
1185
|
+
agent_name: box.agent_name,
|
|
1186
|
+
display_name: box.display_name,
|
|
1187
|
+
last_activity_at: box.last_activity_at,
|
|
1188
|
+
messages: box.sent + box.received,
|
|
1189
|
+
},
|
|
1190
|
+
];
|
|
1191
|
+
purge.selected = new Set([box.id]);
|
|
1192
|
+
renderPurgePreview();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Toute modification remet la confirmation à zéro : on ne supprime que ce qu'on vient de voir. */
|
|
1196
|
+
function resetPurge() {
|
|
1197
|
+
purge.mode = null;
|
|
1198
|
+
purge.before = null;
|
|
1199
|
+
purge.mailboxes = [];
|
|
1200
|
+
purge.selected = new Set();
|
|
1201
|
+
purge.armed = false;
|
|
1202
|
+
el.purgeResult.replaceChildren();
|
|
1203
|
+
el.purgeConfirm.disabled = true;
|
|
1204
|
+
el.purgeConfirm.textContent = "Supprimer";
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Boîtes par lesquelles rien n'est jamais passé, sans considération de date. */
|
|
1208
|
+
async function previewEmpty() {
|
|
1209
|
+
await previewPurge("empty", () => api("/admin/mailboxes/empty"));
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** Boîtes qui ont servi, mais que plus rien n'a touchées depuis la date choisie. */
|
|
1213
|
+
async function previewStale() {
|
|
1214
|
+
const before = el.purgeBefore.value;
|
|
1215
|
+
if (!before) return;
|
|
1216
|
+
await previewPurge("stale", () => api("/admin/mailboxes/stale", { before }), before);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function previewPurge(mode, load, before = null) {
|
|
1220
|
+
resetPurge();
|
|
1221
|
+
try {
|
|
1222
|
+
const { mailboxes } = await load();
|
|
1223
|
+
purge.mode = mode;
|
|
1224
|
+
purge.before = before;
|
|
1225
|
+
purge.mailboxes = mailboxes;
|
|
1226
|
+
purge.selected = new Set(mailboxes.map((mailbox) => mailbox.id));
|
|
1227
|
+
renderPurgePreview();
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
el.purgeResult.replaceChildren(node("p", "purge-error", `Erreur : ${error.message}`));
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function renderPurgePreview() {
|
|
1234
|
+
const container = document.createDocumentFragment();
|
|
1235
|
+
const count = purge.mailboxes.length;
|
|
1236
|
+
// Le critère est rappelé sous les yeux de qui va confirmer : les trois suppressions
|
|
1237
|
+
// remplissent la même liste, et elles ne portent pas sur les mêmes boîtes.
|
|
1238
|
+
const since = ` sans activité depuis le ${formatDay(purge.before)}`;
|
|
1239
|
+
const criterion = purge.mode === "empty" ? "vide" : purge.mode === "one" ? "" : since;
|
|
1240
|
+
|
|
1241
|
+
if (count === 0) {
|
|
1242
|
+
container.appendChild(node("p", "purge-empty", `Aucune boîte ${criterion.trim()}.`));
|
|
1243
|
+
el.purgeResult.replaceChildren(container);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
container.appendChild(
|
|
1248
|
+
node(
|
|
1249
|
+
"p",
|
|
1250
|
+
"purge-count",
|
|
1251
|
+
purge.mode === "one"
|
|
1252
|
+
? "Cette boîte, et tout ce que personne d'autre ne pourrait plus lire :"
|
|
1253
|
+
: purge.mode === "empty"
|
|
1254
|
+
? `${plural(count, "boîte vide", "boîtes vides")}.`
|
|
1255
|
+
: `${plural(count, "boîte")}${since}.`
|
|
1256
|
+
)
|
|
1257
|
+
);
|
|
1258
|
+
|
|
1259
|
+
const list = node("ul", "purge-list");
|
|
1260
|
+
for (const mailbox of purge.mailboxes) {
|
|
1261
|
+
const item = node("li", "purge-item");
|
|
1262
|
+
const label = node("label", "purge-line");
|
|
1263
|
+
|
|
1264
|
+
const box = document.createElement("input");
|
|
1265
|
+
box.type = "checkbox";
|
|
1266
|
+
box.checked = true;
|
|
1267
|
+
box.addEventListener("change", () => {
|
|
1268
|
+
if (box.checked) purge.selected.add(mailbox.id);
|
|
1269
|
+
else purge.selected.delete(mailbox.id);
|
|
1270
|
+
// Décocher change ce qui sera supprimé : la confirmation déjà armée ne vaut plus.
|
|
1271
|
+
purge.armed = false;
|
|
1272
|
+
updatePurgeButton();
|
|
1273
|
+
});
|
|
1274
|
+
label.appendChild(box);
|
|
1275
|
+
|
|
1276
|
+
const text = node("span", "purge-text");
|
|
1277
|
+
text.appendChild(node("span", "purge-name", mailbox.display_name || mailbox.agent_name || "boîte sans nom"));
|
|
1278
|
+
text.appendChild(node("span", "mono muted", mailbox.id));
|
|
1279
|
+
const activity = mailbox.last_activity_at ? `dernière activité ${formatDay(mailbox.last_activity_at)}` : "jamais utilisée";
|
|
1280
|
+
text.appendChild(node("span", "purge-meta", `${plural(mailbox.messages, "message")} · ${activity}`));
|
|
1281
|
+
label.appendChild(text);
|
|
1282
|
+
|
|
1283
|
+
item.appendChild(label);
|
|
1284
|
+
list.appendChild(item);
|
|
1285
|
+
}
|
|
1286
|
+
container.appendChild(list);
|
|
1287
|
+
|
|
1288
|
+
el.purgeResult.replaceChildren(container);
|
|
1289
|
+
updatePurgeButton();
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function updatePurgeButton() {
|
|
1293
|
+
const count = purge.selected.size;
|
|
1294
|
+
el.purgeConfirm.disabled = count === 0;
|
|
1295
|
+
el.purgeConfirm.textContent = purge.armed
|
|
1296
|
+
? `Confirmer : supprimer ${plural(count, "boîte")}`
|
|
1297
|
+
: `Supprimer ${plural(count, "boîte")}`;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async function confirmPurge() {
|
|
1301
|
+
if (purge.selected.size === 0 || !purge.mode) return;
|
|
1302
|
+
|
|
1303
|
+
// Premier clic : la suppression est armée, pas faite. Rien ne part sans un second clic.
|
|
1304
|
+
if (!purge.armed) {
|
|
1305
|
+
purge.armed = true;
|
|
1306
|
+
updatePurgeButton();
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
el.purgeConfirm.disabled = true;
|
|
1311
|
+
const deleted = [...purge.selected];
|
|
1312
|
+
try {
|
|
1313
|
+
// Une boîte désignée à la main s'efface par son adresse : il n'y a pas de critère à
|
|
1314
|
+
// revérifier, l'utilisateur vient de la montrer du doigt.
|
|
1315
|
+
const removed =
|
|
1316
|
+
purge.mode === "one"
|
|
1317
|
+
? await remove(`/admin/mailboxes/${encodeURIComponent(deleted[0])}`)
|
|
1318
|
+
: await post("/admin/mailboxes/purge", {
|
|
1319
|
+
// Le critère repart avec les adresses : c'est lui que le serveur revérifie.
|
|
1320
|
+
...(purge.mode === "empty" ? { empty: true } : { before: purge.before }),
|
|
1321
|
+
mailboxes: deleted,
|
|
1322
|
+
});
|
|
1323
|
+
resetPurge();
|
|
1324
|
+
el.purgeResult.replaceChildren(
|
|
1325
|
+
node(
|
|
1326
|
+
"p",
|
|
1327
|
+
"purge-done",
|
|
1328
|
+
`${plural(removed.mailboxes, "boîte")} supprimée(s), ${plural(removed.messages, "message")} et ${plural(removed.threads, "fil")} avec.`
|
|
1329
|
+
)
|
|
1330
|
+
);
|
|
1331
|
+
// La console montre encore ce qui n'existe plus : elle se relit entièrement, et son
|
|
1332
|
+
// filtre est relâché s'il portait sur une boîte qui vient de disparaître.
|
|
1333
|
+
if (state.selection.kind === "box" && deleted.includes(state.selection.key)) {
|
|
1334
|
+
state.selection = { kind: "all", key: null };
|
|
1335
|
+
}
|
|
1336
|
+
state.threadId = null;
|
|
1337
|
+
state.messageId = null;
|
|
1338
|
+
el.sheet.hidden = true;
|
|
1339
|
+
el.console.classList.remove("has-sheet");
|
|
1340
|
+
await refresh();
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
purge.armed = false;
|
|
1343
|
+
updatePurgeButton();
|
|
1344
|
+
el.purgeResult.appendChild(node("p", "purge-error", `Erreur : ${error.message}`));
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// --- Écouteurs -------------------------------------------------------------
|
|
1349
|
+
|
|
1350
|
+
el.purgeOpen.addEventListener("click", openPurge);
|
|
1351
|
+
el.purgeClose.addEventListener("click", () => el.purge.close());
|
|
1352
|
+
el.purgeCancel.addEventListener("click", () => el.purge.close());
|
|
1353
|
+
el.purgeBefore.addEventListener("change", resetPurge);
|
|
1354
|
+
el.purgePreview.addEventListener("click", previewStale);
|
|
1355
|
+
el.purgePreviewEmpty.addEventListener("click", previewEmpty);
|
|
1356
|
+
el.purgeConfirm.addEventListener("click", confirmPurge);
|
|
1357
|
+
|
|
1358
|
+
el.refresh.addEventListener("click", () => refresh({ keepThread: true }));
|
|
1359
|
+
|
|
1360
|
+
// Le bouton reste en place une fois la colonne repliée : il est le seul moyen de la
|
|
1361
|
+
// rouvrir. Seul ce qu'il annonce change.
|
|
1362
|
+
el.collapse.addEventListener("click", () => {
|
|
1363
|
+
state.collapsed = !state.collapsed;
|
|
1364
|
+
el.console.classList.toggle("is-collapsed", state.collapsed);
|
|
1365
|
+
const label = state.collapsed ? "Déplier la liste des agents" : "Replier la liste des agents";
|
|
1366
|
+
el.collapse.title = state.collapsed ? "Déplier" : "Replier";
|
|
1367
|
+
el.collapse.setAttribute("aria-label", label);
|
|
1368
|
+
el.collapse.setAttribute("aria-expanded", String(!state.collapsed));
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
el.chips.addEventListener("click", (event) => {
|
|
1372
|
+
const chip = event.target.closest(".chip");
|
|
1373
|
+
if (!chip) return;
|
|
1374
|
+
state.filter = chip.dataset.filter;
|
|
1375
|
+
for (const other of el.chips.querySelectorAll(".chip")) {
|
|
1376
|
+
other.classList.toggle("is-active", other === chip);
|
|
1377
|
+
}
|
|
1378
|
+
refresh({ keepThread: true });
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
let searchTimer = null;
|
|
1382
|
+
el.search.addEventListener("input", (event) => {
|
|
1383
|
+
state.search = event.target.value.trim();
|
|
1384
|
+
clearTimeout(searchTimer);
|
|
1385
|
+
searchTimer = setTimeout(() => refresh({ keepThread: true }), 250);
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
el.tokenReveal.addEventListener("click", () => {
|
|
1389
|
+
const shown = el.token.type === "text";
|
|
1390
|
+
el.token.type = shown ? "password" : "text";
|
|
1391
|
+
el.tokenReveal.setAttribute("aria-label", shown ? "Afficher le jeton" : "Masquer le jeton");
|
|
1392
|
+
el.token.focus();
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
el.loginForm.addEventListener("submit", async (event) => {
|
|
1396
|
+
event.preventDefault();
|
|
1397
|
+
el.tokenError.textContent = "";
|
|
1398
|
+
el.tokenField.classList.remove("is-invalid");
|
|
1399
|
+
const token = el.token.value.trim();
|
|
1400
|
+
if (!token) return;
|
|
1401
|
+
const response = await fetch("/ui/login", {
|
|
1402
|
+
method: "POST",
|
|
1403
|
+
headers: { "content-type": "application/json" },
|
|
1404
|
+
credentials: "same-origin",
|
|
1405
|
+
body: JSON.stringify({ token }),
|
|
1406
|
+
}).catch(() => null);
|
|
1407
|
+
// Le champ est vidé dans tous les cas : le jeton ne reste pas affiché dans la page.
|
|
1408
|
+
el.token.value = "";
|
|
1409
|
+
el.token.type = "password";
|
|
1410
|
+
if (!response || !response.ok) {
|
|
1411
|
+
el.tokenField.classList.add("is-invalid");
|
|
1412
|
+
el.tokenError.textContent =
|
|
1413
|
+
response && response.status === 429
|
|
1414
|
+
? "Trop de tentatives, réessayez dans quelques minutes."
|
|
1415
|
+
: "Jeton refusé. Vérifiez MESSENGER_ADMIN_TOKEN côté serveur.";
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
refresh();
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
el.logout.addEventListener("click", async () => {
|
|
1422
|
+
await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
|
|
1423
|
+
state.selection = { kind: "all", key: null };
|
|
1424
|
+
state.expanded = new Set();
|
|
1425
|
+
state.showEmpty = new Set();
|
|
1426
|
+
state.threadId = null;
|
|
1427
|
+
state.messageId = null;
|
|
1428
|
+
state.directory = new Map();
|
|
1429
|
+
state.groups = [];
|
|
1430
|
+
el.sheet.hidden = true;
|
|
1431
|
+
el.console.classList.remove("has-sheet");
|
|
1432
|
+
el.agents.replaceChildren();
|
|
1433
|
+
el.threads.replaceChildren();
|
|
1434
|
+
renderEmptyReader();
|
|
1435
|
+
setLoggedIn(false);
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
setInterval(() => {
|
|
1439
|
+
if (el.autoRefresh.checked && el.login.hidden) refresh({ keepThread: true });
|
|
1440
|
+
}, REFRESH_MS);
|
|
1441
|
+
|
|
1442
|
+
el.loginOrigin.textContent = `MCP Messenger · ${window.location.host}`;
|
|
1443
|
+
renderEmptyReader();
|
|
1444
|
+
refresh();
|