@metricinsights/pp-dev 1.4.7 → 1.5.0-beta.10

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.
Files changed (51) hide show
  1. package/README.md +77 -6
  2. package/dist/README.md +77 -6
  3. package/dist/cjs/cli.js +1 -1
  4. package/dist/cjs/cli.js.map +1 -1
  5. package/dist/cjs/index-BcARY73n.js +2 -0
  6. package/dist/cjs/index-BcARY73n.js.map +1 -0
  7. package/dist/cjs/index.js +1 -1
  8. package/dist/cjs/migrate-CTAwfgnJ.js +2 -0
  9. package/dist/cjs/{migrate-G-xh-wpc.js.map → migrate-CTAwfgnJ.js.map} +1 -1
  10. package/dist/cjs/package.json +8 -8
  11. package/dist/cjs/plugin-Cx8D7mM_.js +106 -0
  12. package/dist/cjs/plugin-Cx8D7mM_.js.map +1 -0
  13. package/dist/cjs/plugin.js +1 -1
  14. package/dist/cjs/version-plugin-C_tAfa8C.js +2 -0
  15. package/dist/cjs/version-plugin-C_tAfa8C.js.map +1 -0
  16. package/dist/client/client.css +1 -1
  17. package/dist/client/client.css.map +1 -1
  18. package/dist/client/client.js +555 -74
  19. package/dist/client/client.js.map +1 -1
  20. package/dist/client/index.html +24 -2
  21. package/dist/esm/cli.js +1 -1
  22. package/dist/esm/cli.js.map +1 -1
  23. package/dist/esm/index-DYif-i3c.js +2 -0
  24. package/dist/esm/index-DYif-i3c.js.map +1 -0
  25. package/dist/esm/index.js +1 -1
  26. package/dist/esm/migrate-3G0bzseG.js +2 -0
  27. package/dist/esm/{migrate-BBCvcE_8.js.map → migrate-3G0bzseG.js.map} +1 -1
  28. package/dist/esm/package.json +8 -8
  29. package/dist/esm/plugin-GOAsE6Xr.js +106 -0
  30. package/dist/esm/plugin-GOAsE6Xr.js.map +1 -0
  31. package/dist/esm/plugin.js +1 -1
  32. package/dist/esm/version-plugin-RuvytTeG.js +2 -0
  33. package/dist/esm/{version-plugin-BqEwLs0b.js.map → version-plugin-RuvytTeG.js.map} +1 -1
  34. package/dist/types/index.d.ts +18 -0
  35. package/docs/features.md +25 -2
  36. package/docs/screenshots/13-users-popover.png +0 -0
  37. package/docs/screenshots/14-users-user-tab.png +0 -0
  38. package/package.json +17 -17
  39. package/dist/cjs/index-DlBoHBGI.js +0 -2
  40. package/dist/cjs/index-DlBoHBGI.js.map +0 -1
  41. package/dist/cjs/migrate-G-xh-wpc.js +0 -2
  42. package/dist/cjs/plugin-Bfd0zHTR.js +0 -2
  43. package/dist/cjs/plugin-Bfd0zHTR.js.map +0 -1
  44. package/dist/cjs/version-plugin-DmJ0luHT.js +0 -2
  45. package/dist/cjs/version-plugin-DmJ0luHT.js.map +0 -1
  46. package/dist/esm/index-Dv-Y3cH6.js +0 -2
  47. package/dist/esm/index-Dv-Y3cH6.js.map +0 -1
  48. package/dist/esm/migrate-BBCvcE_8.js +0 -2
  49. package/dist/esm/plugin-B0dsWaBT.js +0 -2
  50. package/dist/esm/plugin-B0dsWaBT.js.map +0 -1
  51. package/dist/esm/version-plugin-BqEwLs0b.js +0 -2
@@ -58,8 +58,11 @@ function createPPDevHotContext() {
58
58
  scheduleReconnect();
59
59
  return;
60
60
  }
61
+ let opened = false;
61
62
  socket.addEventListener('open', () => {
63
+ opened = true;
62
64
  reconnectDelay = 1_000;
65
+ dispatch('vite:ws:connect', {});
63
66
  flushOutbox();
64
67
  });
65
68
  socket.addEventListener('message', (ev) => {
@@ -76,6 +79,12 @@ function createPPDevHotContext() {
76
79
  });
77
80
  socket.addEventListener('close', () => {
78
81
  socket = null;
82
+ // Same events as Vite's own HMR client, so the panel can tell a lost connection apart
83
+ // (e.g. a sync whose response will never arrive). Only for a socket that was open: a
84
+ // failed reconnect attempt is not a new disconnect.
85
+ if (opened) {
86
+ dispatch('vite:ws:disconnect', {});
87
+ }
79
88
  scheduleReconnect();
80
89
  });
81
90
  socket.addEventListener('error', () => {
@@ -644,6 +653,441 @@ function initPanelSettings($panel, controller, hooks) {
644
653
  });
645
654
  }
646
655
 
656
+ /**
657
+ * The dev panel's "Users" popover (PP-4597): the user of this tab, the saved users (open as, log
658
+ * in, make default, remove) and adding a user by API token or by username + password. Each user
659
+ * works on its own host, `<name>.localhost:<port>`; see src/lib/user-registry.ts.
660
+ */
661
+ const USERS_API = '/@api/users';
662
+ const REFRESH_INTERVAL = 3000;
663
+ const USER_HOST_REGEXP = /^([a-z](?:[a-z0-9-]*[a-z0-9])?)\.localhost$/;
664
+ /** Marks the user this tab works as, apart from the radio, which picks localhost's user. */
665
+ const THIS_TAB_BADGE = '<span class="pp-dev-info__users-badge">this tab</span>';
666
+ const STATE_LABELS = {
667
+ pending: 'not logged in yet',
668
+ active: 'logged in',
669
+ expired: 'session expired',
670
+ };
671
+ /** The saved user this tab belongs to, from its host; `undefined` on the main host. */
672
+ function getTabUserName(hostname = window.location.hostname) {
673
+ return USER_HOST_REGEXP.exec(hostname)?.[1];
674
+ }
675
+ /** Users need `*.localhost` hosts, which only exist when the dev server is opened on localhost. */
676
+ function isOnLocalhost(hostname = window.location.hostname) {
677
+ return hostname === 'localhost' || hostname.endsWith('.localhost');
678
+ }
679
+ function mainOrigin() {
680
+ const { protocol, port } = window.location;
681
+ return `${protocol}//localhost${port ? `:${port}` : ''}`;
682
+ }
683
+ /** The current page on another origin, so "open as" keeps the page you are on. */
684
+ function samePageOn(origin) {
685
+ const { pathname, search, hash } = window.location;
686
+ return `${origin}${pathname}${search}${hash}`;
687
+ }
688
+ function escapeHtml(value) {
689
+ return value.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
690
+ }
691
+ function describeUser(user) {
692
+ const who = user.miUser?.username || (user.miUser ? `MI user ${user.miUser.id}` : '');
693
+ const how = user.kind === 'token'
694
+ ? user.state === 'expired'
695
+ ? 'API token turned down by MI'
696
+ : 'API token'
697
+ : STATE_LABELS[user.state ?? 'pending'];
698
+ return [who, how].filter(Boolean).join(' · ');
699
+ }
700
+ async function readError(response) {
701
+ try {
702
+ const body = await response.json();
703
+ return body?.error || `Request failed (${response.status})`;
704
+ }
705
+ catch {
706
+ return `Request failed (${response.status})`;
707
+ }
708
+ }
709
+ function initUsersPanel($panel, hooks) {
710
+ const $btn = $panel.querySelector('.pp-dev-info__users-btn');
711
+ if (!$btn) {
712
+ return;
713
+ }
714
+ const tabUser = getTabUserName();
715
+ // On a saved user's host the button stands out (the name is in its tooltip and the popover, so a
716
+ // long name doesn't widen the panel), so the tab is never mistaken for your own.
717
+ if (tabUser) {
718
+ $panel.classList.add('pp-dev-info--user-tab');
719
+ $btn.title = `This tab works as ${tabUser}. Users…`;
720
+ $btn.setAttribute('aria-label', `Users (this tab works as ${tabUser})`);
721
+ }
722
+ let $popover = null;
723
+ let refreshTimer;
724
+ let addKind = 'token';
725
+ let data = null;
726
+ // What the list shows now, so a refresh with the same data leaves it (and the focus) alone.
727
+ let renderedData = '';
728
+ // The token user being edited, whose row shows the edit form instead.
729
+ let editing = null;
730
+ const onDocClick = (ev) => {
731
+ const target = ev.target;
732
+ if ($popover && !$popover.contains(target) && !$btn.contains(target)) {
733
+ close();
734
+ }
735
+ };
736
+ const onKeyDown = (ev) => {
737
+ if (ev.key === 'Escape') {
738
+ close();
739
+ }
740
+ };
741
+ const close = () => {
742
+ if (!$popover) {
743
+ return;
744
+ }
745
+ editing = null;
746
+ window.clearInterval(refreshTimer);
747
+ $popover.remove();
748
+ $popover = null;
749
+ document.removeEventListener('click', onDocClick, true);
750
+ document.removeEventListener('keydown', onKeyDown, true);
751
+ hooks?.onOpenChange?.(false);
752
+ };
753
+ const setError = (message) => {
754
+ const $error = $popover?.querySelector('.pp-dev-info__users-error');
755
+ if ($error) {
756
+ $error.textContent = message;
757
+ $error.hidden = !message;
758
+ }
759
+ };
760
+ const request = async (method, path, body) => {
761
+ try {
762
+ const response = await fetch(path, {
763
+ method,
764
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
765
+ body: body === undefined ? undefined : JSON.stringify(body),
766
+ });
767
+ if (!response.ok) {
768
+ setError(await readError(response));
769
+ return null;
770
+ }
771
+ setError('');
772
+ return response;
773
+ }
774
+ catch {
775
+ setError('pp-dev is not reachable');
776
+ return null;
777
+ }
778
+ };
779
+ /**
780
+ * Who this tab works as: on a user's host that user, on localhost the default user, and on an IP
781
+ * address or a custom domain you (`undefined`). Before the users are loaded, a user's host only.
782
+ */
783
+ const getCurrentUser = () => tabUser ?? (window.location.hostname === 'localhost' ? data?.defaultUser : null) ?? undefined;
784
+ const renderList = () => {
785
+ const $list = $popover?.querySelector('.pp-dev-info__users-list');
786
+ if (!$popover || !$list || !data) {
787
+ return;
788
+ }
789
+ // The user being edited is gone (removed, logged out): nothing to edit any more.
790
+ if (editing && !data.users.some((user) => user.name === editing && user.kind === 'token')) {
791
+ editing = null;
792
+ }
793
+ else if (editing && $list.querySelector('.pp-dev-info__users-edit')?.dataset.user === editing) {
794
+ // A refresh must not wipe what is being typed into the edit form. (Edit on another user
795
+ // redraws it for that one.)
796
+ return;
797
+ }
798
+ const snapshot = JSON.stringify(data);
799
+ if (snapshot === renderedData) {
800
+ return;
801
+ }
802
+ renderedData = snapshot;
803
+ // The control that has the keyboard focus, to focus its counterpart in the new list.
804
+ const focusKey = document.activeElement instanceof HTMLElement && $list.contains(document.activeElement)
805
+ ? document.activeElement.dataset.focusKey
806
+ : undefined;
807
+ const defaultUser = data.defaultUser;
808
+ const currentUser = getCurrentUser();
809
+ const $tab = $popover.querySelector('.pp-dev-info__users-tab strong');
810
+ if ($tab) {
811
+ $tab.textContent = currentUser ?? 'you';
812
+ }
813
+ const rows = [
814
+ `<div class="pp-dev-info__users-row${currentUser ? '' : ' is-current'}">
815
+ <input type="radio" name="pp-dev-default-user" class="pp-dev-info__users-default" value="" data-focus-key=":default"
816
+ title="Make the main host (localhost) work as you" ${defaultUser ? '' : 'checked'} />
817
+ <div class="pp-dev-info__users-who">
818
+ <span class="pp-dev-info__users-name">You${currentUser ? '' : THIS_TAB_BADGE}</span>
819
+ <span class="pp-dev-info__users-meta">original user · the dev panel always works as you</span>
820
+ </div>
821
+ ${
822
+ // The main host works as you only while no other user is the default; then that user's
823
+ // row is the one to open.
824
+ tabUser && !defaultUser
825
+ ? `<button type="button" class="pp-dev-info__users-action" data-open="${escapeHtml(mainOrigin())}" data-focus-key=":open" title="Open this page on the main host">Open</button>`
826
+ : ''}
827
+ </div>`,
828
+ ...data.users.map((user) => {
829
+ const canBeDefault = user.kind === 'token' ? user.state !== 'expired' : user.state === 'active';
830
+ const needsLogin = user.kind === 'session' && user.state !== 'active';
831
+ const needsAttention = needsLogin || user.state === 'expired';
832
+ const name = escapeHtml(user.name);
833
+ if (user.kind === 'token' && user.name === editing) {
834
+ return `<form class="pp-dev-info__users-row pp-dev-info__users-edit" data-user="${name}">
835
+ <input class="pp-dev-info__users-input pp-dev-info__users-edit-name" value="${name}" maxlength="32"
836
+ autocomplete="off" spellcheck="false" aria-label="Name" data-focus-key="${name}:edit-name"
837
+ title="Lowercase latin letters, digits and -, starting with a letter. The user opens on <name>.localhost." />
838
+ <input class="pp-dev-info__users-input pp-dev-info__users-edit-token" type="password" autocomplete="off"
839
+ placeholder="New API token (empty keeps the current one)" aria-label="New API token" data-focus-key="${name}:edit-token" />
840
+ <div class="pp-dev-info__users-edit-actions">
841
+ <button type="submit" class="pp-dev-info__users-action pp-dev-info__users-action--accent" data-focus-key="${name}:save">Save</button>
842
+ <button type="button" class="pp-dev-info__users-action" data-cancel-edit data-focus-key="${name}:cancel">Cancel</button>
843
+ </div>
844
+ </form>`;
845
+ }
846
+ let defaultTitle = `Make the main host (localhost) work as ${name}`;
847
+ if (!canBeDefault) {
848
+ defaultTitle =
849
+ user.kind === 'token' ? `MI turned down the API token of ${name}` : `${name} has to log in first`;
850
+ }
851
+ return `<div class="pp-dev-info__users-row${user.name === currentUser ? ' is-current' : ''}${needsAttention ? ' needs-login' : ''}" data-user="${name}">
852
+ <input type="radio" name="pp-dev-default-user" class="pp-dev-info__users-default" value="${name}" data-focus-key="${name}:default"
853
+ title="${defaultTitle}"
854
+ ${user.name === defaultUser ? 'checked' : ''} ${canBeDefault ? '' : 'disabled'} />
855
+ <div class="pp-dev-info__users-who">
856
+ <span class="pp-dev-info__users-name">${name}${user.name === currentUser ? THIS_TAB_BADGE : ''}</span>
857
+ <span class="pp-dev-info__users-meta">${escapeHtml(describeUser(user))}</span>
858
+ </div>
859
+ ${needsLogin && user.loginUrl
860
+ ? `<button type="button" class="pp-dev-info__users-action pp-dev-info__users-action--accent" data-open="${escapeHtml(user.loginUrl)}" data-raw="true" data-focus-key="${name}:login">Log in</button>`
861
+ : ''}
862
+ ${user.kind === 'token'
863
+ ? `<button type="button" class="pp-dev-info__users-action" data-edit="${name}" data-focus-key="${name}:edit" title="Rename ${name} or replace its API token">Edit</button>`
864
+ : ''}
865
+ <button type="button" class="pp-dev-info__users-action" data-open="${escapeHtml(user.origin)}" data-focus-key="${name}:open" title="Open this page as ${name}">Open</button>
866
+ <button type="button" class="pp-dev-info__users-remove" data-remove="${name}" data-focus-key="${name}:remove" title="Remove ${name}" aria-label="Remove ${name}">×</button>
867
+ </div>`;
868
+ }),
869
+ ];
870
+ $list.innerHTML = rows.join('');
871
+ if (focusKey !== undefined) {
872
+ // The same control of the same user, or the list's first control if it is gone.
873
+ const $same = [...$list.querySelectorAll('[data-focus-key]')].find(($el) => $el.dataset.focusKey === focusKey);
874
+ ($same ?? $list.querySelector('[data-focus-key]'))?.focus();
875
+ }
876
+ $list.querySelectorAll('[data-open]').forEach(($open) => {
877
+ $open.addEventListener('click', (ev) => {
878
+ ev.preventDefault();
879
+ const target = $open.dataset.open;
880
+ window.open($open.dataset.raw ? target : samePageOn(target), '_blank');
881
+ });
882
+ });
883
+ const startEditing = (name) => {
884
+ editing = name;
885
+ renderedData = '';
886
+ renderList();
887
+ $list.querySelector('.pp-dev-info__users-edit-name')?.focus();
888
+ };
889
+ $list.querySelectorAll('[data-edit]').forEach(($edit) => {
890
+ $edit.addEventListener('click', (ev) => {
891
+ ev.preventDefault();
892
+ startEditing($edit.dataset.edit);
893
+ });
894
+ });
895
+ $list.querySelector('[data-cancel-edit]')?.addEventListener('click', (ev) => {
896
+ ev.preventDefault();
897
+ setError('');
898
+ startEditing(null);
899
+ });
900
+ $list.querySelector('.pp-dev-info__users-edit')?.addEventListener('submit', async (ev) => {
901
+ ev.preventDefault();
902
+ const name = editing;
903
+ const $form = ev.currentTarget;
904
+ const newName = $form.querySelector('.pp-dev-info__users-edit-name').value.trim();
905
+ const token = $form.querySelector('.pp-dev-info__users-edit-token').value.trim();
906
+ const response = await request('PATCH', `${USERS_API}/${encodeURIComponent(name)}`, {
907
+ name: newName,
908
+ ...(token ? { token } : {}),
909
+ });
910
+ if (!response) {
911
+ return;
912
+ }
913
+ const { user } = (await response.json());
914
+ // This tab's own user got a new host: go on there (the old one would only send it on).
915
+ if (tabUser === name && user.name !== name) {
916
+ window.location.href = samePageOn(user.origin);
917
+ return;
918
+ }
919
+ editing = null;
920
+ renderedData = '';
921
+ await refresh();
922
+ });
923
+ $list.querySelectorAll('[data-remove]').forEach(($remove) => {
924
+ $remove.addEventListener('click', async (ev) => {
925
+ ev.preventDefault();
926
+ if (await request('DELETE', `${USERS_API}/${encodeURIComponent($remove.dataset.remove)}`)) {
927
+ await refresh();
928
+ }
929
+ });
930
+ });
931
+ $list.querySelectorAll('.pp-dev-info__users-default').forEach(($radio) => {
932
+ $radio.addEventListener('change', async () => {
933
+ await request('PUT', `${USERS_API}/default`, { name: $radio.value || null });
934
+ // Redrawn even if refused: the radio the click checked must go back.
935
+ renderedData = '';
936
+ await refresh();
937
+ });
938
+ });
939
+ };
940
+ const renderAvailability = () => {
941
+ if (!$popover || !data) {
942
+ return;
943
+ }
944
+ const $form = $popover.querySelector('.pp-dev-info__users-add');
945
+ const $unavailable = $popover.querySelector('.pp-dev-info__users-unavailable');
946
+ let reason = '';
947
+ if (!isOnLocalhost()) {
948
+ reason = 'Users open on <name>.localhost hosts: open the dev server on localhost to use them.';
949
+ }
950
+ else if (!data.available) {
951
+ reason = 'Adding users needs your own API token: set mi.token or MI_ACCESS_TOKEN.';
952
+ }
953
+ if ($form) {
954
+ $form.hidden = Boolean(reason);
955
+ }
956
+ if ($unavailable) {
957
+ $unavailable.textContent = reason;
958
+ $unavailable.hidden = !reason;
959
+ }
960
+ };
961
+ const refresh = async () => {
962
+ try {
963
+ const response = await fetch(USERS_API);
964
+ if (!response.ok) {
965
+ return;
966
+ }
967
+ data = (await response.json());
968
+ renderList();
969
+ renderAvailability();
970
+ }
971
+ catch {
972
+ // The next refresh tries again.
973
+ }
974
+ };
975
+ const renderKind = () => {
976
+ if (!$popover) {
977
+ return;
978
+ }
979
+ $popover.querySelectorAll('.pp-dev-info__users-kind').forEach(($kind) => {
980
+ const active = $kind.dataset.kind === addKind;
981
+ $kind.classList.toggle('active', active);
982
+ $kind.setAttribute('aria-pressed', String(active));
983
+ });
984
+ const $token = $popover.querySelector('.pp-dev-info__users-token');
985
+ const $submit = $popover.querySelector('.pp-dev-info__users-submit');
986
+ const $kindHint = $popover.querySelector('.pp-dev-info__users-kind-hint');
987
+ if ($token) {
988
+ $token.hidden = addKind !== 'token';
989
+ }
990
+ if ($submit) {
991
+ $submit.textContent = addKind === 'token' ? 'Add user' : 'Add and log in…';
992
+ }
993
+ if ($kindHint) {
994
+ $kindHint.textContent =
995
+ addKind === 'token'
996
+ ? 'Checked with MI before the user is added. SSO users can only be added this way.'
997
+ : "Opens MI's login page on the user's own host. pp-dev never sees the password.";
998
+ }
999
+ };
1000
+ const submit = async () => {
1001
+ if (!$popover) {
1002
+ return;
1003
+ }
1004
+ const $name = $popover.querySelector('.pp-dev-info__users-name-input');
1005
+ const $token = $popover.querySelector('.pp-dev-info__users-token');
1006
+ const $submit = $popover.querySelector('.pp-dev-info__users-submit');
1007
+ const body = addKind === 'token'
1008
+ ? { name: $name.value.trim(), token: $token.value }
1009
+ : { name: $name.value.trim(), kind: 'session' };
1010
+ $submit.disabled = true;
1011
+ const response = await request('POST', USERS_API, body);
1012
+ $submit.disabled = false;
1013
+ if (!response) {
1014
+ return;
1015
+ }
1016
+ const { user } = (await response.json());
1017
+ $name.value = '';
1018
+ $token.value = '';
1019
+ if (user.kind === 'session' && user.loginUrl) {
1020
+ window.open(user.loginUrl, '_blank');
1021
+ }
1022
+ await refresh();
1023
+ };
1024
+ const open = () => {
1025
+ renderedData = '';
1026
+ $popover = document.createElement('div');
1027
+ $popover.classList.add('pp-dev-info__settings', 'pp-dev-info__users');
1028
+ $popover.innerHTML = `
1029
+ <div class="pp-dev-info__settings-title">Users</div>
1030
+ <div class="pp-dev-info__users-tab">
1031
+ This tab works as <strong>${escapeHtml(getCurrentUser() ?? 'you')}</strong>.
1032
+ </div>
1033
+ <div class="pp-dev-info__users-list-head" aria-hidden="true">localhost</div>
1034
+ <div class="pp-dev-info__users-list" role="radiogroup" aria-label="Who localhost works as">
1035
+ <span class="pp-dev-info__users-meta">Loading users…</span>
1036
+ </div>
1037
+ <div class="pp-dev-info__settings-hint">The radio picks who localhost works as; each user's own host always works as that user.</div>
1038
+ <div class="pp-dev-info__users-unavailable" hidden></div>
1039
+ <form class="pp-dev-info__settings-group pp-dev-info__users-add" hidden>
1040
+ <span class="pp-dev-info__settings-label">Add user</span>
1041
+ <div class="pp-dev-info__theme-grid" role="group" aria-label="How the user logs in">
1042
+ <button type="button" class="pp-dev-info__theme-btn pp-dev-info__users-kind" data-kind="token">API token</button>
1043
+ <button type="button" class="pp-dev-info__theme-btn pp-dev-info__users-kind" data-kind="session">Username + password</button>
1044
+ </div>
1045
+ <input class="pp-dev-info__users-input pp-dev-info__users-name-input" placeholder="Name, e.g. viewer" maxlength="32"
1046
+ autocomplete="off" spellcheck="false" pattern="[a-z]([a-z0-9-]*[a-z0-9])?"
1047
+ title="Lowercase latin letters, digits and -, starting with a letter. The user opens on <name>.localhost." />
1048
+ <input class="pp-dev-info__users-input pp-dev-info__users-token" type="password" placeholder="API token" autocomplete="off" />
1049
+ <button type="submit" class="pp-dev-info__vars-btn pp-dev-info__users-submit">Add user</button>
1050
+ <div class="pp-dev-info__settings-hint pp-dev-info__users-kind-hint"></div>
1051
+ </form>
1052
+ <div class="pp-dev-info__users-error" role="alert" hidden></div>
1053
+ <div class="pp-dev-info__settings-hint">Each user opens on <code>&lt;name&gt;.localhost</code> and is kept until the dev server stops.</div>
1054
+ `;
1055
+ $popover.querySelectorAll('.pp-dev-info__users-kind').forEach(($kind) => {
1056
+ $kind.addEventListener('click', (ev) => {
1057
+ ev.preventDefault();
1058
+ addKind = $kind.dataset.kind;
1059
+ renderKind();
1060
+ });
1061
+ });
1062
+ $popover.querySelector('.pp-dev-info__users-add').addEventListener('submit', (ev) => {
1063
+ ev.preventDefault();
1064
+ submit();
1065
+ });
1066
+ renderKind();
1067
+ $panel.appendChild($popover);
1068
+ document.addEventListener('click', onDocClick, true);
1069
+ document.addEventListener('keydown', onKeyDown, true);
1070
+ hooks?.onOpenChange?.(true);
1071
+ // Shown right away from what was loaded with the panel, then brought up to date.
1072
+ renderList();
1073
+ renderAvailability();
1074
+ refresh();
1075
+ // Picks up logins and expired sessions while the popover is open.
1076
+ refreshTimer = window.setInterval(refresh, REFRESH_INTERVAL);
1077
+ };
1078
+ // Loaded with the panel, so the popover opens with its list.
1079
+ refresh();
1080
+ $btn.addEventListener('click', (ev) => {
1081
+ ev.preventDefault();
1082
+ if ($popover) {
1083
+ close();
1084
+ }
1085
+ else {
1086
+ open();
1087
+ }
1088
+ });
1089
+ }
1090
+
647
1091
  const ICON_SIZE = 16;
648
1092
  const CLOSE_ICON_SIZE = 12;
649
1093
  const TYPE_ICONS = {
@@ -705,6 +1149,115 @@ function createPopupElement(opts) {
705
1149
  return $popup;
706
1150
  }
707
1151
 
1152
+ /**
1153
+ * `template:sync:response` code for a sync requested while another one runs. Kept in sync with
1154
+ * `SYNC_IN_PROGRESS_CODE` in `src/lib/client.service.ts` (server side, not importable here).
1155
+ */
1156
+ const SYNC_IN_PROGRESS_CODE = 'SYNC_IN_PROGRESS';
1157
+ const TITLE_AVAILABLE = 'Sync template';
1158
+ const TITLE_UNAVAILABLE = 'Sync is unavailable on this instance';
1159
+ /**
1160
+ * Wires the dev panel's "Sync template" button to the dev server.
1161
+ *
1162
+ * The button is disabled while a sync runs, so a double click can't start a second build, and is
1163
+ * enabled again by whatever ends the sync: its response, or a lost connection. It stays disabled
1164
+ * only when the server says sync is unavailable on this instance (`config.canSync === false`);
1165
+ * a failed sync (MI unreachable, a build error) can simply be retried.
1166
+ */
1167
+ function initSyncButton(hot, $button, deps) {
1168
+ const reload = deps.reload ?? (() => window.location.reload());
1169
+ let available = !$button.disabled && !$button.classList.contains('disabled');
1170
+ let syncing = false;
1171
+ const applyState = () => {
1172
+ $button.disabled = syncing || !available;
1173
+ $button.classList.toggle('syncing', syncing);
1174
+ $button.classList.toggle('disabled', !available);
1175
+ $button.title = available ? TITLE_AVAILABLE : TITLE_UNAVAILABLE;
1176
+ if (syncing) {
1177
+ $button.setAttribute('aria-busy', 'true');
1178
+ }
1179
+ else {
1180
+ $button.removeAttribute('aria-busy');
1181
+ }
1182
+ };
1183
+ const endSync = () => {
1184
+ syncing = false;
1185
+ applyState();
1186
+ };
1187
+ hot.on('client:config:update', (data) => {
1188
+ if (typeof data?.config?.canSync !== 'boolean') {
1189
+ return;
1190
+ }
1191
+ // The server sends `canSync: true` at the start of every sync: it must not enable the
1192
+ // button while that sync runs, which `applyState` takes care of.
1193
+ available = data.config.canSync;
1194
+ applyState();
1195
+ });
1196
+ hot.on('template:sync:action-required', async (payload) => {
1197
+ // The spinner keeps running while a confirmation modal is shown: the sync is still in
1198
+ // progress and only ends on `template:sync:response`.
1199
+ const approved = await deps.confirmModal({
1200
+ title: payload.title,
1201
+ content: payload.content,
1202
+ confirmText: payload.confirmText,
1203
+ cancelText: payload.cancelText,
1204
+ });
1205
+ hot.send('template:sync:action-response', { requestId: payload.requestId, approved });
1206
+ });
1207
+ hot.on('template:sync:response', (payload) => {
1208
+ deps.closeAllConfirmModals();
1209
+ if ('error' in payload && payload.config?.canSync === false) {
1210
+ available = false;
1211
+ }
1212
+ endSync();
1213
+ if ('cancelled' in payload && payload.cancelled) {
1214
+ deps.infoPopup({ title: 'Sync cancelled', content: payload.message, type: 'warning' });
1215
+ return;
1216
+ }
1217
+ if ('error' in payload && typeof payload.error !== 'undefined') {
1218
+ if (payload.code === SYNC_IN_PROGRESS_CODE) {
1219
+ deps.infoPopup({ title: 'Sync already running', content: payload.error, type: 'warning' });
1220
+ return;
1221
+ }
1222
+ deps.infoPopup({ title: 'Sync error', content: payload.error, type: 'danger' });
1223
+ if (payload.refresh) {
1224
+ setTimeout(reload);
1225
+ }
1226
+ return;
1227
+ }
1228
+ if ('syncedAt' in payload && typeof payload.syncedAt !== 'undefined') {
1229
+ deps.infoPopup({
1230
+ title: 'Sync success',
1231
+ content: `Synced at ${new Date(payload.syncedAt).toLocaleString()}.\nBackup filename: ${payload.backupFilename}`,
1232
+ type: 'success',
1233
+ });
1234
+ }
1235
+ });
1236
+ // A response sent to a socket that has closed never arrives. Without this, the button would
1237
+ // spin, disabled, until the page is reloaded.
1238
+ hot.on('vite:ws:disconnect', () => {
1239
+ if (!syncing) {
1240
+ return;
1241
+ }
1242
+ deps.closeAllConfirmModals();
1243
+ endSync();
1244
+ deps.infoPopup({
1245
+ title: 'Sync result unknown',
1246
+ content: 'The connection to the dev server was lost during the sync. Check its terminal for the result before syncing again.',
1247
+ type: 'warning',
1248
+ });
1249
+ });
1250
+ $button.addEventListener('click', (ev) => {
1251
+ ev.preventDefault();
1252
+ if (syncing || !available) {
1253
+ return;
1254
+ }
1255
+ syncing = true;
1256
+ applyState();
1257
+ hot.send('template:sync', {});
1258
+ });
1259
+ }
1260
+
708
1261
  /// <reference types="vite/client" />
709
1262
  // Applied unconditionally (even if the panel itself is hidden) — this is what makes the
710
1263
  // override affect the whole injected panel, not just its own markup.
@@ -895,6 +1448,7 @@ if ($infoPanel) {
895
1448
  onOpenInspectorClick: () => window.open(window.location.origin + '/@pp-dev/inspector', '_blank'),
896
1449
  onReloadVariablesClick: () => startReloadVariablesFlow?.(),
897
1450
  });
1451
+ initUsersPanel($infoPanel, { onOpenChange: (open) => autoHide.keepPeeked(open) });
898
1452
  panelController.onChange((state) => {
899
1453
  if (!state.autoHide) {
900
1454
  $infoPanel.classList.remove('is-peeking');
@@ -936,82 +1490,9 @@ if (hot) {
936
1490
  hot.on('redirect', (data) => {
937
1491
  window.location.href = data.url;
938
1492
  });
939
- hot.on('client:config:update', (data) => {
940
- if (typeof data.config?.canSync === 'boolean') {
941
- if (data.config.canSync) {
942
- const $syncButton = document.getElementById('sync-template');
943
- if ($syncButton) {
944
- $syncButton.disabled = false;
945
- $syncButton.classList.remove('disabled');
946
- $syncButton.title = 'Sync template';
947
- }
948
- }
949
- else {
950
- const $syncButton = document.getElementById('sync-template');
951
- if ($syncButton) {
952
- $syncButton.disabled = true;
953
- $syncButton.classList.add('disabled');
954
- $syncButton.title = 'Sync is unavailable on this instance';
955
- }
956
- }
957
- }
958
- });
959
1493
  const $syncButton = document.getElementById('sync-template');
960
1494
  if ($syncButton) {
961
- hot.on('template:sync:action-required', async (payload) => {
962
- // Keep the sync spinner running while a confirmation modal is shown — the sync
963
- // process is still in progress and only ends on `template:sync:response`.
964
- const approved = await confirmModal({
965
- title: payload.title,
966
- content: payload.content,
967
- confirmText: payload.confirmText,
968
- cancelText: payload.cancelText,
969
- });
970
- hot.send('template:sync:action-response', {
971
- requestId: payload.requestId,
972
- approved,
973
- });
974
- });
975
- hot.on('template:sync:response', (payload) => {
976
- closeAllConfirmModals();
977
- $syncButton.classList.remove('syncing');
978
- if ('cancelled' in payload && payload.cancelled) {
979
- infoPopup({
980
- title: 'Sync cancelled',
981
- content: payload.message,
982
- type: 'warning',
983
- });
984
- }
985
- else if ('error' in payload && typeof payload.error !== 'undefined') {
986
- infoPopup({
987
- title: 'Sync error',
988
- content: payload.error,
989
- type: 'danger',
990
- });
991
- if (payload.refresh) {
992
- setTimeout(() => {
993
- window.location.reload();
994
- });
995
- }
996
- else {
997
- $syncButton.disabled = true;
998
- $syncButton.classList.add('disabled');
999
- $syncButton.title = 'Sync is unavailable on this instance';
1000
- }
1001
- }
1002
- else if ('syncedAt' in payload && typeof payload.syncedAt !== 'undefined') {
1003
- infoPopup({
1004
- title: 'Sync success',
1005
- content: `Synced at ${new Date(payload.syncedAt).toLocaleString()}.\nBackup filename: ${payload.backupFilename}`,
1006
- type: 'success',
1007
- });
1008
- }
1009
- });
1010
- $syncButton.addEventListener('click', (ev) => {
1011
- ev.preventDefault();
1012
- $syncButton.classList.add('syncing');
1013
- hot.send('template:sync', {});
1014
- });
1495
+ initSyncButton(hot, $syncButton, { infoPopup, confirmModal, closeAllConfirmModals });
1015
1496
  }
1016
1497
  hot.on('page-variables:reload:response', (payload) => {
1017
1498
  pendingReloadVariablesPopup?.close();