@polderlabs/bizar 5.0.0 → 5.0.2
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/bizar-dash/dist/assets/{icons-CFqu2M-c.js → icons-Bo0iH9EC.js} +166 -121
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
- package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
- package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js → mobile-DD-FZrTC.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js.map → mobile-DD-FZrTC.js.map} +1 -1
- package/bizar-dash/dist/index.html +4 -4
- package/bizar-dash/dist/mobile.html +3 -3
- package/bizar-dash/node_modules/.package-lock.json +6 -0
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/package-lock.json +6 -0
- package/bizar-dash/src/server/api.mjs +7 -0
- package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
- package/bizar-dash/src/server/memory-store.mjs +46 -0
- package/bizar-dash/src/server/providers-store.mjs +11 -4
- package/bizar-dash/src/server/routes/_shared.mjs +2 -2
- package/bizar-dash/src/server/routes/doctor.mjs +71 -0
- package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
- package/bizar-dash/src/server/routes/memory.mjs +3 -0
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
- package/bizar-dash/src/server/routes/schedules.mjs +55 -0
- package/bizar-dash/src/server/serve-info.mjs +172 -0
- package/bizar-dash/src/web/App.tsx +48 -11
- package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
- package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
- package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
- package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
- package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
- package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
- package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
- package/bizar-dash/src/web/components/Topbar.tsx +11 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
- package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
- package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
- package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
- package/bizar-dash/src/web/lib/types.ts +134 -0
- package/bizar-dash/src/web/styles/chat.css +8 -5
- package/bizar-dash/src/web/styles/main.css +77 -2
- package/bizar-dash/src/web/styles/settings.css +265 -0
- package/bizar-dash/src/web/views/Chat.tsx +15 -1
- package/bizar-dash/src/web/views/Doctor.tsx +317 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
- package/bizar-dash/src/web/views/Schedules.tsx +94 -12
- package/bizar-dash/src/web/views/Settings.tsx +76 -46
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
- package/bizar-dash/tests/autosave.test.tsx +276 -0
- package/bizar-dash/tests/chat-composer.test.tsx +140 -0
- package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
- package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
- package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
- package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
- package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
- package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
- package/bizar-dash/tests/minimax-models.test.mjs +123 -0
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
- package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
- package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
- package/bizar-dash/tests/settings-layout.test.tsx +129 -0
- package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
- package/bizar-dash/tests/settings-nav.test.tsx +126 -0
- package/cli/commands/dash.mjs +5 -1
- package/cli/commands/minimax.mjs +5 -2
- package/cli/tests/minimax-cli.test.mjs +79 -0
- package/config/agents/frigg.md +1 -1
- package/config/agents/heimdall.md +1 -1
- package/config/agents/mimir.md +1 -1
- package/config/agents/quick.md +1 -1
- package/config/agents/semble-search.md +1 -1
- package/config/agents/vor.md +1 -1
- package/config/opencode.json +28 -21
- package/config/opencode.json.template +8 -1
- package/package.json +1 -1
- package/plugins/bizar/index.ts +77 -0
- package/plugins/bizar/src/compaction.d.mts +48 -0
- package/plugins/bizar/src/compaction.mjs +192 -0
- package/plugins/bizar/tests/compaction.test.ts +264 -0
- package/templates/schedules/daily-backup.json +12 -0
- package/templates/schedules/daily-cleanup.json +12 -0
- package/templates/schedules/hourly-health-check.json +12 -0
- package/templates/schedules/webhook-on-push.json +13 -0
- package/templates/schedules/weekly-digest.json +13 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +0 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +0 -16
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +0 -1
- package/bizar-dash/dist/assets/main-ZAfGKENE.css +0 -1
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +0 -2
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +0 -1
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
|
@@ -413,6 +413,92 @@
|
|
|
413
413
|
flex-wrap: wrap;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
/* Bulk import panel */
|
|
417
|
+
.env-var-bulk-panel {
|
|
418
|
+
display: flex;
|
|
419
|
+
flex-direction: column;
|
|
420
|
+
gap: var(--space-2);
|
|
421
|
+
padding: 12px;
|
|
422
|
+
background: var(--bg-elev-2);
|
|
423
|
+
border: 1px solid var(--accent-border);
|
|
424
|
+
border-radius: var(--radius);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.env-var-bulk-header {
|
|
428
|
+
display: flex;
|
|
429
|
+
justify-content: space-between;
|
|
430
|
+
align-items: center;
|
|
431
|
+
font-size: 12px;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.env-var-bulk-textarea {
|
|
435
|
+
resize: vertical;
|
|
436
|
+
min-height: 80px;
|
|
437
|
+
font-size: 12px;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.env-var-bulk-footer {
|
|
441
|
+
display: flex;
|
|
442
|
+
align-items: center;
|
|
443
|
+
gap: var(--space-2);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/* Search */
|
|
447
|
+
.env-var-search-wrap {
|
|
448
|
+
position: relative;
|
|
449
|
+
display: flex;
|
|
450
|
+
align-items: center;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.env-var-search-icon {
|
|
454
|
+
position: absolute;
|
|
455
|
+
left: 8px;
|
|
456
|
+
color: var(--text-dim);
|
|
457
|
+
pointer-events: none;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.env-var-search {
|
|
461
|
+
padding-left: 26px !important;
|
|
462
|
+
width: 160px;
|
|
463
|
+
font-size: 12px;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* Groups */
|
|
467
|
+
.env-var-groups {
|
|
468
|
+
display: flex;
|
|
469
|
+
flex-direction: column;
|
|
470
|
+
gap: var(--space-3);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.env-var-group-header {
|
|
474
|
+
display: flex;
|
|
475
|
+
align-items: center;
|
|
476
|
+
gap: 6px;
|
|
477
|
+
padding: 4px 8px;
|
|
478
|
+
background: var(--bg-elev-1);
|
|
479
|
+
border: none;
|
|
480
|
+
border-radius: var(--radius-sm);
|
|
481
|
+
cursor: pointer;
|
|
482
|
+
font-size: 12px;
|
|
483
|
+
color: var(--text);
|
|
484
|
+
width: 100%;
|
|
485
|
+
text-align: left;
|
|
486
|
+
transition: background var(--motion-fast);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.env-var-group-header:hover {
|
|
490
|
+
background: var(--bg-elev-2);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.env-var-group-prefix {
|
|
494
|
+
font-weight: 600;
|
|
495
|
+
color: var(--accent);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.env-var-group-count {
|
|
499
|
+
font-size: 11px;
|
|
500
|
+
}
|
|
501
|
+
|
|
416
502
|
/* Memory section */
|
|
417
503
|
.memory-field-row {
|
|
418
504
|
display: grid;
|
|
@@ -496,3 +582,182 @@
|
|
|
496
582
|
grid-template-columns: 1fr;
|
|
497
583
|
}
|
|
498
584
|
}
|
|
585
|
+
|
|
586
|
+
/* ─── Autosave field status indicator ─── */
|
|
587
|
+
.autosave-field {
|
|
588
|
+
position: relative;
|
|
589
|
+
display: inline-block;
|
|
590
|
+
width: 100%;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
.autosave-status {
|
|
594
|
+
position: absolute;
|
|
595
|
+
right: 8px;
|
|
596
|
+
top: 50%;
|
|
597
|
+
transform: translateY(-50%);
|
|
598
|
+
display: inline-flex;
|
|
599
|
+
align-items: center;
|
|
600
|
+
gap: 4px;
|
|
601
|
+
color: var(--success, #3fb950);
|
|
602
|
+
font-size: 11px;
|
|
603
|
+
opacity: 0;
|
|
604
|
+
transition: opacity 200ms ease-out;
|
|
605
|
+
pointer-events: none;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.autosave-field.saved .autosave-status,
|
|
609
|
+
.autosave-field.saving .autosave-status,
|
|
610
|
+
.autosave-field.error .autosave-status {
|
|
611
|
+
opacity: 1;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.autosave-field.error .autosave-status {
|
|
615
|
+
color: var(--error, #f85149);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
.autosave-field.saving .autosave-status {
|
|
619
|
+
animation: autosave-save-pulse 800ms ease-out;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.autosave-field.saved .autosave-status {
|
|
623
|
+
animation: autosave-saved-fade 1500ms ease-out;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
@keyframes autosave-save-pulse {
|
|
627
|
+
0% { transform: translateY(-50%) scale(0.5); opacity: 0; }
|
|
628
|
+
50% { transform: translateY(-50%) scale(1.2); opacity: 1; }
|
|
629
|
+
100% { transform: translateY(-50%) scale(1); opacity: 1; }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
@keyframes autosave-saved-fade {
|
|
633
|
+
0% { transform: translateY(-50%) scale(0.5); opacity: 0; }
|
|
634
|
+
30% { transform: translateY(-50%) scale(1.1); opacity: 1; }
|
|
635
|
+
70% { transform: translateY(-50%) scale(1); opacity: 1; }
|
|
636
|
+
100% { transform: translateY(-50%) scale(1); opacity: 0; }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.spinning {
|
|
640
|
+
animation: spin 1s linear infinite;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
@keyframes spin {
|
|
644
|
+
from { transform: rotate(0deg); }
|
|
645
|
+
to { transform: rotate(360deg); }
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/* ─────────────────────────────────────────────────────────────────────
|
|
649
|
+
v4.9.0 — Settings sidebar mode (sidebar layout only)
|
|
650
|
+
When settingsMode is active, the sidebar replaces the normal tab rail
|
|
651
|
+
with a full-width nav showing all settings sections grouped by category.
|
|
652
|
+
───────────────────────────────────────────────────────────────────── */
|
|
653
|
+
|
|
654
|
+
/* Sidebar when in settings mode — full height, scrollable */
|
|
655
|
+
.sidebar-settings-mode {
|
|
656
|
+
width: var(--sidebar-width, 240px);
|
|
657
|
+
overflow-y: auto;
|
|
658
|
+
border-right: 1px solid var(--border);
|
|
659
|
+
background: var(--bg);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/* Settings nav root */
|
|
663
|
+
.settings-nav-root {
|
|
664
|
+
display: flex;
|
|
665
|
+
flex-direction: column;
|
|
666
|
+
padding: var(--space-3);
|
|
667
|
+
gap: 2px;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/* Back button */
|
|
671
|
+
.settings-nav-back {
|
|
672
|
+
display: flex;
|
|
673
|
+
align-items: center;
|
|
674
|
+
gap: 6px;
|
|
675
|
+
padding: 6px 8px;
|
|
676
|
+
border-radius: var(--radius);
|
|
677
|
+
border: none;
|
|
678
|
+
background: transparent;
|
|
679
|
+
color: var(--text-dim);
|
|
680
|
+
font-size: 13px;
|
|
681
|
+
font-weight: 500;
|
|
682
|
+
cursor: pointer;
|
|
683
|
+
transition: background var(--motion-fast), color var(--motion-fast);
|
|
684
|
+
margin-bottom: var(--space-2);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.settings-nav-back:hover {
|
|
688
|
+
background: var(--bg-elev-2);
|
|
689
|
+
color: var(--text);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.settings-nav-divider {
|
|
693
|
+
height: 1px;
|
|
694
|
+
background: var(--border);
|
|
695
|
+
margin: var(--space-2) 0;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/* Section groups */
|
|
699
|
+
.settings-nav-group {
|
|
700
|
+
margin-bottom: var(--space-3);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.settings-nav-group-label {
|
|
704
|
+
font-size: 10px;
|
|
705
|
+
text-transform: uppercase;
|
|
706
|
+
letter-spacing: 0.08em;
|
|
707
|
+
color: var(--text-dim);
|
|
708
|
+
font-weight: 600;
|
|
709
|
+
padding: 4px 8px 6px;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/* Individual nav items */
|
|
713
|
+
.settings-nav-item {
|
|
714
|
+
display: flex;
|
|
715
|
+
align-items: center;
|
|
716
|
+
gap: 8px;
|
|
717
|
+
padding: 6px 8px;
|
|
718
|
+
border-radius: var(--radius);
|
|
719
|
+
border: 1px solid transparent;
|
|
720
|
+
background: transparent;
|
|
721
|
+
color: var(--text-dim);
|
|
722
|
+
font-size: 13px;
|
|
723
|
+
font-weight: 500;
|
|
724
|
+
cursor: pointer;
|
|
725
|
+
text-align: left;
|
|
726
|
+
width: 100%;
|
|
727
|
+
transition: background var(--motion-fast), color var(--motion-fast), border-color var(--motion-fast);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.settings-nav-item:hover {
|
|
731
|
+
background: var(--bg-elev-2);
|
|
732
|
+
color: var(--text);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.settings-nav-item-active {
|
|
736
|
+
background: var(--accent-bg);
|
|
737
|
+
color: var(--accent-2);
|
|
738
|
+
border-color: var(--accent-border);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
.settings-nav-item-active:hover {
|
|
742
|
+
background: var(--accent-bg);
|
|
743
|
+
color: var(--accent-2);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
.settings-nav-item-icon {
|
|
747
|
+
flex-shrink: 0;
|
|
748
|
+
opacity: 0.7;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
.settings-nav-item-active .settings-nav-item-icon {
|
|
752
|
+
opacity: 1;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
.settings-nav-item-label {
|
|
756
|
+
flex: 1;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
.settings-nav-item-chevron {
|
|
760
|
+
flex-shrink: 0;
|
|
761
|
+
opacity: 0.5;
|
|
762
|
+
}
|
|
763
|
+
|
|
@@ -354,7 +354,21 @@ export function Chat({
|
|
|
354
354
|
</div>
|
|
355
355
|
{chat.opencodeError && (
|
|
356
356
|
<div className="chat-thread-error" role="alert">
|
|
357
|
-
{chat.opencodeError}
|
|
357
|
+
<p className="chat-error-message">{chat.opencodeError}</p>
|
|
358
|
+
{chat.opencodeSuggestion && (
|
|
359
|
+
<p className="chat-error-suggestion">{chat.opencodeSuggestion}</p>
|
|
360
|
+
)}
|
|
361
|
+
{chat.activeOpencodeSessionId && (
|
|
362
|
+
<button
|
|
363
|
+
className="btn btn-sm"
|
|
364
|
+
type="button"
|
|
365
|
+
onClick={() =>
|
|
366
|
+
chat.loadOpencodeSession(chat.activeOpencodeSessionId!)
|
|
367
|
+
}
|
|
368
|
+
>
|
|
369
|
+
Retry
|
|
370
|
+
</button>
|
|
371
|
+
)}
|
|
358
372
|
</div>
|
|
359
373
|
)}
|
|
360
374
|
</div>
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// src/views/Doctor.tsx — v6.0.0 Doctor page.
|
|
2
|
+
//
|
|
3
|
+
// Full-page Doctor view: shows system health, service health, counts,
|
|
4
|
+
// recent errors, and action buttons. Refreshes every 30 seconds via
|
|
5
|
+
// the cheap `/api/doctor/health` endpoint and re-fetches the full
|
|
6
|
+
// snapshot on demand (initial mount, manual Refresh, or after a
|
|
7
|
+
// single-check POST so the operator sees the immediate result).
|
|
8
|
+
//
|
|
9
|
+
// The page deliberately leans on the existing Card component and the
|
|
10
|
+
// StatusBadge variants added in v6.0.0 (`ok`/`warn`/`fail`). Layout
|
|
11
|
+
// is a single column on narrow viewports, two columns on desktop —
|
|
12
|
+
// matching the rest of the dashboard's grid.
|
|
13
|
+
|
|
14
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import {
|
|
16
|
+
CheckCircle2,
|
|
17
|
+
AlertTriangle,
|
|
18
|
+
AlertOctagon,
|
|
19
|
+
Download,
|
|
20
|
+
FileText,
|
|
21
|
+
RefreshCw,
|
|
22
|
+
PlayCircle,
|
|
23
|
+
Stethoscope,
|
|
24
|
+
Clock,
|
|
25
|
+
} from 'lucide-react';
|
|
26
|
+
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
27
|
+
import { Button } from '../components/Button';
|
|
28
|
+
import { DoctorPanel } from '../components/DoctorPanel';
|
|
29
|
+
import { StatusBadge } from '../components/StatusBadge';
|
|
30
|
+
import { Spinner } from '../components/Spinner';
|
|
31
|
+
import { useToast } from '../components/Toast';
|
|
32
|
+
import { api } from '../lib/api';
|
|
33
|
+
import { cn, formatTime } from '../lib/utils';
|
|
34
|
+
import type { DoctorSnapshot, DoctorCheck, Settings, Snapshot } from '../lib/types';
|
|
35
|
+
|
|
36
|
+
const REFRESH_INTERVAL_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
type Props = {
|
|
39
|
+
snapshot: Snapshot;
|
|
40
|
+
settings: Settings;
|
|
41
|
+
activeTab: string;
|
|
42
|
+
setActiveTab: (id: string) => void;
|
|
43
|
+
refreshSnapshot: () => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function DoctorInner({
|
|
47
|
+
setActiveTab,
|
|
48
|
+
}: Props) {
|
|
49
|
+
const toast = useToast();
|
|
50
|
+
const [data, setData] = useState<DoctorSnapshot | null>(null);
|
|
51
|
+
const [loading, setLoading] = useState(true);
|
|
52
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
53
|
+
const [lastFetched, setLastFetched] = useState<number | null>(null);
|
|
54
|
+
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
55
|
+
const cancelledRef = useRef(false);
|
|
56
|
+
|
|
57
|
+
const fetchSnapshot = useCallback(async () => {
|
|
58
|
+
setRefreshing(true);
|
|
59
|
+
try {
|
|
60
|
+
const r = await api.get<DoctorSnapshot>('/doctor');
|
|
61
|
+
if (!cancelledRef.current) {
|
|
62
|
+
setData(r);
|
|
63
|
+
setLastFetched(Date.now());
|
|
64
|
+
setLoading(false);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (!cancelledRef.current) {
|
|
68
|
+
setLoading(false);
|
|
69
|
+
toast.error(`Doctor refresh failed: ${(err as Error).message}`);
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
if (!cancelledRef.current) setRefreshing(false);
|
|
73
|
+
}
|
|
74
|
+
}, [toast]);
|
|
75
|
+
|
|
76
|
+
// Initial fetch on mount
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
cancelledRef.current = false;
|
|
79
|
+
fetchSnapshot();
|
|
80
|
+
return () => { cancelledRef.current = true; };
|
|
81
|
+
}, [fetchSnapshot]);
|
|
82
|
+
|
|
83
|
+
// 30s auto-refresh via the cheap health endpoint so the page
|
|
84
|
+
// stays current without burning a full snapshot every tick. We
|
|
85
|
+
// only trigger a full snapshot fetch if status changed.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!autoRefresh) return;
|
|
88
|
+
const tick = async () => {
|
|
89
|
+
try {
|
|
90
|
+
const r = await api.get<{ status: string; issues: DoctorCheck[] }>('/doctor/health');
|
|
91
|
+
setData((cur) => {
|
|
92
|
+
if (!cur) return cur;
|
|
93
|
+
if (cur.health.status !== r.status || cur.health.issues.length !== r.issues.length) {
|
|
94
|
+
// Status changed — re-fetch the full snapshot
|
|
95
|
+
fetchSnapshot();
|
|
96
|
+
}
|
|
97
|
+
return cur;
|
|
98
|
+
});
|
|
99
|
+
} catch { /* non-fatal */ }
|
|
100
|
+
};
|
|
101
|
+
const id = setInterval(tick, REFRESH_INTERVAL_MS);
|
|
102
|
+
return () => clearInterval(id);
|
|
103
|
+
}, [autoRefresh, fetchSnapshot]);
|
|
104
|
+
|
|
105
|
+
const onRunCheck = async (checkName: string) => {
|
|
106
|
+
try {
|
|
107
|
+
const r = await api.post<DoctorCheck>('/doctor/check', { checkName });
|
|
108
|
+
toast.info(`${r.name}: ${r.message}`, 4000);
|
|
109
|
+
// Re-fetch so the panel reflects the fresh result
|
|
110
|
+
await fetchSnapshot();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const apiErr = err as { status?: number; data?: { message?: string } };
|
|
113
|
+
toast.error(`Check failed: ${apiErr.data?.message ?? (err as Error).message}`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const onExportJson = () => {
|
|
118
|
+
if (!data) return;
|
|
119
|
+
try {
|
|
120
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
121
|
+
const url = URL.createObjectURL(blob);
|
|
122
|
+
const a = document.createElement('a');
|
|
123
|
+
a.href = url;
|
|
124
|
+
a.download = `doctor-snapshot-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
|
125
|
+
document.body.appendChild(a);
|
|
126
|
+
a.click();
|
|
127
|
+
document.body.removeChild(a);
|
|
128
|
+
URL.revokeObjectURL(url);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
toast.error(`Export failed: ${(err as Error).message}`);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onViewLogs = () => {
|
|
135
|
+
// Logs live on the Settings → Activity Log surface (already
|
|
136
|
+
// implemented). Set the tab and let the user browse.
|
|
137
|
+
setActiveTab('settings');
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const counts = data?.counts;
|
|
141
|
+
const countsInfo = useMemo(() => {
|
|
142
|
+
if (!counts) return [];
|
|
143
|
+
return [
|
|
144
|
+
{ label: 'Tasks', value: counts.tasks },
|
|
145
|
+
{ label: 'Schedules', value: counts.schedules },
|
|
146
|
+
{ label: 'Mods', value: counts.mods },
|
|
147
|
+
{ label: 'Providers', value: counts.providers },
|
|
148
|
+
{ label: 'MCPs', value: counts.mcps },
|
|
149
|
+
{ label: 'Agents', value: counts.agents },
|
|
150
|
+
{ label: 'Projects', value: counts.projects },
|
|
151
|
+
{ label: 'Workspaces', value: counts.workspaces },
|
|
152
|
+
{ label: 'Voice notes', value: counts.voiceNotes },
|
|
153
|
+
{ label: 'Eval runs', value: counts.evalRuns },
|
|
154
|
+
{ label: 'Backups', value: counts.backups },
|
|
155
|
+
];
|
|
156
|
+
}, [counts]);
|
|
157
|
+
|
|
158
|
+
if (loading) {
|
|
159
|
+
return (
|
|
160
|
+
<div className="view view-doctor view-loading">
|
|
161
|
+
<Spinner size="lg" />
|
|
162
|
+
<p>Running health checks…</p>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!data) {
|
|
168
|
+
return (
|
|
169
|
+
<div className="view view-doctor">
|
|
170
|
+
<Card>
|
|
171
|
+
<CardTitle>
|
|
172
|
+
<AlertOctagon size={14} /> Doctor unavailable
|
|
173
|
+
</CardTitle>
|
|
174
|
+
<CardMeta>Could not reach /api/doctor.</CardMeta>
|
|
175
|
+
<div className="doctor-fallback-actions">
|
|
176
|
+
<Button variant="primary" onClick={fetchSnapshot}>
|
|
177
|
+
<RefreshCw size={12} /> Retry
|
|
178
|
+
</Button>
|
|
179
|
+
</div>
|
|
180
|
+
</Card>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const overall = data.health.status;
|
|
186
|
+
const OverallIcon =
|
|
187
|
+
overall === 'ok' ? CheckCircle2 : overall === 'warn' ? AlertTriangle : AlertOctagon;
|
|
188
|
+
const overallLabel =
|
|
189
|
+
overall === 'ok' ? 'Healthy' : overall === 'warn' ? 'Warning' : 'Failed';
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div className="view view-doctor" data-overall={overall}>
|
|
193
|
+
<div className="doctor-header">
|
|
194
|
+
<div className="doctor-header-row">
|
|
195
|
+
<div className="doctor-header-title">
|
|
196
|
+
<Stethoscope size={20} aria-hidden />
|
|
197
|
+
<h1>Doctor</h1>
|
|
198
|
+
</div>
|
|
199
|
+
<div className="doctor-header-status">
|
|
200
|
+
<span className={cn('doctor-overall-pill', `doctor-overall-${overall}`)}>
|
|
201
|
+
<OverallIcon size={14} aria-hidden />
|
|
202
|
+
<span className="doctor-overall-label">{overallLabel}</span>
|
|
203
|
+
<StatusBadge kind={overall} dot>
|
|
204
|
+
{data.health.issues.length} issue{data.health.issues.length === 1 ? '' : 's'}
|
|
205
|
+
</StatusBadge>
|
|
206
|
+
</span>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
<div className="doctor-header-meta muted">
|
|
210
|
+
<Clock size={12} aria-hidden />
|
|
211
|
+
<span>
|
|
212
|
+
v{data.bizarVersion} · {data.platform}/{data.arch} ·{' '}
|
|
213
|
+
up {Math.floor(data.uptime)}s
|
|
214
|
+
{lastFetched ? ` · refreshed ${formatTime(new Date(lastFetched))}` : ''}
|
|
215
|
+
</span>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div className="doctor-grid">
|
|
220
|
+
<DoctorPanel
|
|
221
|
+
category="system"
|
|
222
|
+
checks={data.checks.system}
|
|
223
|
+
meta={`${data.platform}/${data.arch}`}
|
|
224
|
+
/>
|
|
225
|
+
<DoctorPanel
|
|
226
|
+
category="services"
|
|
227
|
+
checks={data.checks.services}
|
|
228
|
+
meta={
|
|
229
|
+
data.services.opencode?.reachable
|
|
230
|
+
? `opencode on ${data.services.opencode.port ?? '?'}`
|
|
231
|
+
: 'opencode not connected'
|
|
232
|
+
}
|
|
233
|
+
/>
|
|
234
|
+
<DoctorPanel
|
|
235
|
+
category="counts"
|
|
236
|
+
checks={[]}
|
|
237
|
+
info={countsInfo}
|
|
238
|
+
meta={counts?.activeProject ? `active: ${counts.activeProject}` : 'no active project'}
|
|
239
|
+
/>
|
|
240
|
+
<Card className="doctor-errors-card">
|
|
241
|
+
<CardTitle>
|
|
242
|
+
<AlertOctagon size={14} aria-hidden />
|
|
243
|
+
<span>Recent errors (last hour)</span>
|
|
244
|
+
<span className="card-meta-inline muted">
|
|
245
|
+
{data.recentErrors.length} line{data.recentErrors.length === 1 ? '' : 's'}
|
|
246
|
+
</span>
|
|
247
|
+
</CardTitle>
|
|
248
|
+
{data.recentErrors.length === 0 ? (
|
|
249
|
+
<div className="muted doctor-errors-empty">No errors in the last hour. Nice.</div>
|
|
250
|
+
) : (
|
|
251
|
+
<ul className="doctor-errors-list" role="list">
|
|
252
|
+
{data.recentErrors.map((e, idx) => (
|
|
253
|
+
<li key={`${e.tsMs ?? idx}-${idx}`} className="doctor-error-row">
|
|
254
|
+
<span className="doctor-error-ts mono">
|
|
255
|
+
{e.ts ? formatTime(new Date(e.ts)) : '??:??:??'}
|
|
256
|
+
</span>
|
|
257
|
+
<span className="doctor-error-line mono">{e.line}</span>
|
|
258
|
+
</li>
|
|
259
|
+
))}
|
|
260
|
+
</ul>
|
|
261
|
+
)}
|
|
262
|
+
</Card>
|
|
263
|
+
<Card className="doctor-actions-card">
|
|
264
|
+
<CardTitle>
|
|
265
|
+
<PlayCircle size={14} aria-hidden /> Actions
|
|
266
|
+
</CardTitle>
|
|
267
|
+
<CardMeta>Run a check, view logs, or export the snapshot.</CardMeta>
|
|
268
|
+
<div className="doctor-actions-row">
|
|
269
|
+
<Button variant="primary" onClick={fetchSnapshot} disabled={refreshing}>
|
|
270
|
+
{refreshing ? <Spinner size="sm" /> : <RefreshCw size={12} />}
|
|
271
|
+
{refreshing ? 'Running…' : 'Run Health Check Now'}
|
|
272
|
+
</Button>
|
|
273
|
+
<Button variant="secondary" onClick={onViewLogs}>
|
|
274
|
+
<FileText size={12} /> View Full Logs
|
|
275
|
+
</Button>
|
|
276
|
+
<Button variant="secondary" onClick={onExportJson}>
|
|
277
|
+
<Download size={12} /> Export Diagnostics as JSON
|
|
278
|
+
</Button>
|
|
279
|
+
<Button
|
|
280
|
+
variant="ghost"
|
|
281
|
+
onClick={() => setAutoRefresh((v) => !v)}
|
|
282
|
+
title="Toggle auto-refresh"
|
|
283
|
+
>
|
|
284
|
+
{autoRefresh ? 'Pause auto-refresh' : 'Resume auto-refresh'}
|
|
285
|
+
</Button>
|
|
286
|
+
</div>
|
|
287
|
+
{data.checks.services.some((c) => c.status !== 'ok') ||
|
|
288
|
+
data.checks.config.some((c) => c.status !== 'ok') ? (
|
|
289
|
+
<div className="doctor-actions-rerun">
|
|
290
|
+
<span className="muted">Rerun a single check:</span>
|
|
291
|
+
<div className="doctor-actions-rerun-row">
|
|
292
|
+
{[
|
|
293
|
+
...data.checks.services,
|
|
294
|
+
...data.checks.config,
|
|
295
|
+
]
|
|
296
|
+
.filter((c) => c.status !== 'ok')
|
|
297
|
+
.map((c) => (
|
|
298
|
+
<Button
|
|
299
|
+
key={c.name}
|
|
300
|
+
variant="ghost"
|
|
301
|
+
size="sm"
|
|
302
|
+
onClick={() => onRunCheck(c.name)}
|
|
303
|
+
title={`Run check: ${c.name}`}
|
|
304
|
+
>
|
|
305
|
+
<PlayCircle size={10} /> {c.name}
|
|
306
|
+
</Button>
|
|
307
|
+
))}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
) : null}
|
|
311
|
+
</Card>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const Doctor = React.memo(DoctorInner);
|
|
@@ -784,6 +784,7 @@ function QuotaBar({ icon, label, remainingPct, consumedPct, used, total, resetIn
|
|
|
784
784
|
<div className="minimax-quota-head">
|
|
785
785
|
<span className="minimax-quota-label">{icon} {label}</span>
|
|
786
786
|
<span className="minimax-quota-remaining">{remainingPct}% remaining</span>
|
|
787
|
+
<span className="minimax-quota-consumed">· {consumedPct}% used</span>
|
|
787
788
|
</div>
|
|
788
789
|
<div className="minimax-bar">
|
|
789
790
|
<div className="minimax-bar-fill" style={{ width: `${consumedPct}%` }} aria-label={`${consumedPct}% consumed`} />
|