@goodandready/dsh-key-rotation 0.8.20 → 0.8.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/lib/client.js +493 -210
- package/lib/config-compat.js +82 -0
- package/lib/http-bridge.js +55 -21
- package/lib/index.js +24 -13
- package/lib/lifecycle.js +28 -14
- package/lib/ops-keys.js +6 -4
- package/lib/ops-telemetry.js +5 -5
- package/lib/pool-builder.js +9 -3
- package/lib/pool.js +3 -2
- package/lib/settings-write.js +77 -0
- package/package.json +8 -2
package/lib/client.js
CHANGED
|
@@ -139,6 +139,11 @@ window.__ModuleLoader__.load({
|
|
|
139
139
|
emptyDesc: 'Add a provider and at least one API key name to start rotating.',
|
|
140
140
|
errorTitle: 'Could not load settings',
|
|
141
141
|
retry: 'Retry',
|
|
142
|
+
chooseProvider: 'Choose a provider',
|
|
143
|
+
readOnly: 'Settings are read-only.',
|
|
144
|
+
settingsChanged: 'The settings service changed. Discard this draft and reload before saving.',
|
|
145
|
+
saveRefused: 'The host did not accept this save. Your draft is kept; discard it to load the latest settings.',
|
|
146
|
+
saveUnconfirmed: 'The save result could not be confirmed. Your draft is kept; reload before trying again.',
|
|
142
147
|
unavailableTitle: 'Settings bridge unavailable',
|
|
143
148
|
unavailableDesc: 'The host did not expose the dsh-key-rotation settings namespace. Reopen Settings or check the plugin is enabled.',
|
|
144
149
|
modalClose: 'Close dialog',
|
|
@@ -278,6 +283,11 @@ window.__ModuleLoader__.load({
|
|
|
278
283
|
emptyDesc: '添加提供商及至少一个 API 密钥名称以开始轮换。',
|
|
279
284
|
errorTitle: '无法加载设置',
|
|
280
285
|
retry: '重试',
|
|
286
|
+
chooseProvider: '请选择提供商',
|
|
287
|
+
readOnly: '当前设置为只读。',
|
|
288
|
+
settingsChanged: '设置服务已变更。请放弃当前草稿并重新加载后再保存。',
|
|
289
|
+
saveRefused: '宿主未接受此次保存。草稿已保留;放弃草稿可加载最新设置。',
|
|
290
|
+
saveUnconfirmed: '无法确认保存结果。草稿已保留,请重新加载后再试。',
|
|
281
291
|
unavailableTitle: '设置桥接不可用',
|
|
282
292
|
unavailableDesc: '宿主未暴露 dsh-key-rotation 设置命名空间。请重新打开设置或检查插件是否已启用。',
|
|
283
293
|
modalClose: '关闭对话框',
|
|
@@ -644,13 +654,17 @@ window.__ModuleLoader__.load({
|
|
|
644
654
|
);
|
|
645
655
|
}
|
|
646
656
|
|
|
647
|
-
// Provider registration is not part of
|
|
657
|
+
// Provider registration is not part of settingsScope's value. Fetch it
|
|
648
658
|
// independently, and never turn an unavailable catalog into proof of absence.
|
|
649
|
-
function useProviderCatalog() {
|
|
659
|
+
function useProviderCatalog(enabled = true) {
|
|
650
660
|
const [catalog, setCatalog] = React.useState({ status: 'loading', providers: [] });
|
|
651
661
|
const requestRef = React.useRef(null);
|
|
652
662
|
const reload = React.useCallback(() => {
|
|
653
663
|
requestRef.current?.abort();
|
|
664
|
+
if (!enabled) {
|
|
665
|
+
setCatalog({ status: 'unavailable', providers: [] });
|
|
666
|
+
return Promise.resolve();
|
|
667
|
+
}
|
|
654
668
|
const controller = new AbortController();
|
|
655
669
|
requestRef.current = controller;
|
|
656
670
|
setCatalog((s) => ({ ...s, status: 'loading' }));
|
|
@@ -672,7 +686,7 @@ window.__ModuleLoader__.load({
|
|
|
672
686
|
setCatalog((s) => ({ ...s, status: 'error' }));
|
|
673
687
|
}
|
|
674
688
|
});
|
|
675
|
-
}, []);
|
|
689
|
+
}, [enabled]);
|
|
676
690
|
React.useEffect(() => {
|
|
677
691
|
reload();
|
|
678
692
|
// A provider may have been installed/enabled while this page was open.
|
|
@@ -685,55 +699,182 @@ window.__ModuleLoader__.load({
|
|
|
685
699
|
return { ...catalog, reload };
|
|
686
700
|
}
|
|
687
701
|
|
|
702
|
+
// Services from different core generations are optional at the plugin boundary.
|
|
703
|
+
// One binding belongs to one Cordis activation, not to each React page mount.
|
|
704
|
+
function createSettingsSource(ctx) {
|
|
705
|
+
const listeners = new Set();
|
|
706
|
+
const candidates = new Map();
|
|
707
|
+
let value = { kind: null, service: null };
|
|
708
|
+
const publish = () => {
|
|
709
|
+
const kind = candidates.has('configForm') ? 'configForm'
|
|
710
|
+
: candidates.has('settingsScope') ? 'settingsScope' : null;
|
|
711
|
+
const service = candidates.get(kind) ?? null;
|
|
712
|
+
if (value.kind === kind && value.service === service) return;
|
|
713
|
+
value = { kind, service };
|
|
714
|
+
for (const listener of [...listeners]) listener();
|
|
715
|
+
};
|
|
716
|
+
if (typeof ctx?.inject === 'function') {
|
|
717
|
+
const watch = (name, kind, resolve) => {
|
|
718
|
+
ctx.inject([name], (owner) => {
|
|
719
|
+
let service;
|
|
720
|
+
try { service = resolve(owner.get(name)); } catch (_) { return; }
|
|
721
|
+
if (!service || typeof service.getSnapshot !== 'function'
|
|
722
|
+
|| typeof service.subscribe !== 'function') return;
|
|
723
|
+
candidates.set(kind, service);
|
|
724
|
+
publish();
|
|
725
|
+
owner.effect(() => () => {
|
|
726
|
+
if (candidates.get(kind) === service) candidates.delete(kind);
|
|
727
|
+
publish();
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
};
|
|
731
|
+
watch('configForms', 'configForm', (forms) => forms?.get?.(NS));
|
|
732
|
+
watch('settingsScope', 'settingsScope', (binder) => binder?.bind?.({ namespace: NS }));
|
|
733
|
+
if (typeof ctx.effect === 'function') {
|
|
734
|
+
ctx.effect(() => () => { candidates.clear(); publish(); listeners.clear(); });
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return {
|
|
738
|
+
getSnapshot: () => value,
|
|
739
|
+
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function sameJson(a, b) {
|
|
744
|
+
if (Object.is(a, b)) return true;
|
|
745
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object'
|
|
746
|
+
|| Array.isArray(a) !== Array.isArray(b)) return false;
|
|
747
|
+
const keys = Object.keys(a);
|
|
748
|
+
return keys.length === Object.keys(b).length
|
|
749
|
+
&& keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameJson(a[key], b[key]));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// A redacted, resolved snapshot is not a complete user section. Submit only
|
|
753
|
+
// changed top-level fields; arrays are intentionally edited atomically.
|
|
754
|
+
function changedSettingsOps(base, next) {
|
|
755
|
+
const keys = new Set([...Object.keys(base), ...Object.keys(next)]);
|
|
756
|
+
return [...keys].filter((key) => !sameJson(base[key], next[key])).map((key) =>
|
|
757
|
+
next[key] === undefined ? { op: 'unset', path: [key] }
|
|
758
|
+
: { op: 'set', path: [key], value: next[key] });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function requireJson(value, depth = 0) {
|
|
762
|
+
if (depth > 40) throw new Error('Configuration is nested too deeply');
|
|
763
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
764
|
+
if (typeof value === 'number' && Number.isFinite(value)) return;
|
|
765
|
+
if (Array.isArray(value)) { value.forEach(child => requireJson(child, depth + 1)); return; }
|
|
766
|
+
if (value && typeof value === 'object') {
|
|
767
|
+
for (const [key, child] of Object.entries(value)) {
|
|
768
|
+
if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('Unsafe configuration field');
|
|
769
|
+
if (child !== undefined) requireJson(child, depth + 1);
|
|
770
|
+
}
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
throw new Error('Configuration must contain finite, JSON-compatible values');
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
function reorderKeys(entry, order) {
|
|
778
|
+
const next = { ...entry, keys: order.map(index => entry.keys[index]) };
|
|
779
|
+
for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
|
|
780
|
+
if (Array.isArray(entry[field]) && entry[field].length) next[field] = order.map(index => entry[field][index] ?? fallback);
|
|
781
|
+
}
|
|
782
|
+
return next;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function validatePoolDraft(providers) {
|
|
786
|
+
if (!Array.isArray(providers)) throw new Error('Provider pools must be an array');
|
|
787
|
+
const names = new Set();
|
|
788
|
+
for (const entry of providers) {
|
|
789
|
+
if (!entry || typeof entry.provider !== 'string' || !entry.provider.trim()
|
|
790
|
+
|| names.has(entry.provider) || !Array.isArray(entry.keys)
|
|
791
|
+
|| entry.keys.some(key => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
792
|
+
|| new Set(entry.keys).size !== entry.keys.length) {
|
|
793
|
+
throw new Error('Choose a unique provider and valid, unique credential reference names');
|
|
794
|
+
}
|
|
795
|
+
names.add(entry.provider);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function settingsResponse(response) {
|
|
800
|
+
let data;
|
|
801
|
+
try { data = await response.json(); }
|
|
802
|
+
catch (_) { throw new Error('Settings response is not valid JSON (HTTP ' + response.status + ')'); }
|
|
803
|
+
if (!response.ok || data?.error) {
|
|
804
|
+
throw new Error(data?.error?.message || 'Settings request failed (HTTP ' + response.status + ')');
|
|
805
|
+
}
|
|
806
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid settings response');
|
|
807
|
+
return data;
|
|
808
|
+
}
|
|
809
|
+
|
|
688
810
|
function KeyRotationSection(props) {
|
|
689
811
|
const t = resolveT(props);
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
return null;
|
|
699
|
-
}, [props.ctx]);
|
|
812
|
+
const settingsBinding = React.useSyncExternalStore(
|
|
813
|
+
props.settingsSource.subscribe, props.settingsSource.getSnapshot
|
|
814
|
+
);
|
|
815
|
+
const settingsScope = settingsBinding.service;
|
|
816
|
+
const settingsContract = settingsBinding.kind;
|
|
817
|
+
const bindingRef = React.useRef(settingsBinding);
|
|
818
|
+
bindingRef.current = settingsBinding;
|
|
700
819
|
// #273: getSnapshot must be referentially stable or React 18 unmounts the tree
|
|
701
820
|
const scopeCacheRef = React.useRef({ has: false, value: null });
|
|
702
821
|
const getScopeSnapshot = React.useCallback(() => {
|
|
703
|
-
if (!
|
|
822
|
+
if (!settingsScope || typeof settingsScope.getSnapshot !== 'function') return null;
|
|
704
823
|
let next = null;
|
|
705
824
|
try {
|
|
706
|
-
next =
|
|
825
|
+
next = settingsScope.getSnapshot();
|
|
707
826
|
} catch (_) {
|
|
708
827
|
return null;
|
|
709
828
|
}
|
|
710
829
|
const prev = scopeCacheRef.current;
|
|
830
|
+
if (prev.service !== settingsScope) prev.has = false;
|
|
711
831
|
if (prev.has && Object.is(prev.value, next)) return prev.value;
|
|
712
832
|
// shallow-compare common snapshot shape to avoid identity thrash
|
|
713
833
|
if (prev.has && prev.value && next
|
|
714
|
-
&& prev.value.
|
|
715
|
-
&& prev.value
|
|
716
|
-
&& prev.value.value === next.value) {
|
|
834
|
+
&& Object.keys(prev.value).length === Object.keys(next).length
|
|
835
|
+
&& Object.keys(next).every((key) => Object.is(prev.value[key], next[key]))) {
|
|
717
836
|
return prev.value;
|
|
718
837
|
}
|
|
719
|
-
scopeCacheRef.current = { has: true, value: next };
|
|
838
|
+
scopeCacheRef.current = { has: true, value: next, service: settingsScope };
|
|
720
839
|
return next;
|
|
721
|
-
}, [
|
|
840
|
+
}, [settingsScope]);
|
|
722
841
|
const scopeSnapshot = React.useSyncExternalStore(
|
|
723
|
-
React.useMemo(() => (cb) => (
|
|
842
|
+
React.useMemo(() => (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}), [settingsScope]),
|
|
724
843
|
getScopeSnapshot
|
|
725
844
|
);
|
|
726
|
-
const [state, setState] = React.useState({ status: 'loading', value: null, revision:
|
|
727
|
-
const
|
|
845
|
+
const [state, setState] = React.useState({ status: 'loading', value: null, revision: undefined, writable: false, error: '', providers: [] });
|
|
846
|
+
const stateRef = React.useRef(state);
|
|
847
|
+
const updateState = React.useCallback((update) => {
|
|
848
|
+
const next = typeof update === 'function' ? update(stateRef.current) : update;
|
|
849
|
+
stateRef.current = next;
|
|
850
|
+
setState(next);
|
|
851
|
+
}, []);
|
|
852
|
+
const catalog = useProviderCatalog(Boolean(settingsScope) && scopeSnapshot?.mode !== 'memory');
|
|
728
853
|
const latestScopeSnapshot = React.useRef(scopeSnapshot);
|
|
729
854
|
latestScopeSnapshot.current = scopeSnapshot;
|
|
730
|
-
// #290: surface configForms availability as card state
|
|
731
|
-
React.useEffect(() => {
|
|
732
|
-
if (!configForms) {
|
|
733
|
-
setState((s) => (s.status === 'unavailable' ? s : { ...s, status: 'unavailable' }));
|
|
734
|
-
}
|
|
735
|
-
}, [configForms]);
|
|
736
855
|
const [draft, setDraft] = React.useState(null);
|
|
856
|
+
const draftRef = React.useRef(null);
|
|
857
|
+
const draftBaseRef = React.useRef(null);
|
|
858
|
+
const saveInFlightRef = React.useRef(false);
|
|
859
|
+
const [saving, setSaving] = React.useState(false);
|
|
860
|
+
const [importing, setImporting] = React.useState(false);
|
|
861
|
+
const importRef = React.useRef(false);
|
|
862
|
+
const importCancelRef = React.useRef(null);
|
|
863
|
+
const editEpochRef = React.useRef(0);
|
|
864
|
+
const mountedRef = React.useRef(true);
|
|
865
|
+
const readRef = React.useRef({ generation: 0, controller: null });
|
|
866
|
+
const saveControllerRef = React.useRef(null);
|
|
867
|
+
React.useEffect(() => {
|
|
868
|
+
mountedRef.current = true;
|
|
869
|
+
return () => {
|
|
870
|
+
mountedRef.current = false;
|
|
871
|
+
editEpochRef.current++;
|
|
872
|
+
readRef.current.generation++;
|
|
873
|
+
readRef.current.controller?.abort();
|
|
874
|
+
saveControllerRef.current?.abort();
|
|
875
|
+
importCancelRef.current?.();
|
|
876
|
+
};
|
|
877
|
+
}, []);
|
|
737
878
|
// ── all hooks live ABOVE any early return (React error 310 otherwise) ──
|
|
738
879
|
const [search, setSearch] = React.useState('');
|
|
739
880
|
const [statusFilter, setStatusFilter] = React.useState('all');
|
|
@@ -743,6 +884,7 @@ window.__ModuleLoader__.load({
|
|
|
743
884
|
const [bulkCooldown, setBulkCooldown] = React.useState('');
|
|
744
885
|
const [undo, setUndo] = React.useState(null);
|
|
745
886
|
const undoTimer = React.useRef(null);
|
|
887
|
+
React.useEffect(() => () => { if (undoTimer.current) clearTimeout(undoTimer.current); }, []);
|
|
746
888
|
// #289 a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
|
|
747
889
|
const modalReturnFocusRef = React.useRef(null);
|
|
748
890
|
const modalCardRef = React.useRef(null);
|
|
@@ -790,13 +932,36 @@ window.__ModuleLoader__.load({
|
|
|
790
932
|
const [testingProgress, setTestingProgress] = React.useState({});
|
|
791
933
|
const [secretDraft, setSecretDraft] = React.useState({});
|
|
792
934
|
const [secretError, setSecretError] = React.useState('');
|
|
935
|
+
const secretWritesRef = React.useRef(new Set());
|
|
793
936
|
const stashUndo = (u) => { setUndo(u); if (undoTimer.current) clearTimeout(undoTimer.current); undoTimer.current = setTimeout(() => setUndo(null), 5000); };
|
|
794
|
-
const doUndo = () => {
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
937
|
+
const doUndo = () => {
|
|
938
|
+
if (!undo || saveInFlightRef.current || importRef.current || !stateRef.current.writable) return;
|
|
939
|
+
const u = undo;
|
|
940
|
+
setField((cur) => {
|
|
941
|
+
const providers = [...(cur.providers ?? [])];
|
|
942
|
+
if (u.type === 'provider') {
|
|
943
|
+
if (providers.some(p => p.provider === u.entry.provider)) return cur;
|
|
944
|
+
providers.splice(Math.min(u.index, providers.length), 0, u.entry);
|
|
945
|
+
} else {
|
|
946
|
+
// Other providers may have been removed or re-ordered since Undo
|
|
947
|
+
// was staged. Identity, not the old displayed index, selects it.
|
|
948
|
+
const index = providers.findIndex(p => p.provider === u.provider);
|
|
949
|
+
if (index < 0 || providers[index].keys.includes(u.key)) return cur;
|
|
950
|
+
const entry = { ...providers[index] };
|
|
951
|
+
const at = Math.min(u.kIndex, entry.keys.length);
|
|
952
|
+
entry.keys = [...entry.keys]; entry.keys.splice(at, 0, u.key);
|
|
953
|
+
for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
|
|
954
|
+
if (u[field] !== undefined || entry[field]?.length) {
|
|
955
|
+
const values = Array.from({ length: entry.keys.length - 1 }, (_, i) => entry[field]?.[i] ?? fallback);
|
|
956
|
+
values.splice(at, 0, u[field] ?? fallback); entry[field] = values;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
providers[index] = entry;
|
|
960
|
+
}
|
|
961
|
+
return { ...cur, providers };
|
|
962
|
+
});
|
|
963
|
+
setUndo(null);
|
|
964
|
+
};
|
|
800
965
|
// Both buttons must run the same live probe, never a presence-only check.
|
|
801
966
|
// Serialize probes: a large pool must not burst /models requests. The ref
|
|
802
967
|
// also guards repeated clicks before React has rendered disabled buttons.
|
|
@@ -852,41 +1017,70 @@ window.__ModuleLoader__.load({
|
|
|
852
1017
|
return runKeyTests(refs, providerId);
|
|
853
1018
|
};
|
|
854
1019
|
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1020
|
+
const acceptSnapshot = React.useCallback((snapshot, source) => {
|
|
1021
|
+
if (!mountedRef.current || bindingRef.current.service !== source) return;
|
|
1022
|
+
if (!snapshot || snapshot.status === 'unavailable' || snapshot.mode === 'memory'
|
|
1023
|
+
|| snapshot.available === false) {
|
|
1024
|
+
updateState((s) => ({ ...s, status: 'unavailable', writable: false, source }));
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (!snapshot.value || typeof snapshot.value !== 'object' || Array.isArray(snapshot.value)
|
|
1028
|
+
|| !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) {
|
|
1029
|
+
throw new Error('Invalid settings snapshot or revision');
|
|
1030
|
+
}
|
|
1031
|
+
updateState((s) => {
|
|
1032
|
+
if (s.source === source && Number.isSafeInteger(s.revision) && s.revision > snapshot.revision) return s;
|
|
1033
|
+
return { ...s, status: 'ready', value: snapshot.value, revision: snapshot.revision,
|
|
1034
|
+
writable: snapshot.writable === true, source };
|
|
1035
|
+
});
|
|
1036
|
+
}, [updateState]);
|
|
1037
|
+
|
|
1038
|
+
const load = React.useCallback(async (force = false) => {
|
|
1039
|
+
const source = bindingRef.current.service;
|
|
1040
|
+
const snapshot = latestScopeSnapshot.current;
|
|
1041
|
+
const generation = ++readRef.current.generation;
|
|
1042
|
+
readRef.current.controller?.abort();
|
|
1043
|
+
if (!source || snapshot?.status === 'unavailable' || snapshot?.mode === 'memory') {
|
|
1044
|
+
acceptSnapshot(null, source);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
updateState((s) => ({ ...s, error: '' }));
|
|
1048
|
+
if (!force && snapshot?.status === 'ready') {
|
|
1049
|
+
try { acceptSnapshot(snapshot, source); }
|
|
1050
|
+
catch (error) { updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: error.message })); }
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const controller = new AbortController();
|
|
1054
|
+
readRef.current.controller = controller;
|
|
1055
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
1056
|
+
updateState((s) => ({ ...s, status: s.value ? s.status : 'loading' }));
|
|
1057
|
+
try {
|
|
1058
|
+
const data = await settingsResponse(await fetch(CONFIG_PATH, {
|
|
1059
|
+
headers: { accept: 'application/json' }, signal: controller.signal,
|
|
1060
|
+
}));
|
|
1061
|
+
if (controller.signal.aborted || generation !== readRef.current.generation || !mountedRef.current) return;
|
|
1062
|
+
acceptSnapshot(data, source);
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
if (generation !== readRef.current.generation || !mountedRef.current) return;
|
|
1065
|
+
updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: String(error?.message ?? error) }));
|
|
1066
|
+
} finally {
|
|
1067
|
+
clearTimeout(timeout);
|
|
1068
|
+
if (readRef.current.controller === controller) readRef.current.controller = null;
|
|
1069
|
+
}
|
|
1070
|
+
}, [acceptSnapshot, updateState]);
|
|
877
1071
|
|
|
878
1072
|
React.useEffect(() => {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}));
|
|
1073
|
+
readRef.current.generation++;
|
|
1074
|
+
readRef.current.controller?.abort();
|
|
1075
|
+
if (!settingsScope || scopeSnapshot?.status === 'unavailable' || scopeSnapshot?.mode === 'memory') {
|
|
1076
|
+
acceptSnapshot(null, settingsScope);
|
|
1077
|
+
} else if (scopeSnapshot?.status === 'ready') {
|
|
1078
|
+
try { acceptSnapshot(scopeSnapshot, settingsScope); }
|
|
1079
|
+
catch (error) { updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: error.message })); }
|
|
886
1080
|
} else {
|
|
887
|
-
load();
|
|
1081
|
+
void load();
|
|
888
1082
|
}
|
|
889
|
-
}, [load,
|
|
1083
|
+
}, [settingsScope, scopeSnapshot, load, acceptSnapshot, updateState]);
|
|
890
1084
|
|
|
891
1085
|
const val = draft ?? state.value;
|
|
892
1086
|
const status = useRotationStatus();
|
|
@@ -960,7 +1154,9 @@ window.__ModuleLoader__.load({
|
|
|
960
1154
|
};
|
|
961
1155
|
const saveSecret = async (ref, rowKey) => {
|
|
962
1156
|
const value = secretDraft[rowKey];
|
|
963
|
-
if (!value) return;
|
|
1157
|
+
if (!value || secretWritesRef.current.has(ref)) return;
|
|
1158
|
+
secretWritesRef.current.add(ref);
|
|
1159
|
+
try {
|
|
964
1160
|
setSecretError('');
|
|
965
1161
|
// Pre-save validation (issue #118)
|
|
966
1162
|
setValidating(ref);
|
|
@@ -973,7 +1169,7 @@ window.__ModuleLoader__.load({
|
|
|
973
1169
|
setSecretError(t('keyWriteFailed').replace('{msg}', msg));
|
|
974
1170
|
return;
|
|
975
1171
|
}
|
|
976
|
-
fetch('/dsh-key-rotation/key', {
|
|
1172
|
+
await fetch('/dsh-key-rotation/key', {
|
|
977
1173
|
method: 'PUT',
|
|
978
1174
|
headers: { 'content-type': 'application/json' },
|
|
979
1175
|
body: JSON.stringify({ ref, value }),
|
|
@@ -988,11 +1184,12 @@ window.__ModuleLoader__.load({
|
|
|
988
1184
|
}
|
|
989
1185
|
setSecretDraft((cur) => {
|
|
990
1186
|
const next = { ...cur };
|
|
991
|
-
delete next[rowKey];
|
|
1187
|
+
if (next[rowKey] === value) delete next[rowKey];
|
|
992
1188
|
return next;
|
|
993
1189
|
});
|
|
994
1190
|
})
|
|
995
1191
|
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
|
|
1192
|
+
} finally { secretWritesRef.current.delete(ref); }
|
|
996
1193
|
};
|
|
997
1194
|
// #290: explicit card states — loading / error / unavailable / empty / ready
|
|
998
1195
|
if (state.status === 'loading' || (!val && state.status !== 'error' && state.status !== 'unavailable')) {
|
|
@@ -1008,13 +1205,13 @@ window.__ModuleLoader__.load({
|
|
|
1008
1205
|
})
|
|
1009
1206
|
);
|
|
1010
1207
|
}
|
|
1011
|
-
if (state.status === 'error') {
|
|
1208
|
+
if (state.status === 'error' && !val) {
|
|
1012
1209
|
return React.createElement('div', { className: 'krot-state-block', role: 'alert', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '10px', alignItems: 'flex-start' } },
|
|
1013
1210
|
React.createElement('p', { className: 'krot-err', style: { margin: 0 } }, t('errorTitle') + (state.error ? (': ' + state.error) : '')),
|
|
1014
1211
|
React.createElement('button', { type: 'button', className: 'krot-btn', onClick: () => load() }, t('retry'))
|
|
1015
1212
|
);
|
|
1016
1213
|
}
|
|
1017
|
-
if (state.status === 'unavailable' || !
|
|
1214
|
+
if (state.status === 'unavailable' || !settingsScope) {
|
|
1018
1215
|
return React.createElement('div', { className: 'krot-state-block', role: 'status', style: { padding: '12px 0', display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
1019
1216
|
React.createElement('p', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', margin: 0 } }, t('unavailableTitle')),
|
|
1020
1217
|
React.createElement('p', { className: 'krot-hint', style: { margin: 0 } }, t('unavailableDesc'))
|
|
@@ -1024,8 +1221,73 @@ window.__ModuleLoader__.load({
|
|
|
1024
1221
|
const providers = catalog.providers;
|
|
1025
1222
|
const providerById = new Map(providers.map((p) => [p.id, p.name]));
|
|
1026
1223
|
|
|
1027
|
-
const setField = (fn) =>
|
|
1028
|
-
|
|
1224
|
+
const setField = (fn, fromImport = false) => {
|
|
1225
|
+
if (!mountedRef.current || saveInFlightRef.current || (importRef.current && !fromImport)
|
|
1226
|
+
|| !stateRef.current.writable) return;
|
|
1227
|
+
const current = draftRef.current ?? stateRef.current.value;
|
|
1228
|
+
if (!current) return;
|
|
1229
|
+
const next = fn(current);
|
|
1230
|
+
if (sameJson(current, next)) return;
|
|
1231
|
+
if (!draftBaseRef.current) {
|
|
1232
|
+
draftBaseRef.current = { value: structuredClone(current), revision: stateRef.current.revision,
|
|
1233
|
+
source: bindingRef.current.service };
|
|
1234
|
+
}
|
|
1235
|
+
const unchanged = sameJson(draftBaseRef.current.value, next);
|
|
1236
|
+
if (unchanged) draftBaseRef.current = null;
|
|
1237
|
+
draftRef.current = unchanged ? null : next;
|
|
1238
|
+
setDraft(draftRef.current);
|
|
1239
|
+
updateState((s) => ({ ...s, error: '' }));
|
|
1240
|
+
};
|
|
1241
|
+
const clearDraft = () => {
|
|
1242
|
+
editEpochRef.current++;
|
|
1243
|
+
draftBaseRef.current = null;
|
|
1244
|
+
draftRef.current = null;
|
|
1245
|
+
setDraft(null);
|
|
1246
|
+
setUndo(null);
|
|
1247
|
+
setSelected(new Set());
|
|
1248
|
+
if (undoTimer.current) clearTimeout(undoTimer.current);
|
|
1249
|
+
};
|
|
1250
|
+
const readImport = (file, stage) => {
|
|
1251
|
+
if (!file || saveInFlightRef.current || importRef.current || !stateRef.current.writable) return;
|
|
1252
|
+
if (file.size > 1024 * 1024) { setSecretError('Import file exceeds 1 MiB'); return; }
|
|
1253
|
+
const epoch = editEpochRef.current;
|
|
1254
|
+
const source = bindingRef.current.service;
|
|
1255
|
+
importRef.current = true;
|
|
1256
|
+
setImporting(true);
|
|
1257
|
+
const reader = new FileReader();
|
|
1258
|
+
let finished = false;
|
|
1259
|
+
const finish = () => {
|
|
1260
|
+
if (finished) return;
|
|
1261
|
+
finished = true;
|
|
1262
|
+
clearTimeout(timeout);
|
|
1263
|
+
importCancelRef.current = null;
|
|
1264
|
+
importRef.current = false;
|
|
1265
|
+
if (mountedRef.current) setImporting(false);
|
|
1266
|
+
};
|
|
1267
|
+
const timeout = setTimeout(() => {
|
|
1268
|
+
if (mountedRef.current) setSecretError('Import file read timed out');
|
|
1269
|
+
finish(); reader.abort?.();
|
|
1270
|
+
}, 15000);
|
|
1271
|
+
importCancelRef.current = () => { finish(); reader.abort?.(); };
|
|
1272
|
+
reader.onload = () => {
|
|
1273
|
+
if (finished) return;
|
|
1274
|
+
try {
|
|
1275
|
+
if (!mountedRef.current || epoch !== editEpochRef.current || source !== bindingRef.current.service) return;
|
|
1276
|
+
const value = JSON.parse(String(reader.result));
|
|
1277
|
+
requireJson(value);
|
|
1278
|
+
stage(value);
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
if (mountedRef.current) setSecretError(String(error?.message ?? error));
|
|
1281
|
+
} finally { finish(); }
|
|
1282
|
+
};
|
|
1283
|
+
reader.onerror = reader.onabort = () => { if (mountedRef.current) setSecretError('Could not read the import file'); finish(); };
|
|
1284
|
+
try { reader.readAsText(file); }
|
|
1285
|
+
catch (error) { if (mountedRef.current) setSecretError(String(error?.message ?? error)); finish(); }
|
|
1286
|
+
};
|
|
1287
|
+
const providerList = (Array.isArray(val.providers) ? val.providers : [])
|
|
1288
|
+
.map((entry, pIndex) => ({ entry, pIndex }))
|
|
1289
|
+
.filter(({ entry }) => entry && typeof entry.provider === 'string')
|
|
1290
|
+
.filter(({ entry }) => !search || entry.provider.toLowerCase().includes(search.toLowerCase()));
|
|
1029
1291
|
|
|
1030
1292
|
const setProvider = (index, id) => setField((cur) => {
|
|
1031
1293
|
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
@@ -1038,20 +1300,21 @@ window.__ModuleLoader__.load({
|
|
|
1038
1300
|
const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
|
|
1039
1301
|
entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
|
|
1040
1302
|
// keep weights aligned with keys (#215): new key gets default weight 1
|
|
1041
|
-
|
|
1303
|
+
for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
|
|
1304
|
+
if (Array.isArray(entry[field]) && entry[field].length) entry[field] = Array.from({ length: entry.keys.length }, (_, i) => entry[field][i] ?? fallback);
|
|
1305
|
+
}
|
|
1042
1306
|
providers[pIndex] = entry;
|
|
1043
1307
|
return { ...cur, providers };
|
|
1044
1308
|
});
|
|
1045
|
-
const removeKey = (pIndex, kIndex) =>
|
|
1046
|
-
const next = [...(
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
}
|
|
1309
|
+
const removeKey = (pIndex, kIndex) => setField((cur) => {
|
|
1310
|
+
const next = [...(cur.providers ?? [])];
|
|
1311
|
+
const entry = next[pIndex];
|
|
1312
|
+
if (!entry || !entry.keys[kIndex]) return cur;
|
|
1313
|
+
stashUndo({ type: 'key', provider: entry.provider, kIndex, key: entry.keys[kIndex],
|
|
1314
|
+
weights: entry.weights?.[kIndex], expiresAt: entry.expiresAt?.[kIndex] });
|
|
1315
|
+
next[pIndex] = reorderKeys(entry, entry.keys.map((_, index) => index).filter(index => index !== kIndex));
|
|
1053
1316
|
return { ...cur, providers: next };
|
|
1054
|
-
});
|
|
1317
|
+
});
|
|
1055
1318
|
const removeProvider = (pIndex) => setField((cur) => {
|
|
1056
1319
|
const arr = Array.isArray(cur.providers) ? cur.providers : [];
|
|
1057
1320
|
stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
|
|
@@ -1060,23 +1323,13 @@ window.__ModuleLoader__.load({
|
|
|
1060
1323
|
// Key order is attempt order — move it with buttons, do not retype names.
|
|
1061
1324
|
const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
|
|
1062
1325
|
const providers = [...(cur.providers ?? [])];
|
|
1063
|
-
const entry =
|
|
1064
|
-
|
|
1326
|
+
const entry = providers[pIndex];
|
|
1327
|
+
if (!entry) return cur;
|
|
1328
|
+
const order = entry.keys.map((_, index) => index);
|
|
1065
1329
|
const target = kIndex + delta;
|
|
1066
|
-
if (target < 0 || target >=
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
keys[target] = moved;
|
|
1070
|
-
entry.keys = keys;
|
|
1071
|
-
// weights are positional - swap along with the keys (#215)
|
|
1072
|
-
const weights = [...(entry.weights ?? [])];
|
|
1073
|
-
if (weights.length > 0) {
|
|
1074
|
-
const w = weights[kIndex];
|
|
1075
|
-
weights[kIndex] = weights[target];
|
|
1076
|
-
weights[target] = w;
|
|
1077
|
-
entry.weights = weights;
|
|
1078
|
-
}
|
|
1079
|
-
providers[pIndex] = entry;
|
|
1330
|
+
if (target < 0 || target >= order.length) return cur;
|
|
1331
|
+
[order[kIndex], order[target]] = [order[target], order[kIndex]];
|
|
1332
|
+
providers[pIndex] = reorderKeys(entry, order);
|
|
1080
1333
|
return { ...cur, providers };
|
|
1081
1334
|
});
|
|
1082
1335
|
// #215: set a single key's round-robin weight (integer >= 1)
|
|
@@ -1104,48 +1357,80 @@ window.__ModuleLoader__.load({
|
|
|
1104
1357
|
|
|
1105
1358
|
const addProvider = () => setField((cur) => ({
|
|
1106
1359
|
...cur,
|
|
1107
|
-
providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [
|
|
1360
|
+
providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [] }],
|
|
1108
1361
|
}));
|
|
1109
1362
|
|
|
1110
|
-
const save = () => {
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
if (data.error) {
|
|
1122
|
-
setState((s) => ({ ...s, status: 'error', error: data.error.message }));
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
setState((s) => ({ status: 'ready', value: data.value ?? draft, revision: data.revision ?? s.revision, error: '', providers: s.providers }));
|
|
1126
|
-
setDraft(null);
|
|
1127
|
-
})
|
|
1128
|
-
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
1129
|
-
};
|
|
1130
|
-
if (configForms && typeof configForms.set === 'function') {
|
|
1131
|
-
(async () => {
|
|
1132
|
-
try {
|
|
1133
|
-
for (const [k, v] of Object.entries(draft)) {
|
|
1134
|
-
await configForms.set(k, v);
|
|
1135
|
-
}
|
|
1136
|
-
setState((s) => ({ ...s, status: 'ready', value: draft, error: '' }));
|
|
1137
|
-
setDraft(null);
|
|
1138
|
-
} catch (_) {
|
|
1139
|
-
saveViaBridge();
|
|
1140
|
-
}
|
|
1141
|
-
})();
|
|
1363
|
+
const save = async () => {
|
|
1364
|
+
const savingDraft = draftRef.current;
|
|
1365
|
+
const base = draftBaseRef.current;
|
|
1366
|
+
if (!savingDraft || !base || saveInFlightRef.current || importRef.current) return;
|
|
1367
|
+
if (!stateRef.current.writable || bindingRef.current.service !== base.source) {
|
|
1368
|
+
updateState((s) => ({ ...s, error: t('settingsChanged') }));
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const expectedRevision = base.revision;
|
|
1372
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
1373
|
+
updateState((s) => ({ ...s, error: t('settingsChanged') }));
|
|
1142
1374
|
return;
|
|
1143
1375
|
}
|
|
1144
|
-
|
|
1376
|
+
let ops;
|
|
1377
|
+
try {
|
|
1378
|
+
ops = changedSettingsOps(base.value, savingDraft); requireJson(ops);
|
|
1379
|
+
if (ops.some(op => op.path[0] === 'providers')) validatePoolDraft(savingDraft.providers);
|
|
1380
|
+
}
|
|
1381
|
+
catch (error) { updateState((s) => ({ ...s, error: error.message })); return; }
|
|
1382
|
+
if (!ops.length) { clearDraft(); return; }
|
|
1383
|
+
saveInFlightRef.current = true;
|
|
1384
|
+
setSaving(true);
|
|
1385
|
+
editEpochRef.current++;
|
|
1386
|
+
readRef.current.generation++;
|
|
1387
|
+
readRef.current.controller?.abort();
|
|
1388
|
+
updateState((s) => ({ ...s, error: '' }));
|
|
1389
|
+
const controller = new AbortController();
|
|
1390
|
+
saveControllerRef.current = controller;
|
|
1391
|
+
let timeout;
|
|
1392
|
+
try {
|
|
1393
|
+
const expired = new Promise((_, reject) => {
|
|
1394
|
+
timeout = setTimeout(() => {
|
|
1395
|
+
controller.abort();
|
|
1396
|
+
reject(new Error(t('saveUnconfirmed')));
|
|
1397
|
+
}, 15000);
|
|
1398
|
+
});
|
|
1399
|
+
const operation = async () => {
|
|
1400
|
+
if (settingsContract === 'configForm' && typeof base.source.mutate === 'function') {
|
|
1401
|
+
const accepted = await base.source.mutate(ops, expectedRevision);
|
|
1402
|
+
if (accepted !== true) throw new Error(t('saveRefused'));
|
|
1403
|
+
const committed = base.source.getSnapshot();
|
|
1404
|
+
// Shared native queues can settle before publishing this write's
|
|
1405
|
+
// snapshot. Confirm an older/absent snapshot through the bridge.
|
|
1406
|
+
if (committed?.status === 'ready' && committed.revision > expectedRevision) return committed;
|
|
1407
|
+
return settingsResponse(await fetch(CONFIG_PATH, { headers: { accept: 'application/json' }, signal: controller.signal }));
|
|
1408
|
+
}
|
|
1409
|
+
// Legacy client mutate resolves void on BOTH success and refusal.
|
|
1410
|
+
// Host-side mutate, reached through this bridge, throws on refusal.
|
|
1411
|
+
return settingsResponse(await fetch(CONFIG_PATH, {
|
|
1412
|
+
method: 'PUT', headers: { 'content-type': 'application/json' },
|
|
1413
|
+
body: JSON.stringify({ ops, expectedRevision }), signal: controller.signal,
|
|
1414
|
+
}));
|
|
1415
|
+
};
|
|
1416
|
+
const committed = await Promise.race([operation(), expired]);
|
|
1417
|
+
if (!mountedRef.current) return;
|
|
1418
|
+
if (bindingRef.current.service !== base.source) throw new Error(t('saveUnconfirmed'));
|
|
1419
|
+
if (committed?.available === false || committed?.status === 'unavailable'
|
|
1420
|
+
|| !committed?.value || !Number.isSafeInteger(committed.revision)
|
|
1421
|
+
|| committed.revision < expectedRevision) throw new Error(t('saveUnconfirmed'));
|
|
1422
|
+
acceptSnapshot(committed, base.source);
|
|
1423
|
+
clearDraft();
|
|
1424
|
+
} catch (error) {
|
|
1425
|
+
if (mountedRef.current) updateState((s) => ({ ...s, status: s.value ? 'ready' : 'error', error: String(error?.message ?? error) }));
|
|
1426
|
+
} finally {
|
|
1427
|
+
clearTimeout(timeout);
|
|
1428
|
+
saveInFlightRef.current = false;
|
|
1429
|
+
if (saveControllerRef.current === controller) saveControllerRef.current = null;
|
|
1430
|
+
if (mountedRef.current) setSaving(false);
|
|
1431
|
+
}
|
|
1145
1432
|
};
|
|
1146
1433
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
1434
|
const field = (labelText, node) => h('label', { className: 'krot-field' },
|
|
1150
1435
|
h('span', { className: 'krot-label' }, labelText), node);
|
|
1151
1436
|
|
|
@@ -1156,12 +1441,13 @@ window.__ModuleLoader__.load({
|
|
|
1156
1441
|
placeholder,
|
|
1157
1442
|
});
|
|
1158
1443
|
|
|
1159
|
-
const btn = (labelText, onClick, opts) =>
|
|
1160
|
-
className
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1444
|
+
const btn = (labelText, onClick, opts = {}) => {
|
|
1445
|
+
const { primary, className = '', ...rest } = opts;
|
|
1446
|
+
return h('button', { type: 'button', ...rest,
|
|
1447
|
+
className: 'krot-btn' + (primary ? ' krot-save' : '') + (className ? ' ' + className : ''),
|
|
1448
|
+
onClick, disabled: Boolean(rest.disabled),
|
|
1449
|
+
}, labelText);
|
|
1450
|
+
};
|
|
1165
1451
|
|
|
1166
1452
|
// Key state dot: color and label read at a glance; "key not found"
|
|
1167
1453
|
// catches an env-name typo that would otherwise stay silent.
|
|
@@ -1187,8 +1473,8 @@ window.__ModuleLoader__.load({
|
|
|
1187
1473
|
};
|
|
1188
1474
|
|
|
1189
1475
|
const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
|
|
1190
|
-
const providerRows = providerList.map((entry, pIndex) => {
|
|
1191
|
-
const options = [];
|
|
1476
|
+
const providerRows = providerList.map(({ entry, pIndex }) => {
|
|
1477
|
+
const options = [h('option', { key: '', value: '' }, t('chooseProvider'))];
|
|
1192
1478
|
if (entry.provider && !providerById.has(entry.provider)) {
|
|
1193
1479
|
options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + (catalog.status === 'ready' ? t('notRegistered')
|
|
1194
1480
|
: catalog.status === 'loading' ? t('loading') : t('providerCatalogUnavailable')) + ')'));
|
|
@@ -1230,7 +1516,7 @@ window.__ModuleLoader__.load({
|
|
|
1230
1516
|
const keyRows = filteredIndices.map(({ key, kIndex }) => {
|
|
1231
1517
|
const st = keyStatus(entry.provider, key);
|
|
1232
1518
|
const info = keyInfo(entry.provider, key);
|
|
1233
|
-
const rowKey = entry.provider + '/' +
|
|
1519
|
+
const rowKey = entry.provider + '/' + key;
|
|
1234
1520
|
const typed = secretDraft[rowKey];
|
|
1235
1521
|
const fromEnv = Boolean(info && info.source === 'env');
|
|
1236
1522
|
|
|
@@ -1261,7 +1547,7 @@ window.__ModuleLoader__.load({
|
|
|
1261
1547
|
placeholder: info && info.tail ? '••••' + info.tail : t('keyValuePlaceholder'),
|
|
1262
1548
|
onChange: (e) => setSecretDraft((cur) => ({ ...cur, [rowKey]: e.target.value })),
|
|
1263
1549
|
}));
|
|
1264
|
-
if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: '
|
|
1550
|
+
if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'save-secret' }));
|
|
1265
1551
|
}
|
|
1266
1552
|
if (info && typeof info.usage === 'number' && info.usage > 0) {
|
|
1267
1553
|
let tip = 'requests through this key';
|
|
@@ -1318,7 +1604,7 @@ window.__ModuleLoader__.load({
|
|
|
1318
1604
|
btn('✕', () => removeKey(pIndex, kIndex), { title: t('removeKey') }),
|
|
1319
1605
|
));
|
|
1320
1606
|
|
|
1321
|
-
return h('div', { key
|
|
1607
|
+
return h('div', { key, className: 'krot-key' },
|
|
1322
1608
|
nameRow,
|
|
1323
1609
|
h('div', { className: 'krot-meta' }, meta),
|
|
1324
1610
|
);
|
|
@@ -1440,8 +1726,9 @@ window.__ModuleLoader__.load({
|
|
|
1440
1726
|
setField((cur) => {
|
|
1441
1727
|
const next = [...(cur.providers ?? [])];
|
|
1442
1728
|
if (!next[pIndex]) return cur;
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1729
|
+
const entry = next[pIndex];
|
|
1730
|
+
const order = entry.keys.map((_, index) => index).sort((a, b) => usageOf(entry.keys[b]) - usageOf(entry.keys[a]));
|
|
1731
|
+
next[pIndex] = reorderKeys(entry, order);
|
|
1445
1732
|
return { ...cur, providers: next };
|
|
1446
1733
|
});
|
|
1447
1734
|
}, { title: t('sortUsage') }),
|
|
@@ -1485,7 +1772,7 @@ window.__ModuleLoader__.load({
|
|
|
1485
1772
|
h('div', { className: 'krot-keys' }, keyRows),
|
|
1486
1773
|
h(UpdaterSection, { t }),
|
|
1487
1774
|
h('div', { className: 'krot-foot' },
|
|
1488
|
-
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
1775
|
+
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle'), disabled: !entry.provider.trim() }),
|
|
1489
1776
|
switchesLine,
|
|
1490
1777
|
budgetLine,
|
|
1491
1778
|
sloLine,
|
|
@@ -1599,10 +1886,10 @@ window.__ModuleLoader__.load({
|
|
|
1599
1886
|
});
|
|
1600
1887
|
}, { disabled: selected.size === 0 || !bulkCooldown }),
|
|
1601
1888
|
btn(t('bulkRemove'), () => {
|
|
1602
|
-
const ids =
|
|
1603
|
-
if (!ids.
|
|
1889
|
+
const ids = new Set(selected);
|
|
1890
|
+
if (!ids.size) return;
|
|
1604
1891
|
setConfirmModal({
|
|
1605
|
-
title: t('confirmBulkRemoveTitle').replace('{n}', String(ids.
|
|
1892
|
+
title: t('confirmBulkRemoveTitle').replace('{n}', String(ids.size)),
|
|
1606
1893
|
desc: t('confirmBulkRemoveDesc'),
|
|
1607
1894
|
actionLabel: t('bulkRemove'),
|
|
1608
1895
|
danger: true,
|
|
@@ -1661,11 +1948,11 @@ window.__ModuleLoader__.load({
|
|
|
1661
1948
|
field(t('selfHealingLabel'), textInput(String(val.selfHealingIntervalMinutes ?? 30), (v) => setField((cur) => ({ ...cur, selfHealingIntervalMinutes: Number(v) || 0 })))),
|
|
1662
1949
|
field(t('proactiveGuardLabel'), h('label', { style: { display: 'flex', alignItems: 'center', gap: '8px', height: '36px', cursor: 'pointer' } }, [
|
|
1663
1950
|
h('input', {
|
|
1664
|
-
type: 'checkbox',
|
|
1951
|
+
key: 'input', type: 'checkbox',
|
|
1665
1952
|
checked: val.proactiveRateLimitGuard ?? true,
|
|
1666
1953
|
onChange: (e) => setField((cur) => ({ ...cur, proactiveRateLimitGuard: e.target.checked })),
|
|
1667
1954
|
}),
|
|
1668
|
-
h('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('proactiveGuardLabel')),
|
|
1955
|
+
h('span', { key: 'label', style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } }, t('proactiveGuardLabel')),
|
|
1669
1956
|
]))
|
|
1670
1957
|
)
|
|
1671
1958
|
);
|
|
@@ -1683,15 +1970,15 @@ window.__ModuleLoader__.load({
|
|
|
1683
1970
|
const a = document.createElement('a'); a.href = url; a.download = 'pools.json'; a.click(); URL.revokeObjectURL(url);
|
|
1684
1971
|
}, {}),
|
|
1685
1972
|
h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('importPools'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
|
|
1686
|
-
const
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1973
|
+
const file = e.target.files[0]; e.target.value = '';
|
|
1974
|
+
readImport(file, (imported) => {
|
|
1975
|
+
validatePoolDraft(imported);
|
|
1976
|
+
setField((cur) => {
|
|
1977
|
+
const map = new Map((cur.providers ?? []).map((p) => [p.provider, p]));
|
|
1978
|
+
for (const p of imported) map.set(p.provider, p);
|
|
1979
|
+
return { ...cur, providers: [...map.values()] };
|
|
1980
|
+
}, true);
|
|
1981
|
+
});
|
|
1695
1982
|
} })),
|
|
1696
1983
|
btn(t('snapshotExport'), () => {
|
|
1697
1984
|
fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' } })
|
|
@@ -1704,19 +1991,16 @@ window.__ModuleLoader__.load({
|
|
|
1704
1991
|
.catch((e) => setSecretError(String(e?.message ?? e)));
|
|
1705
1992
|
}, {}),
|
|
1706
1993
|
h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('snapshotImport'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
|
|
1707
|
-
const
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
} catch (err) { setSecretError(String(err.message || err)); }
|
|
1718
|
-
};
|
|
1719
|
-
reader2.readAsText(f);
|
|
1994
|
+
const file = e.target.files[0]; e.target.value = '';
|
|
1995
|
+
readImport(file, (imported) => {
|
|
1996
|
+
const snap = imported?.snapshot ?? imported;
|
|
1997
|
+
if (!snap || typeof snap !== 'object' || Array.isArray(snap)) throw new Error('Expected a settings snapshot');
|
|
1998
|
+
if (Object.prototype.hasOwnProperty.call(snap, 'providers')) validatePoolDraft(snap.providers);
|
|
1999
|
+
const patch = { ...snap };
|
|
2000
|
+
// Exported empty secret fields mean "not included", not "erase".
|
|
2001
|
+
if (!patch.webhookActionToken) delete patch.webhookActionToken;
|
|
2002
|
+
setField((cur) => ({ ...cur, ...patch }), true);
|
|
2003
|
+
});
|
|
1720
2004
|
} }))
|
|
1721
2005
|
),
|
|
1722
2006
|
h('p', { className: 'krot-hint', style: { marginTop: '4px' } }, t('keyHint'))
|
|
@@ -1724,7 +2008,7 @@ window.__ModuleLoader__.load({
|
|
|
1724
2008
|
|
|
1725
2009
|
const alerts = [
|
|
1726
2010
|
secretError ? h('div', { key: 'se', className: 'krot-alert-bad' }, secretError) : null,
|
|
1727
|
-
state.error ? h('div', { key: 'st', className: 'krot-alert-bad' }, state.error) : null,
|
|
2011
|
+
state.error ? h('div', { key: 'st', role: 'alert', className: 'krot-alert-bad' }, state.error) : null,
|
|
1728
2012
|
undo ? h('div', { key: 'un', className: 'krot-alert-ok' },
|
|
1729
2013
|
h('span', null, undo.type === 'provider' ? t('undoProvider') : t('undoKey')),
|
|
1730
2014
|
btn(t('undo'), doUndo, { className: 'krot-btn-sm' })
|
|
@@ -1773,19 +2057,28 @@ window.__ModuleLoader__.load({
|
|
|
1773
2057
|
h('div', { className: 'krot-page-sub' }, t('header.sub'))
|
|
1774
2058
|
);
|
|
1775
2059
|
|
|
1776
|
-
return h('div', { className: 'krot' },
|
|
2060
|
+
return h('div', { className: 'krot', 'aria-busy': saving || importing },
|
|
1777
2061
|
confirmModalEl,
|
|
1778
2062
|
headerSection,
|
|
1779
2063
|
statsSection,
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
2064
|
+
h('fieldset', { disabled: saving || importing || !state.writable,
|
|
2065
|
+
style: { border: 0, padding: 0, margin: 0, minWidth: 0, display: 'flex', flexDirection: 'column', gap: '14px' } },
|
|
2066
|
+
poolsSection,
|
|
2067
|
+
failoverSection,
|
|
2068
|
+
timingSection,
|
|
2069
|
+
backupSection,
|
|
2070
|
+
),
|
|
1784
2071
|
alerts.length > 0 ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } }, alerts) : null,
|
|
1785
2072
|
h('div', { className: 'krot-foot', style: { marginTop: '6px', paddingTop: '14px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
1786
|
-
btn(t('save'), save, { primary: true }),
|
|
1787
|
-
btn(t('discard'),
|
|
1788
|
-
|
|
2073
|
+
btn(t('save'), save, { primary: true, disabled: saving || importing || !state.writable || !draft }),
|
|
2074
|
+
btn(t('discard'), () => {
|
|
2075
|
+
if (saveInFlightRef.current || importRef.current) return;
|
|
2076
|
+
clearDraft();
|
|
2077
|
+
setSecretError('');
|
|
2078
|
+
void load();
|
|
2079
|
+
}, { disabled: saving || importing }),
|
|
2080
|
+
saving || importing ? h('span', { className: 'krot-hint' }, t(saving ? 'saving' : 'loading')) : null,
|
|
2081
|
+
!state.writable ? h('span', { className: 'krot-hint' }, t('readOnly')) : null,
|
|
1789
2082
|
)
|
|
1790
2083
|
);
|
|
1791
2084
|
}
|
|
@@ -1867,6 +2160,7 @@ window.__ModuleLoader__.load({
|
|
|
1867
2160
|
}
|
|
1868
2161
|
|
|
1869
2162
|
function apply(ctx) {
|
|
2163
|
+
const settingsSource = createSettingsSource(ctx);
|
|
1870
2164
|
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-key-rotation: dictionaries');
|
|
1871
2165
|
// Header chip (#201): status dot in the session header utilities slot,
|
|
1872
2166
|
// same slot dsh-gitea / dsh-subscriptions use for their header widgets.
|
|
@@ -1908,35 +2202,24 @@ window.__ModuleLoader__.load({
|
|
|
1908
2202
|
h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' })))),
|
|
1909
2203
|
(page || open) ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
|
|
1910
2204
|
}
|
|
1911
|
-
//
|
|
1912
|
-
//
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
ctx.slots.register(
|
|
1920
|
-
{ name
|
|
1921
|
-
KeyRotationCard,
|
|
1922
|
-
));
|
|
1923
|
-
// Row seat and the legacy seat stay as fallbacks.
|
|
1924
|
-
ctx.slots.inject('plugins.row.config', () =>
|
|
1925
|
-
ctx.slots.register(
|
|
1926
|
-
{ name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, inject: () => ({ ctx }) },
|
|
1927
|
-
KeyRotationCard,
|
|
1928
|
-
));
|
|
1929
|
-
ctx.slots.inject('settings.plugin.item', () =>
|
|
1930
|
-
ctx.slots.register(
|
|
1931
|
-
{ name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
|
|
1932
|
-
KeyRotationCard,
|
|
2205
|
+
// Register each available seat independently. A missing legacy seat must
|
|
2206
|
+
// not prevent the current bundle or row seat from registering.
|
|
2207
|
+
for (const [name, key] of [
|
|
2208
|
+
['plugins.bundle.config', PKG],
|
|
2209
|
+
['plugins.row.config', ROW_CONFIG_KEY],
|
|
2210
|
+
['settings.plugin.item', NS],
|
|
2211
|
+
]) {
|
|
2212
|
+
try {
|
|
2213
|
+
ctx.slots.inject(name, () => ctx.slots.register(
|
|
2214
|
+
{ name, key, locale: NS, inject: () => ({ ctx, settingsSource }) }, KeyRotationCard,
|
|
1933
2215
|
));
|
|
1934
|
-
|
|
1935
|
-
|
|
2216
|
+
} catch (error) {
|
|
2217
|
+
bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings seat unavailable', name, error); });
|
|
2218
|
+
}
|
|
1936
2219
|
}
|
|
1937
2220
|
}
|
|
1938
2221
|
|
|
1939
|
-
module.exports = { apply, inject: ['slots', 'locale'
|
|
2222
|
+
module.exports = { apply, inject: ['slots', 'locale'] };
|
|
1940
2223
|
return module.exports;
|
|
1941
2224
|
},
|
|
1942
2225
|
updateCheck: 'Check for updates',
|