@aaronbassett/midnight-local-devnet 0.3.0 → 0.3.1

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.
@@ -553,6 +553,285 @@ export function generateDashboardHtml({ wsUrl }) {
553
553
  word-break: break-all;
554
554
  }
555
555
 
556
+ /* --- Modal Overlay --- */
557
+ .modal-overlay {
558
+ position: fixed;
559
+ inset: 0;
560
+ background: rgba(9, 9, 15, 0.85);
561
+ display: flex;
562
+ align-items: center;
563
+ justify-content: center;
564
+ z-index: 150;
565
+ backdrop-filter: blur(4px);
566
+ }
567
+
568
+ .modal-dialog {
569
+ background: var(--mn-surface);
570
+ border: 1px solid var(--mn-border);
571
+ border-radius: 12px;
572
+ padding: 24px;
573
+ width: 480px;
574
+ max-width: 90vw;
575
+ max-height: 80vh;
576
+ overflow-y: auto;
577
+ box-shadow: 0 16px 64px rgba(9, 9, 15, 0.8);
578
+ animation: fadeInUp 0.2s ease-out;
579
+ }
580
+
581
+ .modal-header {
582
+ display: flex;
583
+ align-items: center;
584
+ justify-content: space-between;
585
+ margin-bottom: 20px;
586
+ }
587
+
588
+ .modal-title {
589
+ font-size: 16px;
590
+ font-weight: 600;
591
+ color: var(--mn-text);
592
+ }
593
+
594
+ .modal-close {
595
+ background: none;
596
+ border: none;
597
+ color: var(--mn-text-secondary);
598
+ cursor: pointer;
599
+ padding: 4px;
600
+ display: flex;
601
+ align-items: center;
602
+ justify-content: center;
603
+ border-radius: 6px;
604
+ transition: all 0.15s;
605
+ }
606
+
607
+ .modal-close:hover {
608
+ color: var(--mn-text);
609
+ background: var(--mn-surface-alt);
610
+ }
611
+
612
+ /* --- Import Modal Tabs --- */
613
+ .modal-tabs {
614
+ display: flex;
615
+ gap: 0;
616
+ margin-bottom: 16px;
617
+ border-bottom: 1px solid var(--mn-border);
618
+ }
619
+
620
+ .modal-tab {
621
+ flex: 1;
622
+ padding: 10px 16px;
623
+ background: none;
624
+ border: none;
625
+ border-bottom: 2px solid transparent;
626
+ color: var(--mn-text-secondary);
627
+ font-family: 'Inter', sans-serif;
628
+ font-size: 13px;
629
+ font-weight: 500;
630
+ cursor: pointer;
631
+ transition: all 0.15s;
632
+ }
633
+
634
+ .modal-tab:hover {
635
+ color: var(--mn-text);
636
+ }
637
+
638
+ .modal-tab.active {
639
+ color: var(--mn-accent);
640
+ border-bottom-color: var(--mn-accent);
641
+ }
642
+
643
+ .modal-field {
644
+ margin-bottom: 14px;
645
+ }
646
+
647
+ .modal-label {
648
+ display: block;
649
+ font-size: 12px;
650
+ font-weight: 500;
651
+ color: var(--mn-text-secondary);
652
+ margin-bottom: 6px;
653
+ }
654
+
655
+ .modal-input {
656
+ width: 100%;
657
+ padding: 8px 12px;
658
+ border-radius: 6px;
659
+ border: 1px solid var(--mn-border);
660
+ background: var(--mn-surface-alt);
661
+ color: var(--mn-text);
662
+ font-family: 'Inter', sans-serif;
663
+ font-size: 13px;
664
+ outline: none;
665
+ transition: border-color 0.15s;
666
+ }
667
+
668
+ .modal-input:focus {
669
+ border-color: var(--mn-accent);
670
+ }
671
+
672
+ .modal-input.mono {
673
+ font-family: 'JetBrains Mono', monospace;
674
+ font-size: 12px;
675
+ }
676
+
677
+ .modal-actions {
678
+ display: flex;
679
+ gap: 8px;
680
+ justify-content: flex-end;
681
+ margin-top: 20px;
682
+ }
683
+
684
+ /* --- Wallet Selector Dropdown --- */
685
+ .wallet-selector {
686
+ display: flex;
687
+ align-items: center;
688
+ gap: 8px;
689
+ flex: 1;
690
+ min-width: 0;
691
+ }
692
+
693
+ .wallet-select {
694
+ flex: 1;
695
+ min-width: 0;
696
+ padding: 6px 10px;
697
+ border-radius: 6px;
698
+ border: 1px solid var(--mn-border);
699
+ background: var(--mn-surface-alt);
700
+ color: var(--mn-text);
701
+ font-family: 'Inter', sans-serif;
702
+ font-size: 13px;
703
+ cursor: pointer;
704
+ outline: none;
705
+ transition: border-color 0.15s;
706
+ }
707
+
708
+ .wallet-select:focus {
709
+ border-color: var(--mn-accent);
710
+ }
711
+
712
+ /* --- Inline Edit Input --- */
713
+ .inline-edit-input {
714
+ padding: 4px 8px;
715
+ border-radius: 4px;
716
+ border: 1px solid var(--mn-accent);
717
+ background: var(--mn-surface-alt);
718
+ color: var(--mn-text);
719
+ font-family: 'Inter', sans-serif;
720
+ font-size: 13px;
721
+ outline: none;
722
+ width: 180px;
723
+ }
724
+
725
+ /* --- Icon Button --- */
726
+ .icon-btn {
727
+ background: none;
728
+ border: none;
729
+ color: var(--mn-text-muted);
730
+ cursor: pointer;
731
+ padding: 4px;
732
+ display: inline-flex;
733
+ align-items: center;
734
+ justify-content: center;
735
+ border-radius: 4px;
736
+ transition: all 0.15s;
737
+ }
738
+
739
+ .icon-btn:hover {
740
+ color: var(--mn-text);
741
+ background: var(--mn-surface-alt);
742
+ }
743
+
744
+ .icon-btn.danger:hover {
745
+ color: var(--mn-error);
746
+ background: rgba(239, 68, 68, 0.1);
747
+ }
748
+
749
+ /* --- Copy Button --- */
750
+ .copy-btn {
751
+ background: none;
752
+ border: none;
753
+ color: var(--mn-text-muted);
754
+ cursor: pointer;
755
+ padding: 4px 6px;
756
+ display: inline-flex;
757
+ align-items: center;
758
+ gap: 4px;
759
+ border-radius: 4px;
760
+ font-family: 'Inter', sans-serif;
761
+ font-size: 11px;
762
+ transition: all 0.15s;
763
+ }
764
+
765
+ .copy-btn:hover {
766
+ color: var(--mn-text);
767
+ background: var(--mn-surface-alt);
768
+ }
769
+
770
+ .copy-btn.copied {
771
+ color: var(--mn-success);
772
+ }
773
+
774
+ /* --- Pulsing Sync Animation --- */
775
+ @keyframes syncPulse {
776
+ 0%, 100% { box-shadow: 0 0 0 0 var(--mn-accent-glow); }
777
+ 50% { box-shadow: 0 0 12px 4px var(--mn-accent-glow); }
778
+ }
779
+
780
+ .sync-indicator {
781
+ display: flex;
782
+ align-items: center;
783
+ gap: 8px;
784
+ font-size: 12px;
785
+ color: var(--mn-text-secondary);
786
+ }
787
+
788
+ .sync-spinner {
789
+ width: 14px;
790
+ height: 14px;
791
+ border: 2px solid var(--mn-border);
792
+ border-top-color: var(--mn-accent);
793
+ border-radius: 50%;
794
+ animation: spin 0.8s linear infinite;
795
+ }
796
+
797
+ .sync-glow {
798
+ animation: syncPulse 2s ease-in-out infinite;
799
+ border-radius: 12px;
800
+ }
801
+
802
+ /* --- Confirm Delete Inline Prompt --- */
803
+ .confirm-delete {
804
+ display: flex;
805
+ align-items: center;
806
+ gap: 8px;
807
+ padding: 6px 10px;
808
+ background: var(--mn-surface-alt);
809
+ border: 1px solid rgba(239, 68, 68, 0.3);
810
+ border-radius: 6px;
811
+ font-size: 12px;
812
+ color: var(--mn-text-secondary);
813
+ animation: fadeInUp 0.15s ease-out;
814
+ }
815
+
816
+ .confirm-delete .btn {
817
+ padding: 4px 10px;
818
+ font-size: 11px;
819
+ }
820
+
821
+ /* --- Wallet Card Header Row --- */
822
+ .wallet-header-row {
823
+ display: flex;
824
+ align-items: center;
825
+ gap: 8px;
826
+ margin-bottom: 16px;
827
+ }
828
+
829
+ .wallet-address-row {
830
+ display: flex;
831
+ align-items: center;
832
+ gap: 8px;
833
+ }
834
+
556
835
  /* --- Connection Overlay --- */
557
836
  .connection-overlay {
558
837
  position: fixed;
@@ -622,6 +901,98 @@ export function generateDashboardHtml({ wsUrl }) {
622
901
  .toast.error { border-left: 3px solid var(--mn-error); }
623
902
  .toast.fade-out { animation: fadeOut 0.3s ease-out forwards; }
624
903
 
904
+ /* --- Card Footer (server time) --- */
905
+ .card-footer {
906
+ color: var(--mn-text-muted);
907
+ font-size: 11px;
908
+ text-align: right;
909
+ margin-top: 12px;
910
+ }
911
+
912
+ /* --- Disabled Button --- */
913
+ .btn:disabled {
914
+ opacity: 0.5;
915
+ cursor: not-allowed;
916
+ pointer-events: none;
917
+ }
918
+
919
+ .btn-spinner {
920
+ display: inline-block;
921
+ width: 12px;
922
+ height: 12px;
923
+ border: 2px solid var(--mn-border);
924
+ border-top-color: currentColor;
925
+ border-radius: 50%;
926
+ animation: spin 0.8s linear infinite;
927
+ flex-shrink: 0;
928
+ }
929
+
930
+ /* --- Polling Settings Popover --- */
931
+ .settings-wrapper {
932
+ position: relative;
933
+ }
934
+
935
+ .settings-popover {
936
+ position: absolute;
937
+ top: calc(100% + 8px);
938
+ right: 0;
939
+ width: 280px;
940
+ background: var(--mn-surface);
941
+ border: 1px solid var(--mn-border-bright);
942
+ border-radius: 10px;
943
+ padding: 16px;
944
+ z-index: 50;
945
+ box-shadow: 0 8px 32px rgba(9, 9, 15, 0.6);
946
+ animation: fadeInUp 0.15s ease-out;
947
+ }
948
+
949
+ .settings-title {
950
+ font-size: 13px;
951
+ font-weight: 600;
952
+ color: var(--mn-text);
953
+ margin-bottom: 12px;
954
+ }
955
+
956
+ .settings-row {
957
+ display: flex;
958
+ align-items: center;
959
+ justify-content: space-between;
960
+ padding: 6px 0;
961
+ }
962
+
963
+ .settings-row:not(:last-child) {
964
+ border-bottom: 1px solid var(--mn-border);
965
+ }
966
+
967
+ .settings-label {
968
+ font-size: 12px;
969
+ color: var(--mn-text-secondary);
970
+ }
971
+
972
+ .settings-input {
973
+ width: 64px;
974
+ padding: 4px 8px;
975
+ border-radius: 4px;
976
+ border: 1px solid var(--mn-border);
977
+ background: var(--mn-surface-alt);
978
+ color: var(--mn-text);
979
+ font-family: 'JetBrains Mono', monospace;
980
+ font-size: 12px;
981
+ text-align: center;
982
+ outline: none;
983
+ transition: border-color 0.15s;
984
+ }
985
+
986
+ .settings-input:focus {
987
+ border-color: var(--mn-accent);
988
+ }
989
+
990
+ .settings-unit {
991
+ font-size: 11px;
992
+ color: var(--mn-text-muted);
993
+ margin-left: 4px;
994
+ }
995
+
625
996
  /* --- Responsive --- */
626
997
  @media (max-width: 768px) {
627
998
  #app { padding: 12px; }
@@ -638,7 +1009,7 @@ export function generateDashboardHtml({ wsUrl }) {
638
1009
 
639
1010
  <script type="module">
640
1011
  import { h, render } from 'preact';
641
- import { useState, useEffect, useRef, useCallback } from 'preact/hooks';
1012
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'preact/hooks';
642
1013
  import { html } from 'htm/preact';
643
1014
 
644
1015
  // --- Lucide icon SVGs (inline, stroke-based) ---
@@ -653,6 +1024,12 @@ export function generateDashboardHtml({ wsUrl }) {
653
1024
  activity: html\`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>\`,
654
1025
  terminal: html\`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>\`,
655
1026
  search: html\`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>\`,
1027
+ copy: html\`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>\`,
1028
+ pencil: html\`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>\`,
1029
+ trash: html\`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>\`,
1030
+ plus: html\`<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>\`,
1031
+ x: html\`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>\`,
1032
+ settings: html\`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>\`,
656
1033
  };
657
1034
 
658
1035
  // --- WebSocket URL (injected at generation time) ---
@@ -687,6 +1064,19 @@ export function generateDashboardHtml({ wsUrl }) {
687
1064
  return 'var(--mn-success)';
688
1065
  }
689
1066
 
1067
+ function formatTime(isoString) {
1068
+ if (!isoString) return '--:--:--';
1069
+ try {
1070
+ const d = new Date(isoString);
1071
+ if (isNaN(d.getTime())) return '--:--:--';
1072
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
1073
+ } catch (e) {
1074
+ return '--:--:--';
1075
+ }
1076
+ }
1077
+
1078
+ const POLLING_KEY = 'mn-polling-config';
1079
+
690
1080
  // --- Default state ---
691
1081
  const defaultState = {
692
1082
  node: { chain: null, name: null, version: null, blockHeight: null, avgBlockTime: null, peers: null, syncing: null },
@@ -701,6 +1091,8 @@ export function generateDashboardHtml({ wsUrl }) {
701
1091
  containers: [],
702
1092
  logs: [],
703
1093
  networkStatus: 'unknown',
1094
+ walletSyncStatus: 'idle',
1095
+ serverTime: '',
704
1096
  };
705
1097
 
706
1098
  // --- Toast component ---
@@ -731,9 +1123,102 @@ export function generateDashboardHtml({ wsUrl }) {
731
1123
  \`;
732
1124
  }
733
1125
 
1126
+ // --- PollingSettings ---
1127
+ function PollingSettings({ sendMessage }) {
1128
+ const [open, setOpen] = useState(false);
1129
+ const [config, setConfig] = useState(() => {
1130
+ try {
1131
+ const raw = localStorage.getItem(POLLING_KEY);
1132
+ if (raw) return JSON.parse(raw);
1133
+ } catch (e) { /* ignore */ }
1134
+ return { node: 5, indexer: 5, proofServer: 5 };
1135
+ });
1136
+
1137
+ // Send saved polling config on mount
1138
+ useEffect(() => {
1139
+ const saved = config;
1140
+ ['node', 'indexer', 'proofServer'].forEach(service => {
1141
+ if (saved[service] && saved[service] !== 5) {
1142
+ sendMessage({ type: 'command', action: 'set-polling', service, interval: saved[service] * 1000 });
1143
+ }
1144
+ });
1145
+ }, []);
1146
+
1147
+ const handleChange = useCallback((service, value) => {
1148
+ const num = Math.max(1, Math.min(60, parseInt(value, 10) || 5));
1149
+ setConfig(prev => {
1150
+ const updated = { ...prev, [service]: num };
1151
+ localStorage.setItem(POLLING_KEY, JSON.stringify(updated));
1152
+ return updated;
1153
+ });
1154
+ sendMessage({ type: 'command', action: 'set-polling', service, interval: num * 1000 });
1155
+ }, [sendMessage]);
1156
+
1157
+ return html\`
1158
+ <div class="settings-wrapper">
1159
+ <button class="btn" onClick=\${() => setOpen(!open)} title="Polling Settings">
1160
+ \${icons.settings}
1161
+ </button>
1162
+ \${open ? html\`
1163
+ <div class="settings-popover">
1164
+ <div class="settings-title">Polling Intervals</div>
1165
+ <div class="settings-row">
1166
+ <span class="settings-label">Node</span>
1167
+ <div>
1168
+ <input class="settings-input" type="number" min="1" max="60"
1169
+ value=\${config.node}
1170
+ onChange=\${e => handleChange('node', e.target.value)} />
1171
+ <span class="settings-unit">sec</span>
1172
+ </div>
1173
+ </div>
1174
+ <div class="settings-row">
1175
+ <span class="settings-label">Indexer</span>
1176
+ <div>
1177
+ <input class="settings-input" type="number" min="1" max="60"
1178
+ value=\${config.indexer}
1179
+ onChange=\${e => handleChange('indexer', e.target.value)} />
1180
+ <span class="settings-unit">sec</span>
1181
+ </div>
1182
+ </div>
1183
+ <div class="settings-row">
1184
+ <span class="settings-label">Proof Server</span>
1185
+ <div>
1186
+ <input class="settings-input" type="number" min="1" max="60"
1187
+ value=\${config.proofServer}
1188
+ onChange=\${e => handleChange('proofServer', e.target.value)} />
1189
+ <span class="settings-unit">sec</span>
1190
+ </div>
1191
+ </div>
1192
+ </div>
1193
+ \` : null}
1194
+ </div>
1195
+ \`;
1196
+ }
1197
+
734
1198
  // --- Header ---
735
- function Header({ networkStatus, onStart, onStop }) {
1199
+ function Header({ networkStatus, onStart, onStop, sendMessage }) {
736
1200
  const statusLabel = networkStatus.charAt(0).toUpperCase() + networkStatus.slice(1);
1201
+
1202
+ const renderButtons = () => {
1203
+ if (networkStatus === 'running') {
1204
+ return html\`<button class="btn btn-danger" onClick=\${onStop}>\${icons.square} Stop</button>\`;
1205
+ }
1206
+ if (networkStatus === 'stopped') {
1207
+ return html\`<button class="btn btn-primary" onClick=\${onStart}>\${icons.play} Start</button>\`;
1208
+ }
1209
+ if (networkStatus === 'starting') {
1210
+ return html\`<button class="btn btn-primary" disabled><span class="btn-spinner"></span> Starting...</button>\`;
1211
+ }
1212
+ if (networkStatus === 'stopping') {
1213
+ return html\`<button class="btn btn-danger" disabled><span class="btn-spinner"></span> Stopping...</button>\`;
1214
+ }
1215
+ // unknown — show both
1216
+ return html\`
1217
+ <button class="btn btn-primary" onClick=\${onStart}>\${icons.play} Start</button>
1218
+ <button class="btn btn-danger" onClick=\${onStop}>\${icons.square} Stop</button>
1219
+ \`;
1220
+ };
1221
+
737
1222
  return html\`
738
1223
  <div class="header fade-in">
739
1224
  <div class="header-left">
@@ -744,15 +1229,15 @@ export function generateDashboardHtml({ wsUrl }) {
744
1229
  </div>
745
1230
  </div>
746
1231
  <div class="header-actions">
747
- <button class="btn btn-primary" onClick=\${onStart}>\${icons.play} Start</button>
748
- <button class="btn btn-danger" onClick=\${onStop}>\${icons.square} Stop</button>
1232
+ \${renderButtons()}
1233
+ <\${PollingSettings} sendMessage=\${sendMessage} />
749
1234
  </div>
750
1235
  </div>
751
1236
  \`;
752
1237
  }
753
1238
 
754
1239
  // --- NodeCard ---
755
- function NodeCard({ node, health }) {
1240
+ function NodeCard({ node, health, serverTime }) {
756
1241
  const blockTimeStr = node.avgBlockTime != null ? (node.avgBlockTime / 1000).toFixed(1) + 's' : '--';
757
1242
  return html\`
758
1243
  <div class="card fade-in" style="animation-delay: 0ms">
@@ -779,12 +1264,13 @@ export function generateDashboardHtml({ wsUrl }) {
779
1264
  <span class="stat-row-value">\${node.version || '--'}</span>
780
1265
  </div>
781
1266
  </div>
1267
+ <div class="card-footer">\${formatTime(serverTime)}</div>
782
1268
  </div>
783
1269
  \`;
784
1270
  }
785
1271
 
786
1272
  // --- IndexerCard ---
787
- function IndexerCard({ indexer, health }) {
1273
+ function IndexerCard({ indexer, health, serverTime }) {
788
1274
  return html\`
789
1275
  <div class="card fade-in" style="animation-delay: 150ms">
790
1276
  <div class="card-header">
@@ -802,15 +1288,19 @@ export function generateDashboardHtml({ wsUrl }) {
802
1288
  <span class="stat-row-value">\${formatMs(indexer.responseTime)}</span>
803
1289
  </div>
804
1290
  </div>
1291
+ <div class="card-footer">\${formatTime(serverTime)}</div>
805
1292
  </div>
806
1293
  \`;
807
1294
  }
808
1295
 
809
1296
  // --- ProofServerCard ---
810
- function ProofServerCard({ proofServer, health }) {
1297
+ function ProofServerCard({ proofServer, health, serverTime }) {
811
1298
  const capacity = proofServer.jobCapacity || 1;
812
1299
  const processing = proofServer.jobsProcessing || 0;
813
1300
  const pct = Math.min(100, (processing / capacity) * 100);
1301
+ const proofVersionsDisplay = proofServer.proofVersions && proofServer.proofVersions.length > 0
1302
+ ? proofServer.proofVersions.join(', ')
1303
+ : 'None';
814
1304
  return html\`
815
1305
  <div class="card fade-in" style="animation-delay: 300ms">
816
1306
  <div class="card-header">
@@ -833,41 +1323,314 @@ export function generateDashboardHtml({ wsUrl }) {
833
1323
  </div>
834
1324
  <div style="margin-top: 10px">
835
1325
  <div class="stat-row">
836
- <span class="stat-row-label">Version</span>
1326
+ <span class="stat-row-label">Server Version</span>
837
1327
  <span class="stat-row-value">\${proofServer.version || '--'}</span>
838
1328
  </div>
1329
+ <div class="stat-row">
1330
+ <span class="stat-row-label">Proof Versions</span>
1331
+ <span class="stat-row-value">\${proofVersionsDisplay}</span>
1332
+ </div>
1333
+ </div>
1334
+ <div class="card-footer">\${formatTime(serverTime)}</div>
1335
+ </div>
1336
+ \`;
1337
+ }
1338
+
1339
+ // --- localStorage helpers for wallet list ---
1340
+ const WALLETS_KEY = 'mn-wallets';
1341
+
1342
+ function loadWallets() {
1343
+ try {
1344
+ const raw = localStorage.getItem(WALLETS_KEY);
1345
+ if (raw) return JSON.parse(raw);
1346
+ } catch (e) { /* ignore */ }
1347
+ return [];
1348
+ }
1349
+
1350
+ function saveWallets(wallets) {
1351
+ localStorage.setItem(WALLETS_KEY, JSON.stringify(wallets));
1352
+ }
1353
+
1354
+ // --- ImportWalletModal ---
1355
+ function ImportWalletModal({ onClose, onImport, sendMessage, deriveResult }) {
1356
+ const [tab, setTab] = useState('mnemonic');
1357
+ const [mnemonic, setMnemonic] = useState('');
1358
+ const [address, setAddress] = useState('');
1359
+ const [displayName, setDisplayName] = useState('');
1360
+ const [deriving, setDeriving] = useState(false);
1361
+ const [derivedAddress, setDerivedAddress] = useState(null);
1362
+
1363
+ // Watch for derive-result coming in
1364
+ useEffect(() => {
1365
+ if (deriveResult && deriving) {
1366
+ setDerivedAddress(deriveResult);
1367
+ setDeriving(false);
1368
+ }
1369
+ }, [deriveResult, deriving]);
1370
+
1371
+ const handleDerive = useCallback(() => {
1372
+ if (!mnemonic.trim()) return;
1373
+ setDeriving(true);
1374
+ setDerivedAddress(null);
1375
+ sendMessage({ type: 'command', action: 'derive-address', mnemonic: mnemonic.trim() });
1376
+ }, [mnemonic, sendMessage]);
1377
+
1378
+ const handleImport = useCallback(() => {
1379
+ if (tab === 'mnemonic') {
1380
+ if (!derivedAddress) return;
1381
+ onImport({
1382
+ id: crypto.randomUUID(),
1383
+ publicKey: derivedAddress,
1384
+ displayName: displayName.trim() || 'Imported Wallet',
1385
+ hasMnemonic: true,
1386
+ });
1387
+ } else {
1388
+ if (!address.trim()) return;
1389
+ onImport({
1390
+ id: crypto.randomUUID(),
1391
+ publicKey: address.trim(),
1392
+ displayName: displayName.trim() || 'Imported Wallet',
1393
+ });
1394
+ }
1395
+ onClose();
1396
+ }, [tab, derivedAddress, address, displayName, onImport, onClose]);
1397
+
1398
+ return html\`
1399
+ <div class="modal-overlay" onClick=\${(e) => { if (e.target === e.currentTarget) onClose(); }}>
1400
+ <div class="modal-dialog">
1401
+ <div class="modal-header">
1402
+ <span class="modal-title">Import Wallet</span>
1403
+ <button class="modal-close" onClick=\${onClose}>\${icons.x}</button>
1404
+ </div>
1405
+ <div class="modal-tabs">
1406
+ <button class="modal-tab \${tab === 'mnemonic' ? 'active' : ''}" onClick=\${() => { setTab('mnemonic'); setDerivedAddress(null); }}>Mnemonic</button>
1407
+ <button class="modal-tab \${tab === 'address' ? 'active' : ''}" onClick=\${() => setTab('address')}>Address</button>
1408
+ </div>
1409
+ \${tab === 'mnemonic' ? html\`
1410
+ <div class="modal-field">
1411
+ <label class="modal-label">Mnemonic Phrase</label>
1412
+ <input class="modal-input mono" type="text" placeholder="Enter mnemonic words..."
1413
+ value=\${mnemonic} onInput=\${e => setMnemonic(e.target.value)} />
1414
+ </div>
1415
+ <div style="margin-bottom: 14px">
1416
+ <button class="btn" onClick=\${handleDerive} disabled=\${deriving || !mnemonic.trim()}>
1417
+ \${deriving ? 'Deriving...' : 'Derive Address'}
1418
+ </button>
1419
+ \${derivedAddress ? html\`
1420
+ <div style="margin-top: 8px; font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--mn-success); word-break: break-all;">
1421
+ Derived: \${truncateAddress(derivedAddress)}
1422
+ </div>
1423
+ \` : null}
1424
+ </div>
1425
+ \` : html\`
1426
+ <div class="modal-field">
1427
+ <label class="modal-label">Public Key / Address</label>
1428
+ <input class="modal-input mono" type="text" placeholder="Paste public key..."
1429
+ value=\${address} onInput=\${e => setAddress(e.target.value)} />
1430
+ </div>
1431
+ \`}
1432
+ <div class="modal-field">
1433
+ <label class="modal-label">Display Name (optional)</label>
1434
+ <input class="modal-input" type="text" placeholder="e.g. My Wallet"
1435
+ value=\${displayName} onInput=\${e => setDisplayName(e.target.value)} />
1436
+ </div>
1437
+ <div class="modal-actions">
1438
+ <button class="btn" onClick=\${onClose}>Cancel</button>
1439
+ <button class="btn btn-primary" onClick=\${handleImport}
1440
+ disabled=\${tab === 'mnemonic' ? !derivedAddress : !address.trim()}>
1441
+ Import
1442
+ </button>
1443
+ </div>
839
1444
  </div>
840
1445
  </div>
841
1446
  \`;
842
1447
  }
843
1448
 
844
1449
  // --- WalletCard ---
845
- function WalletCard({ wallet }) {
1450
+ function WalletCard({ wallet, walletSyncStatus, sendMessage, onOpenImportModal, importHandlerRef }) {
1451
+ // Load wallets from localStorage, ensuring master wallet is always first
1452
+ const [wallets, setWallets] = useState(() => {
1453
+ const stored = loadWallets();
1454
+ const hasMaster = stored.some(w => w.id === 'master');
1455
+ if (!hasMaster) {
1456
+ const masterEntry = { id: 'master', publicKey: wallet.address || '', displayName: 'Master Wallet' };
1457
+ const all = [masterEntry, ...stored];
1458
+ saveWallets(all);
1459
+ return all;
1460
+ }
1461
+ return stored;
1462
+ });
1463
+
1464
+ const [selectedId, setSelectedId] = useState('master');
1465
+ const [editingId, setEditingId] = useState(null);
1466
+ const [editName, setEditName] = useState('');
1467
+ const [deletingId, setDeletingId] = useState(null);
1468
+ const [copied, setCopied] = useState(false);
1469
+
1470
+ // Keep master wallet address in sync with server state
1471
+ useEffect(() => {
1472
+ if (wallet.address) {
1473
+ setWallets(prev => {
1474
+ const updated = prev.map(w => w.id === 'master' ? { ...w, publicKey: wallet.address } : w);
1475
+ saveWallets(updated);
1476
+ return updated;
1477
+ });
1478
+ }
1479
+ }, [wallet.address]);
1480
+
1481
+ const selectedWallet = useMemo(() => {
1482
+ return wallets.find(w => w.id === selectedId) || wallets[0] || null;
1483
+ }, [wallets, selectedId]);
1484
+
1485
+ const isMaster = selectedWallet && selectedWallet.id === 'master';
1486
+
1487
+ const handleImportWallet = useCallback((newWallet) => {
1488
+ setWallets(prev => {
1489
+ const updated = [...prev, newWallet];
1490
+ saveWallets(updated);
1491
+ return updated;
1492
+ });
1493
+ setSelectedId(newWallet.id);
1494
+ }, []);
1495
+
1496
+ // Expose import handler to parent via ref
1497
+ useEffect(() => {
1498
+ if (importHandlerRef) importHandlerRef.current = handleImportWallet;
1499
+ }, [importHandlerRef, handleImportWallet]);
1500
+
1501
+ const handleStartEdit = useCallback((w) => {
1502
+ setEditingId(w.id);
1503
+ setEditName(w.displayName);
1504
+ }, []);
1505
+
1506
+ const handleSaveEdit = useCallback(() => {
1507
+ if (!editingId) return;
1508
+ setWallets(prev => {
1509
+ const updated = prev.map(w => w.id === editingId ? { ...w, displayName: editName.trim() || w.displayName } : w);
1510
+ saveWallets(updated);
1511
+ return updated;
1512
+ });
1513
+ setEditingId(null);
1514
+ }, [editingId, editName]);
1515
+
1516
+ const handleDelete = useCallback((id) => {
1517
+ setWallets(prev => {
1518
+ const updated = prev.filter(w => w.id !== id);
1519
+ saveWallets(updated);
1520
+ return updated;
1521
+ });
1522
+ setDeletingId(null);
1523
+ if (selectedId === id) setSelectedId('master');
1524
+ }, [selectedId]);
1525
+
1526
+ const handleCopy = useCallback((text) => {
1527
+ navigator.clipboard.writeText(text).then(() => {
1528
+ setCopied(true);
1529
+ setTimeout(() => setCopied(false), 1500);
1530
+ }).catch(() => {});
1531
+ }, []);
1532
+
1533
+ const showBalances = isMaster && walletSyncStatus !== 'syncing';
1534
+ const displayAddress = selectedWallet ? selectedWallet.publicKey : null;
1535
+
846
1536
  return html\`
847
- <div class="card full-width fade-in" style="animation-delay: 450ms">
1537
+ <div class="card full-width fade-in \${walletSyncStatus === 'syncing' ? 'sync-glow' : ''}" style="animation-delay: 450ms">
848
1538
  <div class="card-header">
849
1539
  <div class="card-title">
850
1540
  \${icons.wallet}
851
1541
  Wallet
1542
+ \${walletSyncStatus === 'syncing' ? html\`
1543
+ <span class="sync-indicator">
1544
+ <span class="sync-spinner"></span>
1545
+ Syncing...
1546
+ </span>
1547
+ \` : null}
852
1548
  </div>
853
1549
  <span class="card-health-dot \${wallet.connected ? 'healthy' : 'unhealthy'}"></span>
854
1550
  </div>
855
- <div class="wallet-grid">
856
- <div class="wallet-address" title=\${wallet.address || ''}>\${wallet.address ? truncateAddress(wallet.address) : 'No wallet connected'}</div>
857
- <div class="balance-item">
858
- <div class="balance-value">\${formatBalance(wallet.unshielded)}</div>
859
- <div class="balance-label">NIGHT (Unshielded)</div>
1551
+
1552
+ <div class="wallet-header-row">
1553
+ <div class="wallet-selector">
1554
+ \${editingId === (selectedWallet && selectedWallet.id) ? html\`
1555
+ <input class="inline-edit-input" type="text" value=\${editName}
1556
+ onInput=\${e => setEditName(e.target.value)}
1557
+ onBlur=\${handleSaveEdit}
1558
+ onKeyDown=\${e => { if (e.key === 'Enter') handleSaveEdit(); if (e.key === 'Escape') setEditingId(null); }}
1559
+ autofocus />
1560
+ \` : html\`
1561
+ <select class="wallet-select" value=\${selectedId}
1562
+ onChange=\${e => { setSelectedId(e.target.value); setDeletingId(null); }}>
1563
+ \${wallets.map(w => html\`
1564
+ <option key=\${w.id} value=\${w.id}>\${w.displayName}</option>
1565
+ \`)}
1566
+ </select>
1567
+ \`}
1568
+ <button class="icon-btn" title="Rename wallet" onClick=\${() => selectedWallet && handleStartEdit(selectedWallet)}>
1569
+ \${icons.pencil}
1570
+ </button>
1571
+ \${selectedWallet && selectedWallet.id !== 'master' ? html\`
1572
+ <button class="icon-btn danger" title="Delete wallet" onClick=\${() => setDeletingId(selectedWallet.id)}>
1573
+ \${icons.trash}
1574
+ </button>
1575
+ \` : null}
860
1576
  </div>
861
- <div class="balance-item">
862
- <div class="balance-value">\${formatBalance(wallet.shielded)}</div>
863
- <div class="balance-label">NIGHT (Shielded)</div>
1577
+ <button class="btn" onClick=\${onOpenImportModal}>
1578
+ \${icons.plus} Import
1579
+ </button>
1580
+ </div>
1581
+
1582
+ \${deletingId ? html\`
1583
+ <div class="confirm-delete">
1584
+ <span>Delete this wallet?</span>
1585
+ <button class="btn btn-danger" onClick=\${() => handleDelete(deletingId)}>Yes, delete</button>
1586
+ <button class="btn" onClick=\${() => setDeletingId(null)}>Cancel</button>
864
1587
  </div>
865
- <div class="balance-item">
866
- <div class="balance-value">\${formatBalance(wallet.dust)}</div>
867
- <div class="balance-label">DUST</div>
1588
+ \` : null}
1589
+
1590
+ <div class="wallet-grid">
1591
+ <div class="wallet-address">
1592
+ <div class="wallet-address-row">
1593
+ <span class="mono" title=\${displayAddress || ''} style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;">
1594
+ \${displayAddress ? truncateAddress(displayAddress) : 'No wallet connected'}
1595
+ </span>
1596
+ \${displayAddress ? html\`
1597
+ <button class="copy-btn \${copied ? 'copied' : ''}" onClick=\${() => handleCopy(displayAddress)}>
1598
+ \${icons.copy}
1599
+ \${copied ? 'Copied!' : 'Copy'}
1600
+ </button>
1601
+ \` : null}
1602
+ </div>
868
1603
  </div>
1604
+ \${showBalances ? html\`
1605
+ <div class="balance-item">
1606
+ <div class="balance-value">\${formatBalance(wallet.unshielded)}</div>
1607
+ <div class="balance-label">NIGHT (Unshielded)</div>
1608
+ </div>
1609
+ <div class="balance-item">
1610
+ <div class="balance-value">\${formatBalance(wallet.shielded)}</div>
1611
+ <div class="balance-label">NIGHT (Shielded)</div>
1612
+ </div>
1613
+ <div class="balance-item">
1614
+ <div class="balance-value">\${formatBalance(wallet.dust)}</div>
1615
+ <div class="balance-label">DUST</div>
1616
+ </div>
1617
+ \` : isMaster && walletSyncStatus === 'syncing' ? html\`
1618
+ <div class="balance-item" style="grid-column: 1 / -1; text-align: center;">
1619
+ <div class="sync-indicator" style="justify-content: center;">
1620
+ <span class="sync-spinner"></span>
1621
+ Syncing balances...
1622
+ </div>
1623
+ </div>
1624
+ \` : !isMaster ? html\`
1625
+ <div class="balance-item" style="grid-column: 1 / -1; text-align: center;">
1626
+ <div style="font-size: 13px; color: var(--mn-text-muted); padding: 8px 0;">
1627
+ \${selectedWallet && selectedWallet.hasMnemonic ? 'Derived address — balances not available' : 'Address only — balances not available'}
1628
+ </div>
1629
+ </div>
1630
+ \` : null}
869
1631
  </div>
870
1632
  </div>
1633
+
871
1634
  \`;
872
1635
  }
873
1636
 
@@ -1017,6 +1780,16 @@ export function generateDashboardHtml({ wsUrl }) {
1017
1780
  }
1018
1781
  }, []);
1019
1782
 
1783
+ const sendMessage = useCallback((msg) => {
1784
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
1785
+ wsRef.current.send(JSON.stringify(msg));
1786
+ }
1787
+ }, []);
1788
+
1789
+ const [deriveResult, setDeriveResult] = useState(null);
1790
+ const [showImportModal, setShowImportModal] = useState(false);
1791
+ const importHandlerRef = useRef(null);
1792
+
1020
1793
  useEffect(() => {
1021
1794
  function connect() {
1022
1795
  const ws = new WebSocket(WS_URL);
@@ -1025,6 +1798,8 @@ export function generateDashboardHtml({ wsUrl }) {
1025
1798
  ws.addEventListener('open', () => {
1026
1799
  setConnected(true);
1027
1800
  backoff.current = 1000;
1801
+ // Auto-sync wallet on first connect
1802
+ ws.send(JSON.stringify({ type: 'command', action: 'sync-wallet' }));
1028
1803
  });
1029
1804
 
1030
1805
  ws.addEventListener('message', (event) => {
@@ -1034,6 +1809,8 @@ export function generateDashboardHtml({ wsUrl }) {
1034
1809
  setState(msg.data);
1035
1810
  } else if (msg.type === 'result') {
1036
1811
  addToast(msg.message || (msg.success ? 'Command succeeded' : 'Command failed'), msg.success ? 'success' : 'error');
1812
+ } else if (msg.type === 'derive-result') {
1813
+ setDeriveResult(msg.address || null);
1037
1814
  }
1038
1815
  } catch (e) {
1039
1816
  // ignore malformed messages
@@ -1069,19 +1846,27 @@ export function generateDashboardHtml({ wsUrl }) {
1069
1846
  return html\`
1070
1847
  <\${ConnectionStatus} connected=\${connected} />
1071
1848
  <\${ToastContainer} toasts=\${toasts} onRemove=\${removeToast} />
1072
- <\${Header} networkStatus=\${state.networkStatus} onStart=\${() => sendCommand('start')} onStop=\${() => sendCommand('stop')} />
1849
+ <\${Header} networkStatus=\${state.networkStatus} onStart=\${() => sendCommand('start')} onStop=\${() => sendCommand('stop')} sendMessage=\${sendMessage} />
1073
1850
  <div class="cards-grid">
1074
- <\${NodeCard} node=\${state.node} health=\${state.health.node} />
1075
- <\${IndexerCard} indexer=\${state.indexer} health=\${state.health.indexer} />
1076
- <\${ProofServerCard} proofServer=\${state.proofServer} health=\${state.health.proofServer} />
1851
+ <\${NodeCard} node=\${state.node} health=\${state.health.node} serverTime=\${state.serverTime} />
1852
+ <\${IndexerCard} indexer=\${state.indexer} health=\${state.health.indexer} serverTime=\${state.serverTime} />
1853
+ <\${ProofServerCard} proofServer=\${state.proofServer} health=\${state.health.proofServer} serverTime=\${state.serverTime} />
1077
1854
  </div>
1078
1855
  <div class="cards-grid">
1079
- <\${WalletCard} wallet=\${state.wallet} />
1856
+ <\${WalletCard} wallet=\${state.wallet} walletSyncStatus=\${state.walletSyncStatus} sendMessage=\${sendMessage} onOpenImportModal=\${() => { setDeriveResult(null); setShowImportModal(true); }} importHandlerRef=\${importHandlerRef} />
1080
1857
  </div>
1081
1858
  <div class="cards-grid">
1082
1859
  <\${ResponseChart} health=\${state.health} />
1083
1860
  </div>
1084
1861
  <\${LogViewer} logs=\${state.logs} />
1862
+ \${showImportModal ? html\`
1863
+ <\${ImportWalletModal}
1864
+ onClose=\${() => setShowImportModal(false)}
1865
+ onImport=\${(w) => importHandlerRef.current && importHandlerRef.current(w)}
1866
+ sendMessage=\${sendMessage}
1867
+ deriveResult=\${deriveResult}
1868
+ />
1869
+ \` : null}
1085
1870
  \`;
1086
1871
  }
1087
1872