@goodandready/dsh-key-rotation 0.8.76 → 0.8.78

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.
@@ -0,0 +1,55 @@
1
+ /** Poll rotation status while the settings section is open (smart polling). */
2
+ function useRotationStatus() {
3
+ const [byProvider, setByProvider] = React.useState({});
4
+ React.useEffect(() => {
5
+ let alive = true;
6
+ const pull = () => {
7
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
8
+ fetch('/dsh-key-rotation/status', { headers: { accept: 'application/json' }, signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
9
+ .then((r) => (r.ok ? r.json() : null))
10
+ .then((data) => {
11
+ if (!alive || !data || !Array.isArray(data.providers)) return;
12
+ const map = {};
13
+ for (const entry of data.providers) map[entry.provider] = entry;
14
+ setByProvider(map);
15
+ })
16
+ .catch(() => { /* status is optional: card stays a working editor */ });
17
+ };
18
+ pull();
19
+ const id = setInterval(pull, 4000);
20
+ const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') pull(); };
21
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
22
+ return () => {
23
+ alive = false;
24
+ clearInterval(id);
25
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
26
+ };
27
+ }, []);
28
+ return byProvider;
29
+ }
30
+
31
+ /** Latest probe result per key (#219): /sandbox-cache (smart polling). */
32
+ function useProbeCache() {
33
+ const [cache, setCache] = React.useState({});
34
+ React.useEffect(() => {
35
+ let alive = true;
36
+ const pull = () => {
37
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
38
+ fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' }, signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
39
+ .then((r) => (r.ok ? r.json() : null))
40
+ .then((data) => { if (alive && data) setCache(data); })
41
+ .catch(() => { /* cache is non-critical: card works without it */ });
42
+ };
43
+ pull();
44
+ const id = setInterval(pull, 4000);
45
+ const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') pull(); };
46
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
47
+ return () => {
48
+ alive = false;
49
+ clearInterval(id);
50
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
51
+ };
52
+ }, []);
53
+ return cache;
54
+ }
55
+
@@ -0,0 +1,185 @@
1
+ // formatAgo moved to lib/client-helpers.js for testability — keep local alias for bundle self-containment
2
+ function formatAgo(t, at) {
3
+ if (!at) return '';
4
+ const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
5
+ if (sec < 60) return t('justNow');
6
+ if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
7
+ return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
8
+ }
9
+
10
+ // Card layout uses a grid, not ad-hoc inline widths. Fixed widths used to
11
+ // clip key names and collide actions with the value field, so the name owns
12
+ // its own row and the meta row shrinks on its own.
13
+ const CARD_CSS = [
14
+ '.krot{display:flex;flex-direction:column;gap:20px;max-width:960px;padding:6px 0 24px;box-sizing:border-box}',
15
+ '.krot-header{display:flex;flex-direction:column;gap:8px;padding-bottom:16px;border-bottom:1px solid var(--dsw-alias-border-l2)}',
16
+ '.krot-page-title{font-size:20px;font-weight:700;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
17
+ '.krot-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}',
18
+ '.krot p{margin:0}',
19
+ '.krot-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}',
20
+ '.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);display:flex;align-items:center;gap:8px}',
21
+ '.krot-label{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}',
22
+ '.krot-field{display:flex;flex-direction:column;gap:6px}',
23
+ '.krot-in{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;height:36px;font-family:inherit;min-width:0;width:100%;box-sizing:border-box;transition:border-color .15s ease}',
24
+ '.krot-in:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}',
25
+ '.krot-codes{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px 14px}',
26
+ '.krot-code{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--dsw-alias-label-primary);cursor:pointer;user-select:none}',
27
+ '.krot-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:14px}',
28
+ '.krot-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between;gap:8px}',
29
+ '.krot-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-6px;line-height:1.4}',
30
+ '.krot-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}',
31
+ '.krot-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}',
32
+ '.krot-grid-3{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}',
33
+ '.krot-stat-box{padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:4px}',
34
+ '.krot-stat-val{font-size:20px;font-weight:700;color:var(--dsw-alias-label-primary)}',
35
+ '.krot-stat-lbl{font-size:12px;color:var(--dsw-alias-label-secondary)}',
36
+ '.krot-load-chart{margin:10px 0;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}',
37
+ '.krot-load-header{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--dsw-alias-label-secondary);margin-bottom:6px}',
38
+ '.krot-load-bar{display:flex;height:12px;border-radius:999px;overflow:hidden;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1)}',
39
+ '.krot-load-segment{height:100%;transition:width 0.2s ease}',
40
+ '.krot-load-legend{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;font-size:11px}',
41
+ '.krot-load-item{display:inline-flex;align-items:center;gap:4px}',
42
+ '.krot-load-dot{width:7px;height:7px;border-radius:50%}',
43
+ '.krot-modal-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--dsw-alias-bg-overlay, color-mix(in srgb, var(--dsw-alias-bg-layer-1) 55%, transparent));z-index:9999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}',
44
+ '.krot-modal-card{width:90%;max-width:420px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 12px 36px color-mix(in srgb, var(--dsw-alias-bg-layer-1) 28%, transparent);padding:20px;display:flex;flex-direction:column;gap:12px}',
45
+ '.krot-modal-title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
46
+ '.krot-modal-desc{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.45}',
47
+ '.krot-modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:8px}',
48
+ '.krot-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500;white-space:nowrap}',
49
+ '.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}',
50
+ '.krot-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 8%, transparent)}',
51
+ '.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}',
52
+ '.krot-prov{display:flex;flex-direction:column;gap:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:14px 16px}',
53
+ '.krot-prov-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
54
+ '.krot-prov-head select{flex:1;min-width:180px}',
55
+ '.krot-keys{display:flex;flex-direction:column;gap:8px}',
56
+ '.krot-key{display:grid;grid-template-columns:22px minmax(0,1fr);gap:6px 10px;align-items:center;padding:8px 12px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
57
+ '.krot-num{font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);text-align:right}',
58
+ '.krot-name{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary);cursor:default}',
59
+ '.krot-meta{grid-column:2;display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
60
+ '.krot-dot{width:8px;height:8px;border-radius:50%;flex:none}',
61
+ '.krot-state{font-size:12px;color:var(--dsw-alias-label-secondary);font-weight:500}',
62
+ '.krot-tail{font-size:11px;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,Menlo,Consolas,monospace;background:var(--dsw-alias-bg-layer-1);padding:2px 6px;border-radius:4px;border:1px solid var(--dsw-alias-border-l2)}',
63
+ '.krot-secret{flex:1;min-width:120px;max-width:220px}',
64
+ '.krot-acts{display:flex;gap:4px;margin-left:auto;flex:none}',
65
+ '.krot-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 14px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}',
66
+ '.krot-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4,var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed,var(--dsw-alias-border-l2))}',
67
+ '.krot-btn-primary,.krot-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent;font-weight:600}',
68
+ '.krot-btn-primary:hover:not(:disabled),.krot-save:hover:not(:disabled){background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;opacity:0.88}',
69
+ '.krot-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}',
70
+ '.krot-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)!important;border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}',
71
+ '.krot-btn:disabled{opacity:0.45;cursor:not-allowed}',
72
+ '.krot-foot{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
73
+ '.krot-filter-bar{display:flex;gap:8px;margin:4px 0 10px;flex-wrap:wrap}',
74
+ '.krot-pill{appearance:none;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:4px 12px;font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:all .15s ease}',
75
+ '.krot-pill:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}',
76
+ '.krot-pill-active{background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)!important;font-weight:600}',
77
+ '.krot-pill-warn{border-color:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 30%, transparent);color:var(--dsw-alias-state-warn-primary)}',
78
+ '.krot-pill-err{border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent);color:var(--dsw-alias-state-error-primary)}',
79
+ '.krot-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
80
+ '.krot-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
81
+ '.krot-event-stream{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
82
+ // Per-model token quota editor + runtime meter
83
+ '.krot-models{margin-top:14px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:12px}',
84
+ '.krot-models-title{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:4px}',
85
+ '.krot-models-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4;margin:0 0 10px}',
86
+ '.krot-model{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;margin-bottom:10px;background:var(--dsw-alias-bg-layer-2)}',
87
+ '.krot-model-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px}',
88
+ '.krot-model-tag{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}',
89
+ '.krot-limit{width:110px;font-variant-numeric:tabular-nums;text-align:right}',
90
+ '.krot-qbar{display:inline-block;width:64px;height:6px;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden;vertical-align:middle}',
91
+ '.krot-qbar-fill{display:block;height:100%;transition:width .2s ease}',
92
+ '.krot-qmeta{font-size:11px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
93
+ '.krot-q-exhausted{font-size:11px;font-weight:600;color:var(--dsw-alias-state-error-primary)}',
94
+ '.krot-q-unlimited{font-size:11px;color:var(--dsw-alias-label-tertiary);font-style:italic}',
95
+ '.krot-event-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}',
96
+ '.krot-event-time{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dsw-alias-label-tertiary);font-size:11px}',
97
+ ':root{--krot-chart-4:var(--dsw-alias-brand-primary);--krot-chart-5:var(--dsw-alias-state-error-primary);--krot-chart-6:var(--dsw-alias-state-success-primary)}',
98
+ '.krot-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
99
+ '.krot-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
100
+ '.krot-card-head-text{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}',
101
+ '.krot-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
102
+ '.krot-card-description{color:var(--dsw-alias-label-secondary);font-size:13px}',
103
+ '.krot-card-chevron{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s ease;display:flex;align-items:center}.krot-card-chevron-open{transform:rotate(180deg)}',
104
+ '.krot-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:16px 0 8px}',
105
+ '.krot-header-chip{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;cursor:pointer;position:relative;user-select:none;transition:all .15s ease}',
106
+ '.krot-header-chip:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-3));border-color:var(--dsw-alias-border-l1);transform:translateY(-0.5px)}',
107
+ '.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:220px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 14px;box-shadow:0 16px 40px color-mix(in srgb, var(--dsw-alias-bg-layer-1) 45%, transparent),0 2px 8px color-mix(in srgb, var(--dsw-alias-bg-layer-1) 15%, transparent);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;flex-direction:column;gap:8px;text-align:left}',
108
+ '.krot-pop-title{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}',
109
+ '.krot-pop-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:4px 0}',
110
+ '.krot-pop-name{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:8px}',
111
+ '.krot-pop-count{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;opacity:.9}',
112
+ ].join('');
113
+ const CARD_CSS_ID = 'dsh-key-rotation/section.module.css';
114
+ if (typeof document !== 'undefined' && !document.getElementById('dsh-key-rotation-full-css')) {
115
+ const tag = document.createElement('style');
116
+ tag.id = 'dsh-key-rotation-full-css';
117
+ tag.dataset.dshPlugin = NS;
118
+ tag.dataset.pluginCss = CARD_CSS_ID;
119
+ tag.setAttribute('data-plugin', NS);
120
+ tag.textContent = CARD_CSS;
121
+ document.head.appendChild(tag);
122
+ }
123
+
124
+ /**
125
+ * Env-var name for a newly added key.
126
+ *
127
+ * Users no longer type it: the first key of a provider becomes
128
+ * <PROVIDER>_API_KEY, later keys reuse that root with _2, _3… suffixes.
129
+ * The root is taken from existing keys so hand-made names keep working,
130
+ * and uniqueness is checked across ALL providers — otherwise two providers
131
+ * would silently share one credential.
132
+ */
133
+ // nextKeyRef also in lib/client-helpers.js
134
+ function nextKeyRef(providerId, existingKeys, allRefs) {
135
+ const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
136
+ const base = fromExisting
137
+ ? fromExisting.replace(/_\d+$/, '')
138
+ : String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
139
+ const taken = new Set(allRefs);
140
+ if (!taken.has(base)) return base;
141
+ for (let n = 2; n < 1000; n++) {
142
+ const candidate = base + '_' + n;
143
+ if (!taken.has(candidate)) return candidate;
144
+ }
145
+ return base + '_' + Date.now();
146
+ }
147
+
148
+ function useActiveLocale(ctx) {
149
+ return React.useSyncExternalStore(
150
+ React.useMemo(() => (cb) => (ctx && ctx.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
151
+ React.useCallback(() => {
152
+ if (ctx && ctx.locale) {
153
+ const active = ctx.locale.getSnapshot().active;
154
+ if (typeof active === 'string' && active) return active;
155
+ }
156
+ // Match DSH core: first entry of navigator.languages, else en.
157
+ if (typeof navigator !== 'undefined') {
158
+ const langs = (navigator.languages && navigator.languages.length)
159
+ ? Array.from(navigator.languages)
160
+ : (navigator.language ? [navigator.language] : []);
161
+ for (const lang of langs) {
162
+ const code = String(lang || '').slice(0, 2).toLowerCase();
163
+ if (code) return code;
164
+ }
165
+ }
166
+ return 'en';
167
+ }, [ctx])
168
+ );
169
+ }
170
+
171
+ function makeT(DICT, fallbackKeys) {
172
+ return (key) => (DICT && DICT[key]) || (fallbackKeys && fallbackKeys[key]) || key;
173
+ }
174
+
175
+ // Prefer the host slot translator (props.t + translation plugins); fall back to en source.
176
+ function resolveT(props) {
177
+ const coreT = props && typeof props.t === 'function' ? props.t : null;
178
+ if (!coreT) return makeT(en, en);
179
+ return (key) => {
180
+ const v = coreT(key);
181
+ return (v && v !== key) ? v : (en[key] || key);
182
+ };
183
+ }
184
+
185
+
@@ -0,0 +1,346 @@
1
+ function Sparkline({ points = [], width = 120, height = 22, stroke = 'var(--dsw-alias-brand-primary, currentColor)' }) {
2
+ if (!Array.isArray(points) || points.length < 2) {
3
+ return h('svg', { width, height, viewBox: `0 0 ${width} ${height}`, style: { opacity: 0.3 } },
4
+ h('line', { x1: 0, y1: height / 2, x2: width, y2: height / 2, stroke, strokeWidth: 1, strokeDasharray: '3 3' })
5
+ );
6
+ }
7
+ const min = Math.min(...points);
8
+ const max = Math.max(...points);
9
+ const range = max - min || 1;
10
+ const step = width / (points.length - 1);
11
+ const coords = points.map((p, i) => {
12
+ const x = Math.round(i * step * 10) / 10;
13
+ const y = Math.round((height - ((p - min) / range) * (height - 4) - 2) * 10) / 10;
14
+ return `${x},${y}`;
15
+ }).join(' ');
16
+
17
+ return h('svg', {
18
+ width,
19
+ height,
20
+ viewBox: `0 0 ${width} ${height}`,
21
+ style: { display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' },
22
+ }, h('polyline', {
23
+ points: coords,
24
+ fill: 'none',
25
+ stroke,
26
+ strokeWidth: 2,
27
+ strokeLinecap: 'round',
28
+ strokeLinejoin: 'round',
29
+ }));
30
+ }
31
+
32
+ function parseBulkKeys(text) {
33
+ if (!text || typeof text !== 'string') return [];
34
+ const lines = text.split(/\r?\n/);
35
+ const result = [];
36
+ const seen = new Set();
37
+ for (let raw of lines) {
38
+ const line = raw.trim();
39
+ if (!line || line.startsWith('#') || line.startsWith('//')) continue;
40
+ const envMatch = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
41
+ if (envMatch) {
42
+ const ref = envMatch[1];
43
+ if (!seen.has(ref)) {
44
+ seen.add(ref);
45
+ let weight = 1;
46
+ const rest = envMatch[2].trim().replace(/^['"]|['"]$/g, '');
47
+ const wMatch = rest.match(/,\s*(?:weight\s*=\s*)?([0-9]+)$/i);
48
+ if (wMatch) weight = Math.max(1, parseInt(wMatch[1], 10) || 1);
49
+ result.push({ ref, weight });
50
+ }
51
+ continue;
52
+ }
53
+ const parts = line.split(',');
54
+ const ref = parts[0].trim();
55
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref) && !seen.has(ref)) {
56
+ seen.add(ref);
57
+ let weight = 1;
58
+ if (parts[1]) {
59
+ const wm = parts[1].trim().match(/(?:weight\s*=\s*)?([0-9]+)/i);
60
+ if (wm) weight = Math.max(1, parseInt(wm[1], 10) || 1);
61
+ }
62
+ result.push({ ref, weight });
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+
68
+ function UpdaterSection({ t }) {
69
+ const tRef = React.useRef(t);
70
+ tRef.current = t;
71
+ const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
72
+ const load = React.useCallback(async () => {
73
+ setState((s) => ({ ...s, phase: 'loading', message: '' }));
74
+ try {
75
+ const res = await fetch('/api/dsh-key-rotation/update', { headers: { 'x-dsh-plugin-update': '1' } });
76
+ const data = await res.json();
77
+ if (!res.ok) throw new Error(data?.error?.message || (tRef.current ? tRef.current('updateFailed') : 'Update check failed'));
78
+ setState({
79
+ phase: data.updateAvailable ? 'available' : 'current',
80
+ current: data.currentVersion || '',
81
+ latest: data.latestVersion || '',
82
+ message: data.updateAvailable ? (tRef.current ? tRef.current('updateAvailable') : '') : (tRef.current ? tRef.current('updateNone') : ''),
83
+ });
84
+ } catch (e) {
85
+ setState({ phase: 'error', current: '', latest: '', message: e?.message || (tRef.current ? tRef.current('updateFailed') : 'Update check failed') });
86
+ }
87
+ }, []);
88
+ React.useEffect(() => { load(); }, [load]);
89
+ const run = async () => {
90
+ setState((s) => ({ ...s, phase: 'running', message: t('updateRunning') }));
91
+ try {
92
+ const res = await fetch('/api/dsh-key-rotation/update', {
93
+ method: 'POST',
94
+ headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
95
+ });
96
+ const data = await res.json();
97
+ if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
98
+ setState({
99
+ phase: 'done',
100
+ current: data.currentVersion || '',
101
+ latest: data.installedVersion || data.latestVersion || '',
102
+ message: t('updateRestart'),
103
+ });
104
+ } catch (e) {
105
+ setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
106
+ }
107
+ };
108
+ return h('div', { className: 'krot-update', style: { marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
109
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
110
+ h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
111
+ t('currentVersion') + ': ' + (state.current || '—')),
112
+ state.latest ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
113
+ t('latestVersion') + ': ' + state.latest) : null,
114
+ h('button', {
115
+ className: 'krot-btn',
116
+ type: 'button',
117
+ disabled: state.phase === 'running' || state.phase === 'loading',
118
+ onClick: load,
119
+ }, t('updateCheck')),
120
+ state.phase === 'available' ? h('button', {
121
+ className: 'krot-btn krot-btn-primary',
122
+ type: 'button',
123
+ disabled: state.phase === 'running',
124
+ onClick: run,
125
+ }, t('updateRun')) : null,
126
+ ),
127
+ state.message ? h('div', {
128
+ className: state.phase === 'error' ? 'krot-alert-bad' : state.phase === 'done' ? 'krot-alert-ok' : '',
129
+ style: { marginTop: 8, fontSize: 12 },
130
+ }, state.message) : null,
131
+ );
132
+ }
133
+
134
+ // Provider registration is not part of settingsScope's value. Fetch it
135
+ // independently, and never turn an unavailable catalog into proof of absence.
136
+ function useProviderCatalog(enabled = true) {
137
+ const [catalog, setCatalog] = React.useState({ status: 'loading', providers: [] });
138
+ const requestRef = React.useRef(null);
139
+ const reload = React.useCallback(() => {
140
+ requestRef.current?.abort();
141
+ if (!enabled) {
142
+ setCatalog({ status: 'unavailable', providers: [] });
143
+ return Promise.resolve();
144
+ }
145
+ const controller = new AbortController();
146
+ requestRef.current = controller;
147
+ setCatalog((s) => ({ ...s, status: 'loading' }));
148
+ return fetch(CONFIG_PATH, { headers: { accept: 'application/json' }, signal: controller.signal })
149
+ .then(async (response) => {
150
+ if (!response.ok) throw new Error('provider catalog unavailable');
151
+ const data = await response.json();
152
+ if (!Array.isArray(data?.providers) || !data.providers.every((p) =>
153
+ p && typeof p.id === 'string' && p.id.length > 0)) {
154
+ throw new Error('invalid provider catalog');
155
+ }
156
+ if (controller.signal.aborted || requestRef.current !== controller) return;
157
+ setCatalog({ status: 'ready', providers: data.providers.map((p) => ({
158
+ id: p.id, name: typeof p.name === 'string' ? p.name : p.id,
159
+ })) });
160
+ })
161
+ .catch(() => {
162
+ if (!controller.signal.aborted && requestRef.current === controller) {
163
+ setCatalog((s) => ({ ...s, status: 'error' }));
164
+ }
165
+ });
166
+ }, [enabled]);
167
+ React.useEffect(() => {
168
+ reload();
169
+ // A provider may have been installed/enabled while this page was open.
170
+ window.addEventListener?.('focus', reload);
171
+ return () => {
172
+ requestRef.current?.abort();
173
+ window.removeEventListener?.('focus', reload);
174
+ };
175
+ }, [reload]);
176
+ return { ...catalog, reload };
177
+ }
178
+
179
+ // Services from different core generations are optional at the plugin boundary.
180
+ // One binding belongs to one Cordis activation, not to each React page mount.
181
+ function createSettingsSource(ctx) {
182
+ const listeners = new Set();
183
+ const candidates = new Map();
184
+ let value = { kind: null, service: null };
185
+ const publish = () => {
186
+ const kind = candidates.has('configForm') ? 'configForm'
187
+ : candidates.has('settingsScope') ? 'settingsScope' : null;
188
+ const service = candidates.get(kind) ?? null;
189
+ if (value.kind === kind && value.service === service) return;
190
+ value = { kind, service };
191
+ for (const listener of [...listeners]) listener();
192
+ };
193
+ if (typeof ctx?.inject === 'function') {
194
+ const watch = (name, kind, resolve) => {
195
+ ctx.inject([name], (owner) => {
196
+ let service;
197
+ try { service = resolve(owner.get(name)); } catch (_) { return; }
198
+ if (!service || typeof service.getSnapshot !== 'function'
199
+ || typeof service.subscribe !== 'function') return;
200
+ candidates.set(kind, service);
201
+ publish();
202
+ owner.effect(() => () => {
203
+ if (candidates.get(kind) === service) candidates.delete(kind);
204
+ publish();
205
+ });
206
+ });
207
+ };
208
+ watch('configForms', 'configForm', (forms) => forms?.get?.(NS));
209
+ watch('settingsScope', 'settingsScope', (binder) => binder?.bind?.({ namespace: NS }));
210
+ if (typeof ctx.effect === 'function') {
211
+ ctx.effect(() => () => { candidates.clear(); publish(); listeners.clear(); });
212
+ }
213
+ }
214
+ return {
215
+ getSnapshot: () => value,
216
+ subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
217
+ };
218
+ }
219
+
220
+ function sameJson(a, b) {
221
+ if (Object.is(a, b)) return true;
222
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object'
223
+ || Array.isArray(a) !== Array.isArray(b)) return false;
224
+ const keys = Object.keys(a);
225
+ return keys.length === Object.keys(b).length
226
+ && keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameJson(a[key], b[key]));
227
+ }
228
+
229
+ // A redacted, resolved snapshot is not a complete user section. Submit only
230
+ // changed top-level fields; arrays are intentionally edited atomically.
231
+ function changedSettingsOps(base, next) {
232
+ const keys = new Set([...Object.keys(base), ...Object.keys(next)]);
233
+ return [...keys].filter((key) => !sameJson(base[key], next[key])).map((key) =>
234
+ next[key] === undefined ? { op: 'unset', path: [key] }
235
+ : { op: 'set', path: [key], value: next[key] });
236
+ }
237
+
238
+ function requireJson(value, depth = 0) {
239
+ if (depth > 40) throw new Error('Configuration is nested too deeply');
240
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
241
+ if (typeof value === 'number' && Number.isFinite(value)) return;
242
+ if (Array.isArray(value)) { value.forEach(child => requireJson(child, depth + 1)); return; }
243
+ if (value && typeof value === 'object') {
244
+ for (const [key, child] of Object.entries(value)) {
245
+ if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('Unsafe configuration field');
246
+ if (child !== undefined) requireJson(child, depth + 1);
247
+ }
248
+ return;
249
+ }
250
+ throw new Error('Configuration must contain finite, JSON-compatible values');
251
+ }
252
+
253
+
254
+ function reorderKeys(entry, order) {
255
+ const next = { ...entry, keys: order.map(index => entry.keys[index]) };
256
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0], ['paused', false], ['revoked', false]]) {
257
+ if (Array.isArray(entry[field]) && entry[field].length) next[field] = order.map(index => entry[field][index] ?? fallback);
258
+ }
259
+ return next;
260
+ }
261
+
262
+ function formatCountdown(ms) {
263
+ if (!Number.isFinite(ms) || ms <= 0) return '00:00:00';
264
+ const total = Math.floor(ms / 1000);
265
+ const h = Math.floor(total / 3600);
266
+ const m = Math.floor((total % 3600) / 60);
267
+ const s = total % 60;
268
+ const pad = (n) => String(n).padStart(2, '0');
269
+ return pad(h) + ':' + pad(m) + ':' + pad(s);
270
+ }
271
+
272
+ function quotaMapOf(modelEntry) {
273
+ const out = {};
274
+ const raw = (modelEntry && typeof modelEntry === 'object' && modelEntry.quotas) || {};
275
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
276
+ for (const [ref, value] of Object.entries(raw)) {
277
+ if (['__proto__', 'constructor', 'prototype'].includes(ref)) continue;
278
+ const n = typeof value === 'object' && value !== null ? value.tokenLimit : value;
279
+ const num = typeof n === 'string' ? Number(n.trim() === '' ? NaN : n) : Number(n);
280
+ if (Number.isFinite(num) && num > 0) out[ref] = { tokenLimit: Math.floor(num) };
281
+ }
282
+ return out;
283
+ }
284
+
285
+ function withQuotas(modelEntry, limits) {
286
+ const quotas = {};
287
+ for (const [ref, value] of Object.entries(limits ?? {})) {
288
+ const n = typeof value === 'object' && value !== null ? value.tokenLimit : value;
289
+ const num = typeof n === 'string' ? Number(n.trim() === '' ? NaN : n) : Number(n);
290
+ if (!Number.isFinite(num) || num <= 0) continue;
291
+ quotas[ref] = { tokenLimit: Math.floor(num) };
292
+ }
293
+ const next = { ...modelEntry };
294
+ if (Object.keys(quotas).length === 0) delete next.quotas;
295
+ else next.quotas = quotas;
296
+ return next;
297
+ }
298
+
299
+ function modelEntriesOf(entry) {
300
+ const models = entry && entry.models;
301
+ if (!models || typeof models !== 'object' || Array.isArray(models)) return [];
302
+ return Object.entries(models).filter(([, value]) => value && typeof value === 'object' && !Array.isArray(value));
303
+ }
304
+
305
+ function isValidModelId(id) {
306
+ return typeof id === 'string' && id.trim().length > 0
307
+ && !['__proto__', 'constructor', 'prototype'].includes(id)
308
+ && id.length <= 200;
309
+ }
310
+
311
+ function validatePoolDraft(providers) {
312
+ if (!Array.isArray(providers)) throw new Error('Provider pools must be an array');
313
+ const names = new Set();
314
+ for (const entry of providers) {
315
+ if (!entry || typeof entry.provider !== 'string' || !entry.provider.trim()
316
+ || names.has(entry.provider) || !Array.isArray(entry.keys)
317
+ || entry.keys.some(key => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
318
+ || new Set(entry.keys).size !== entry.keys.length) {
319
+ throw new Error('Choose a unique provider and valid, unique credential reference names');
320
+ }
321
+ names.add(entry.provider);
322
+ for (const [model, mp] of modelEntriesOf(entry)) {
323
+ if (!isValidModelId(model)) throw new Error('Model ids must be non-empty and safe as configuration keys');
324
+ if (!Array.isArray(mp.keys)
325
+ || mp.keys.some(key => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
326
+ || new Set(mp.keys).size !== mp.keys.length) {
327
+ throw new Error('Each model pool needs valid, unique credential reference names');
328
+ }
329
+ for (const ref of Object.keys(quotaMapOf(mp))) {
330
+ if (!mp.keys.includes(ref)) throw new Error('A model token limit must reference a credential of that model pool');
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ async function settingsResponse(response) {
337
+ let data;
338
+ try { data = await response.json(); }
339
+ catch (_) { throw new Error('Settings response is not valid JSON (HTTP ' + response.status + ')'); }
340
+ if (!response.ok || data?.error) {
341
+ throw new Error(data?.error?.message || 'Settings request failed (HTTP ' + response.status + ')');
342
+ }
343
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid settings response');
344
+ return data;
345
+ }
346
+