sideby 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +453 -2
- package/README.zh-CN.md +455 -0
- package/dist/build.json +1 -0
- package/dist/src/cli/args.d.ts +13 -0
- package/dist/src/cli/args.js +38 -0
- package/dist/src/cli/commands.d.ts +37 -0
- package/dist/src/cli/commands.js +487 -0
- package/dist/src/cli/format.d.ts +16 -0
- package/dist/src/cli/format.js +80 -0
- package/dist/src/cli/json-schemas.d.ts +2 -0
- package/dist/src/cli/json-schemas.js +224 -0
- package/dist/src/cli/main.d.ts +2 -0
- package/dist/src/cli/main.js +186 -0
- package/dist/src/cli/schemas.d.ts +1 -0
- package/dist/src/cli/schemas.js +16 -0
- package/dist/src/core/account-name.d.ts +2 -0
- package/dist/src/core/account-name.js +2 -0
- package/dist/src/core/accounts.d.ts +18 -0
- package/dist/src/core/accounts.js +116 -0
- package/dist/src/core/config.d.ts +60 -0
- package/dist/src/core/config.js +181 -0
- package/dist/src/core/create.d.ts +30 -0
- package/dist/src/core/create.js +126 -0
- package/dist/src/core/doctor.d.ts +41 -0
- package/dist/src/core/doctor.js +171 -0
- package/dist/src/core/errors.d.ts +12 -0
- package/dist/src/core/errors.js +14 -0
- package/dist/src/core/file-memo.d.ts +24 -0
- package/dist/src/core/file-memo.js +41 -0
- package/dist/src/core/fs-safe.d.ts +38 -0
- package/dist/src/core/fs-safe.js +205 -0
- package/dist/src/core/handoff.d.ts +41 -0
- package/dist/src/core/handoff.js +54 -0
- package/dist/src/core/identity.d.ts +23 -0
- package/dist/src/core/identity.js +79 -0
- package/dist/src/core/last-doctor.d.ts +36 -0
- package/dist/src/core/last-doctor.js +69 -0
- package/dist/src/core/launch.d.ts +35 -0
- package/dist/src/core/launch.js +108 -0
- package/dist/src/core/login-path.d.ts +23 -0
- package/dist/src/core/login-path.js +96 -0
- package/dist/src/core/paths.d.ts +13 -0
- package/dist/src/core/paths.js +32 -0
- package/dist/src/core/quota-levels.d.ts +19 -0
- package/dist/src/core/quota-levels.js +34 -0
- package/dist/src/core/secret-file.d.ts +30 -0
- package/dist/src/core/secret-file.js +116 -0
- package/dist/src/core/share-mode-names.d.ts +1 -0
- package/dist/src/core/share-mode-names.js +10 -0
- package/dist/src/core/share-modes.d.ts +24 -0
- package/dist/src/core/share-modes.js +405 -0
- package/dist/src/core/shell-init.d.ts +25 -0
- package/dist/src/core/shell-init.js +61 -0
- package/dist/src/core/skill-check.d.ts +11 -0
- package/dist/src/core/skill-check.js +57 -0
- package/dist/src/core/usage-days.d.ts +15 -0
- package/dist/src/core/usage-days.js +33 -0
- package/dist/src/core/version.d.ts +9 -0
- package/dist/src/core/version.js +46 -0
- package/dist/src/desktop/app.d.ts +60 -0
- package/dist/src/desktop/app.js +316 -0
- package/dist/src/desktop/icon.d.ts +16 -0
- package/dist/src/desktop/icon.js +174 -0
- package/dist/src/families/claude/index.d.ts +10 -0
- package/dist/src/families/claude/index.js +124 -0
- package/dist/src/families/claude/layout.d.ts +6 -0
- package/dist/src/families/claude/layout.js +5 -0
- package/dist/src/families/claude/quota-cache.d.ts +31 -0
- package/dist/src/families/claude/quota-cache.js +111 -0
- package/dist/src/families/claude/quota.d.ts +3 -0
- package/dist/src/families/claude/quota.js +53 -0
- package/dist/src/families/claude/usage.d.ts +22 -0
- package/dist/src/families/claude/usage.js +146 -0
- package/dist/src/families/codex/index.d.ts +5 -0
- package/dist/src/families/codex/index.js +67 -0
- package/dist/src/families/codex/rollout.d.ts +67 -0
- package/dist/src/families/codex/rollout.js +298 -0
- package/dist/src/families/grok/index.d.ts +8 -0
- package/dist/src/families/grok/index.js +78 -0
- package/dist/src/families/index.d.ts +3 -0
- package/dist/src/families/index.js +40 -0
- package/dist/src/families/logos.d.ts +21 -0
- package/dist/src/families/logos.js +26 -0
- package/dist/src/families/pi/index.d.ts +3 -0
- package/dist/src/families/pi/index.js +54 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +16 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +4 -0
- package/dist/src/panel/background.d.ts +62 -0
- package/dist/src/panel/background.js +274 -0
- package/dist/src/panel/handler.d.ts +93 -0
- package/dist/src/panel/handler.js +411 -0
- package/dist/src/panel/i18n.d.ts +287 -0
- package/dist/src/panel/i18n.js +570 -0
- package/dist/src/panel/page-logic.d.ts +154 -0
- package/dist/src/panel/page-logic.js +326 -0
- package/dist/src/panel/page-script.d.ts +2 -0
- package/dist/src/panel/page-script.js +1633 -0
- package/dist/src/panel/page-styles.d.ts +7 -0
- package/dist/src/panel/page-styles.js +514 -0
- package/dist/src/panel/page.d.ts +20 -0
- package/dist/src/panel/page.js +96 -0
- package/dist/src/panel/server.d.ts +32 -0
- package/dist/src/panel/server.js +133 -0
- package/dist/src/panel/theme.d.ts +116 -0
- package/dist/src/panel/theme.js +132 -0
- package/dist/src/plugins/account-script.d.ts +4 -0
- package/dist/src/plugins/account-script.js +66 -0
- package/dist/src/plugins/bus.d.ts +33 -0
- package/dist/src/plugins/bus.js +84 -0
- package/dist/src/plugins/loader.d.ts +58 -0
- package/dist/src/plugins/loader.js +250 -0
- package/dist/src/quota/claude-statusline.d.ts +28 -0
- package/dist/src/quota/claude-statusline.js +301 -0
- package/dist/src/quota/statusline-tap.d.ts +11 -0
- package/dist/src/quota/statusline-tap.js +104 -0
- package/dist/src/runtime.d.ts +149 -0
- package/dist/src/runtime.js +371 -0
- package/dist/src/types.d.ts +278 -0
- package/dist/src/types.js +3 -0
- package/dist/testing/demo.d.ts +8 -0
- package/dist/testing/demo.js +50 -0
- package/dist/testing/index.d.ts +36 -0
- package/dist/testing/index.js +117 -0
- package/package.json +66 -4
- package/schemas/alias.json +102 -0
- package/schemas/app-install.json +74 -0
- package/schemas/app-uninstall.json +54 -0
- package/schemas/config.json +166 -0
- package/schemas/doctor.json +229 -0
- package/schemas/error.json +25 -0
- package/schemas/list.json +177 -0
- package/schemas/new.json +181 -0
- package/schemas/next.json +127 -0
- package/schemas/plugins.json +74 -0
- package/schemas/quota-setup.json +60 -0
- package/schemas/quota-teardown.json +30 -0
- package/schemas/quota.json +203 -0
- package/skills/sideby/SKILL.md +52 -0
- package/skills/sideby/references/accounts.md +31 -0
- package/skills/sideby/references/diagnose.md +47 -0
- package/skills/sideby/references/handoff.md +41 -0
- package/skills/sideby/references/quota.md +14 -0
|
@@ -0,0 +1,1633 @@
|
|
|
1
|
+
// The Panel page's inline script. Plain browser JavaScript in a string (no template literals inside, so String.raw
|
|
2
|
+
// keeps it verbatim); the pure helpers from page-logic.ts are prepended so the tested code is the code that runs.
|
|
3
|
+
import { aliasArgsOf, aliasIssue, attentionReasons, cacheHitRate, formatDuration, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelHint, panelLang, passedWindow, quotaPressure, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, } from './page-logic.js';
|
|
4
|
+
const LOGIC = [
|
|
5
|
+
aliasArgsOf,
|
|
6
|
+
aliasIssue,
|
|
7
|
+
attentionReasons,
|
|
8
|
+
cacheHitRate,
|
|
9
|
+
formatDuration,
|
|
10
|
+
maskEmail,
|
|
11
|
+
matchesQuery,
|
|
12
|
+
mergeDaily,
|
|
13
|
+
newAccountDir,
|
|
14
|
+
nextReset,
|
|
15
|
+
panelHint,
|
|
16
|
+
panelLang,
|
|
17
|
+
passedWindow,
|
|
18
|
+
quotaPressure,
|
|
19
|
+
sortAccounts,
|
|
20
|
+
suggestAlias,
|
|
21
|
+
suggestName,
|
|
22
|
+
updatedAge,
|
|
23
|
+
windowState,
|
|
24
|
+
]
|
|
25
|
+
.map((fn) => fn.toString())
|
|
26
|
+
.join('\n');
|
|
27
|
+
const MAIN = String.raw `
|
|
28
|
+
const BOOT = JSON.parse(document.getElementById('sideby-boot').textContent)
|
|
29
|
+
const MESSAGES = JSON.parse(document.getElementById('sideby-i18n').textContent)
|
|
30
|
+
const WARN = BOOT.warn
|
|
31
|
+
const FAIL = BOOT.fail
|
|
32
|
+
// Quota data older than this is greyed and marked with its age.
|
|
33
|
+
const STALE_MS = 60 * 60000
|
|
34
|
+
// Findings listed before the rest fold behind a button.
|
|
35
|
+
const FINDINGS_SHOWN = 5
|
|
36
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
37
|
+
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
38
|
+
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
39
|
+
// Auto refresh: the state reloads every AUTO_MS while the page is visible, and every FAST_MS for FAST_FOR_MS after
|
|
40
|
+
// quota is turned on, so the first numbers show up soon after a status line reports them.
|
|
41
|
+
const AUTO_MS = 20000
|
|
42
|
+
const FAST_MS = 5000
|
|
43
|
+
const FAST_FOR_MS = 3 * 60000
|
|
44
|
+
const S = {
|
|
45
|
+
state: null, loadError: null, loading: false, loadedAt: 0, history: null,
|
|
46
|
+
report: null, checking: false, fixing: false, checkError: null, checkRetry: null, fixes: null,
|
|
47
|
+
// silent: the running load is a background one (no spinner); lastTry: when the last load started;
|
|
48
|
+
// pending: a background load finished while the viewer was busy, so its render waits; fastUntil/fastFamily:
|
|
49
|
+
// the quick polling after quota was turned on.
|
|
50
|
+
silent: false, lastTry: 0, pending: false, fastUntil: 0, fastFamily: null,
|
|
51
|
+
}
|
|
52
|
+
// View state that is not saved: the search text, the attention filter, the Health check filter.
|
|
53
|
+
const ui = { query: '', attention: false, allFindings: false, healthRef: null, checkingRef: null, highlight: null, fade: true }
|
|
54
|
+
const $ = (id) => document.getElementById(id)
|
|
55
|
+
|
|
56
|
+
// ---------- preferences ----------
|
|
57
|
+
// Kept in localStorage, which can be missing or throw (private windows, blocked storage): every access is guarded
|
|
58
|
+
// and the defaults apply.
|
|
59
|
+
const PREFS_KEY = 'sideby.panel.v2'
|
|
60
|
+
const CHOICES = { view: ['list', 'cards'], sort: ['pressure', 'name', 'recent'], quota: ['used', 'left'], theme: ['system', 'light', 'dark'], lang: ['en', 'zh'] }
|
|
61
|
+
const prefs = loadPrefs()
|
|
62
|
+
function loadPrefs() {
|
|
63
|
+
let raw = {}
|
|
64
|
+
try { raw = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {} } catch {}
|
|
65
|
+
const p = { view: 'list', sort: 'pressure', quota: 'used', theme: 'system', lang: '', family: '', collapsed: {}, hideEmail: false }
|
|
66
|
+
for (const k of Object.keys(CHOICES)) if (CHOICES[k].includes(raw[k])) p[k] = raw[k]
|
|
67
|
+
if (raw.hideEmail === true) p.hideEmail = true
|
|
68
|
+
if (typeof raw.family === 'string') p.family = raw.family
|
|
69
|
+
if (raw.collapsed && typeof raw.collapsed === 'object')
|
|
70
|
+
for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
|
|
71
|
+
return p
|
|
72
|
+
}
|
|
73
|
+
function savePrefs() { try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } catch {} }
|
|
74
|
+
// A host lang other than auto wins over the saved choice (which stays saved) and hides the switch.
|
|
75
|
+
const LANG_FIXED = BOOT.lang === 'en' || BOOT.lang === 'zh'
|
|
76
|
+
let lang = panelLang(BOOT.lang, prefs.lang, navigator.language)
|
|
77
|
+
|
|
78
|
+
// ---------- text ----------
|
|
79
|
+
function t(key, vars) {
|
|
80
|
+
const table = MESSAGES[lang] || {}
|
|
81
|
+
let s = Object.hasOwn(table, key) ? table[key] : Object.hasOwn(MESSAGES.en, key) ? MESSAGES.en[key] : key
|
|
82
|
+
if (vars) s = s.replace(/\{(\w+)\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m))
|
|
83
|
+
return s
|
|
84
|
+
}
|
|
85
|
+
function tn(key, n, vars) { return t(key + (n === 1 ? '.one' : '.other'), Object.assign({ n }, vars)) }
|
|
86
|
+
// A message whose placeholders are nodes, such as a code span inside a sentence.
|
|
87
|
+
function tx(key, vars) {
|
|
88
|
+
const s = t(key)
|
|
89
|
+
const out = []
|
|
90
|
+
let last = 0
|
|
91
|
+
s.replace(/\{(\w+)\}/g, (m, k, i) => {
|
|
92
|
+
out.push(s.slice(last, i), vars && Object.hasOwn(vars, k) ? vars[k] : m)
|
|
93
|
+
last = i + m.length
|
|
94
|
+
return m
|
|
95
|
+
})
|
|
96
|
+
out.push(s.slice(last))
|
|
97
|
+
return out.filter((x) => x !== '')
|
|
98
|
+
}
|
|
99
|
+
const locale = () => (lang === 'zh' ? 'zh-CN' : 'en-US')
|
|
100
|
+
|
|
101
|
+
function h(tag, props, ...kids) {
|
|
102
|
+
const el = document.createElement(tag)
|
|
103
|
+
if (props) for (const k of Object.keys(props)) {
|
|
104
|
+
const v = props[k]
|
|
105
|
+
if (v === null || v === undefined || v === false) continue
|
|
106
|
+
if (k === 'class') el.className = v
|
|
107
|
+
else if (k === 'css') for (const p of Object.keys(v)) el.style.setProperty(p, v[p])
|
|
108
|
+
else if (k.slice(0, 2) === 'on') el.addEventListener(k.slice(2), v)
|
|
109
|
+
else el.setAttribute(k, v === true ? '' : String(v))
|
|
110
|
+
}
|
|
111
|
+
add(el, kids)
|
|
112
|
+
// Every button gets an explicit accessible name; one without aria-label is named by its visible text.
|
|
113
|
+
if (tag === 'button' && !el.hasAttribute('aria-label') && !el.hasAttribute('aria-labelledby')) labelFromText(el)
|
|
114
|
+
return el
|
|
115
|
+
}
|
|
116
|
+
function visibleText(node) {
|
|
117
|
+
const parts = []
|
|
118
|
+
for (const k of node.childNodes) {
|
|
119
|
+
if (k.nodeType === 3) parts.push(k.data)
|
|
120
|
+
else if (k.nodeType === 1 && k.getAttribute('aria-hidden') !== 'true') parts.push(visibleText(k))
|
|
121
|
+
}
|
|
122
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim()
|
|
123
|
+
}
|
|
124
|
+
function labelFromText(el) {
|
|
125
|
+
const text = visibleText(el)
|
|
126
|
+
if (text) el.setAttribute('aria-label', text)
|
|
127
|
+
else el.removeAttribute('aria-label')
|
|
128
|
+
}
|
|
129
|
+
// replaceChildren() would render null as the text "null"; route children through add(), which skips it.
|
|
130
|
+
function setKids(el, kids) {
|
|
131
|
+
el.replaceChildren()
|
|
132
|
+
add(el, kids)
|
|
133
|
+
}
|
|
134
|
+
function add(el, kid) {
|
|
135
|
+
if (kid === null || kid === undefined || kid === false) return
|
|
136
|
+
if (Array.isArray(kid)) { for (const k of kid) add(el, k); return }
|
|
137
|
+
el.append(kid instanceof Node ? kid : document.createTextNode(String(kid)))
|
|
138
|
+
}
|
|
139
|
+
function dots(parts) {
|
|
140
|
+
const out = []
|
|
141
|
+
for (const p of parts) {
|
|
142
|
+
if (p === null || p === undefined || p === false || p === '') continue
|
|
143
|
+
if (out.length) out.push(h('span', { class: 'sep', 'aria-hidden': 'true' }, '·'))
|
|
144
|
+
out.push(p)
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------- updating in place ----------
|
|
150
|
+
// A refresh rebuilds the markup, then keeps every existing element whose markup did not change, so focus, hover,
|
|
151
|
+
// text selection and scroll stay put and only changed rows are swapped. Children match by data-key, else by
|
|
152
|
+
// position; a data-patch container that changed is updated child by child instead of being replaced. Rows carry
|
|
153
|
+
// a data-sig of their account, so equal markup also means their click handlers see the same data.
|
|
154
|
+
function markup(el) { return el.outerHTML.replace(/tip-[0-9]+/g, 'tip') }
|
|
155
|
+
function syncAttrs(from, to) {
|
|
156
|
+
for (const a of Array.from(from.attributes)) if (!to.hasAttribute(a.name)) from.removeAttribute(a.name)
|
|
157
|
+
for (const a of Array.from(to.attributes)) if (from.getAttribute(a.name) !== a.value) from.setAttribute(a.name, a.value)
|
|
158
|
+
}
|
|
159
|
+
function flat(kids, out) {
|
|
160
|
+
out = out || []
|
|
161
|
+
if (kids === null || kids === undefined || kids === false) return out
|
|
162
|
+
if (Array.isArray(kids)) { for (const k of kids) flat(k, out); return out }
|
|
163
|
+
out.push(kids instanceof Node ? kids : document.createTextNode(String(kids)))
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
166
|
+
function patchKids(box, kids) {
|
|
167
|
+
const next = flat(kids)
|
|
168
|
+
const old = Array.from(box.childNodes)
|
|
169
|
+
const byKey = new Map()
|
|
170
|
+
for (const o of old) if (o.nodeType === 1 && o.dataset.key) byKey.set(o.dataset.key, o)
|
|
171
|
+
const used = new Set()
|
|
172
|
+
const result = next.map((n, i) => {
|
|
173
|
+
const key = n.nodeType === 1 ? n.dataset.key : ''
|
|
174
|
+
const o = key ? byKey.get(key) : old[i] && !(old[i].nodeType === 1 && old[i].dataset.key) ? old[i] : null
|
|
175
|
+
if (!o || used.has(o) || o.nodeType !== n.nodeType || o.nodeName !== n.nodeName) return n
|
|
176
|
+
if (n.nodeType === 3) { if (o.data !== n.data) o.data = n.data; used.add(o); return o }
|
|
177
|
+
if (n.nodeType !== 1) return n
|
|
178
|
+
if (markup(o) === markup(n)) { used.add(o); return o }
|
|
179
|
+
if (o.hasAttribute('data-patch') && n.hasAttribute('data-patch')) { syncAttrs(o, n); patchKids(o, Array.from(n.childNodes)); used.add(o); return o }
|
|
180
|
+
return n
|
|
181
|
+
})
|
|
182
|
+
for (const o of old) if (!used.has(o)) o.remove()
|
|
183
|
+
result.forEach((n, i) => { if (box.childNodes[i] !== n) box.insertBefore(n, box.childNodes[i] || null) })
|
|
184
|
+
}
|
|
185
|
+
function pathOf(el, root) {
|
|
186
|
+
const p = []
|
|
187
|
+
while (el && el !== root) {
|
|
188
|
+
const up = el.parentElement
|
|
189
|
+
if (!up) return null
|
|
190
|
+
p.unshift(Array.prototype.indexOf.call(up.children, el))
|
|
191
|
+
el = up
|
|
192
|
+
}
|
|
193
|
+
return el === root ? p : null
|
|
194
|
+
}
|
|
195
|
+
// Replaces box's children like setKids, keeping unchanged elements. Focus inside a swapped element moves to the
|
|
196
|
+
// element at the same place in its replacement, found through its keyed ancestors (rows can change order).
|
|
197
|
+
function patch(box, kids) {
|
|
198
|
+
const active = document.activeElement
|
|
199
|
+
const keys = []
|
|
200
|
+
let anchor = box
|
|
201
|
+
if (active && active !== document.body && box.contains(active)) {
|
|
202
|
+
for (let el = active.parentElement; el && el !== box; el = el.parentElement) if (el.hasAttribute('data-key')) keys.unshift(el)
|
|
203
|
+
if (keys.length) anchor = keys[keys.length - 1]
|
|
204
|
+
}
|
|
205
|
+
const path = anchor !== box || (active && box.contains(active) && active !== box) ? pathOf(active, anchor) : null
|
|
206
|
+
const chain = keys.map((el) => el.getAttribute('data-key'))
|
|
207
|
+
patchKids(box, kids)
|
|
208
|
+
if (path && !active.isConnected) {
|
|
209
|
+
let el = box
|
|
210
|
+
for (const k of chain) el = el && Array.from(el.querySelectorAll('[data-key]')).find((x) => x.getAttribute('data-key') === k)
|
|
211
|
+
for (const i of path) el = el && el.children[i]
|
|
212
|
+
if (el && typeof el.focus === 'function') el.focus({ preventScroll: true })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// A short stable fingerprint of a value.
|
|
216
|
+
function sig(value) {
|
|
217
|
+
const s = JSON.stringify(value) || ''
|
|
218
|
+
let x = 5381
|
|
219
|
+
for (let i = 0; i < s.length; i++) x = ((x * 33) ^ s.charCodeAt(i)) >>> 0
|
|
220
|
+
return x.toString(36) + s.length.toString(36)
|
|
221
|
+
}
|
|
222
|
+
// Whether the viewer is in the middle of something a background render would disturb: an open menu, dialog or
|
|
223
|
+
// tooltip, a focused field, or a text selection in the account list.
|
|
224
|
+
function uiBusy() {
|
|
225
|
+
if (menu.el || (dlg.el && dlg.el.open) || document.querySelector('.tip.is-open')) return true
|
|
226
|
+
const el = document.activeElement
|
|
227
|
+
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return true
|
|
228
|
+
const sel = window.getSelection && window.getSelection()
|
|
229
|
+
return Boolean(sel && !sel.isCollapsed && sel.anchorNode && $('accounts').contains(sel.anchorNode))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ICONS = {
|
|
233
|
+
refresh: 'M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7',
|
|
234
|
+
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4.2-4.2',
|
|
235
|
+
chevron: 'M9 6l6 6-6 6',
|
|
236
|
+
more: 'M5 12h.01M12 12h.01M19 12h.01',
|
|
237
|
+
plus: 'M12 5v14M5 12h14',
|
|
238
|
+
sun: 'M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4',
|
|
239
|
+
moon: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z',
|
|
240
|
+
monitor: 'M3 5h18v11H3zM8 20h8M12 16v4',
|
|
241
|
+
copy: 'M9 9h11v11H9zM5 15H4V4h11v1',
|
|
242
|
+
terminal: 'M4 17l6-5-6-5M12 19h8',
|
|
243
|
+
key: 'M15 9a3 3 0 1 0-.01 0M12.9 11.1L4 20M7 17l2 2M9.5 14.5l2 2',
|
|
244
|
+
folder: 'M3 6h6l2 2h10v11H3z',
|
|
245
|
+
check: 'M9 12l2 2 4-4M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',
|
|
246
|
+
userPlus: 'M15 19a6 6 0 0 0-12 0M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM19 8v6M16 11h6',
|
|
247
|
+
x: 'M6 6l12 12M18 6L6 18',
|
|
248
|
+
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
|
|
249
|
+
list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
|
|
250
|
+
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
|
|
251
|
+
mail: 'M3 6h18v12H3zM3 7l9 6 9-6',
|
|
252
|
+
eye: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
|
253
|
+
eyeOff: 'M3 3l18 18M10.6 5.1A10.8 10.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.6 6.6A17.4 17.4 0 0 0 2 12s3.5 7 10 7a10.4 10.4 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2',
|
|
254
|
+
}
|
|
255
|
+
function icon(name, cls) {
|
|
256
|
+
const svg = document.createElementNS(SVG_NS, 'svg')
|
|
257
|
+
svg.setAttribute('viewBox', '0 0 24 24')
|
|
258
|
+
svg.setAttribute('class', 'ic' + (cls ? ' ' + cls : ''))
|
|
259
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
260
|
+
svg.setAttribute('focusable', 'false')
|
|
261
|
+
const path = document.createElementNS(SVG_NS, 'path')
|
|
262
|
+
path.setAttribute('d', ICONS[name])
|
|
263
|
+
svg.append(path)
|
|
264
|
+
return svg
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------- server calls ----------
|
|
268
|
+
// The token is per server process: after sideby ui restarts, the first write gets 403 "bad-token". The page then
|
|
269
|
+
// fetches the new token from api/session and sends that same request once more; the server refused it before
|
|
270
|
+
// running it, so the retry cannot apply anything twice. A request that got no answer is never replayed.
|
|
271
|
+
let token = BOOT.token
|
|
272
|
+
function failure(message, retryable, network, status, code) {
|
|
273
|
+
const e = new Error(message)
|
|
274
|
+
e.code = code || ''
|
|
275
|
+
e.retryable = retryable
|
|
276
|
+
e.network = Boolean(network)
|
|
277
|
+
e.status = status || 0
|
|
278
|
+
return e
|
|
279
|
+
}
|
|
280
|
+
async function send(path, init) {
|
|
281
|
+
let res
|
|
282
|
+
try { res = await fetch(BOOT.basePath + path, Object.assign({ cache: 'no-store' }, init)) } catch {
|
|
283
|
+
connectionLost()
|
|
284
|
+
throw failure(t('err.noAnswer'), true, true)
|
|
285
|
+
}
|
|
286
|
+
let data = null
|
|
287
|
+
try { data = await res.json() } catch {}
|
|
288
|
+
return { res, data }
|
|
289
|
+
}
|
|
290
|
+
async function refreshToken() {
|
|
291
|
+
const { res, data } = await send('/api/session')
|
|
292
|
+
if (!res.ok || !data || typeof data.token !== 'string') throw failure(t('err.session'), false)
|
|
293
|
+
token = data.token
|
|
294
|
+
}
|
|
295
|
+
async function api(path, body) {
|
|
296
|
+
for (let attempt = 0; ; attempt++) {
|
|
297
|
+
const init = body === undefined ? {} : {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: { 'content-type': 'application/json', 'x-sideby-token': token },
|
|
300
|
+
body: JSON.stringify(body),
|
|
301
|
+
}
|
|
302
|
+
const { res, data } = await send(path, init)
|
|
303
|
+
if (res.ok) return data
|
|
304
|
+
if (res.status === 403 && data && data.code === 'bad-token') {
|
|
305
|
+
if (attempt === 0) { await refreshToken(); continue }
|
|
306
|
+
throw failure(t('err.expired'), false)
|
|
307
|
+
}
|
|
308
|
+
const msg = (data && data.error) || 'request failed (' + res.status + ')'
|
|
309
|
+
// 4xx messages are written for people (what is wrong, what to do); 5xx ones are internal, so they get a lead-in.
|
|
310
|
+
if (res.status >= 500) throw failure(t('err.server', { msg }), true, false, res.status)
|
|
311
|
+
throw failure(msg.charAt(0).toUpperCase() + msg.slice(1), false, false, res.status, data && typeof data.code === 'string' ? data.code : '')
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------- connection ----------
|
|
316
|
+
// A request that cannot reach the server shows a small notice and polls api/health, waiting 1 s and then up to 5 s
|
|
317
|
+
// between tries. Once it answers, the page renews its token and reloads the state; writes are left for the user.
|
|
318
|
+
const conn = { lost: false, timer: 0, delay: 1000 }
|
|
319
|
+
function connectionLost() {
|
|
320
|
+
if (conn.lost) return
|
|
321
|
+
conn.lost = true
|
|
322
|
+
conn.delay = 1000
|
|
323
|
+
renderConnection()
|
|
324
|
+
conn.timer = setTimeout(ping, conn.delay)
|
|
325
|
+
}
|
|
326
|
+
async function ping() {
|
|
327
|
+
try {
|
|
328
|
+
const res = await fetch(BOOT.basePath + '/api/health', { cache: 'no-store' })
|
|
329
|
+
const data = await res.json()
|
|
330
|
+
if (!res.ok || !data || data.app !== 'sideby') throw new Error('not sideby')
|
|
331
|
+
const s = await fetch(BOOT.basePath + '/api/session', { cache: 'no-store' }).then((r) => r.json())
|
|
332
|
+
if (typeof s.token !== 'string') throw new Error('no session')
|
|
333
|
+
token = s.token
|
|
334
|
+
} catch {
|
|
335
|
+
conn.delay = Math.min(5000, Math.round(conn.delay * 1.6))
|
|
336
|
+
conn.timer = setTimeout(ping, conn.delay)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
conn.lost = false
|
|
340
|
+
renderConnection()
|
|
341
|
+
toast(t('toast.reconnected'))
|
|
342
|
+
load()
|
|
343
|
+
}
|
|
344
|
+
function renderConnection() {
|
|
345
|
+
$('conn').replaceChildren(...(conn.lost ? [spinner(), t('conn.lost')] : []))
|
|
346
|
+
$('conn').hidden = !conn.lost
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------- toasts ----------
|
|
350
|
+
// Bottom center, newest last, at most three. The same text again restarts its timer instead of stacking.
|
|
351
|
+
// Hovering a toast pauses it, so an Undo stays reachable.
|
|
352
|
+
const toasts = []
|
|
353
|
+
// The toast stack is a manual popover so it sits in the top layer; raising it again after a modal dialog
|
|
354
|
+
// opens keeps toasts above that dialog's backdrop. Without popover support it stays a fixed element.
|
|
355
|
+
function raiseToasts() {
|
|
356
|
+
const el = $('toasts')
|
|
357
|
+
if (typeof el.showPopover !== 'function') return
|
|
358
|
+
try { if (el.matches(':popover-open')) el.hidePopover(); el.showPopover() } catch {}
|
|
359
|
+
}
|
|
360
|
+
function toast(text, opts) {
|
|
361
|
+
opts = opts || {}
|
|
362
|
+
const kind = opts.kind || 'ok'
|
|
363
|
+
const ms = opts.duration || Math.max(2000, Math.min(8000, 1800 + text.length * 40 + (opts.action ? 4000 : 0) + (kind === 'fail' ? 2000 : 0)))
|
|
364
|
+
// A keyed toast (such as the theme) replaces the earlier one with that key.
|
|
365
|
+
const keyed = opts.key ? toasts.find((x) => x.key === opts.key && x.text !== text) : null
|
|
366
|
+
if (keyed) dismiss(keyed)
|
|
367
|
+
let item = toasts.find((x) => x.text === text)
|
|
368
|
+
if (item) {
|
|
369
|
+
clearTimeout(item.timer)
|
|
370
|
+
item.el.classList.remove('bump')
|
|
371
|
+
void item.el.offsetWidth
|
|
372
|
+
item.el.classList.add('bump')
|
|
373
|
+
} else {
|
|
374
|
+
item = { text, ms, key: opts.key || null }
|
|
375
|
+
const action = opts.action ? h('button', { class: 'toast-action', type: 'button', onclick: () => { dismiss(item); opts.action.run() } }, opts.action.label) : null
|
|
376
|
+
item.el = h('div', { class: 'toast toast-' + kind, role: kind === 'fail' ? 'alert' : null },
|
|
377
|
+
h('span', { class: 'toast-dot', 'aria-hidden': 'true' }),
|
|
378
|
+
h('span', { class: 'toast-text' }, text),
|
|
379
|
+
action,
|
|
380
|
+
h('button', { class: 'toast-x', type: 'button', 'aria-label': t('toast.dismiss'), onclick: () => dismiss(item) }, icon('x')))
|
|
381
|
+
item.el.addEventListener('mouseenter', () => clearTimeout(item.timer))
|
|
382
|
+
item.el.addEventListener('mouseleave', () => { item.timer = setTimeout(() => dismiss(item), 2500) })
|
|
383
|
+
toasts.push(item)
|
|
384
|
+
$('toasts').append(item.el)
|
|
385
|
+
if (dlg.el && dlg.el.open) raiseToasts()
|
|
386
|
+
while (toasts.length > 3) dismiss(toasts[0])
|
|
387
|
+
}
|
|
388
|
+
item.timer = setTimeout(() => dismiss(item), ms)
|
|
389
|
+
}
|
|
390
|
+
function dismiss(item) {
|
|
391
|
+
clearTimeout(item.timer)
|
|
392
|
+
const i = toasts.indexOf(item)
|
|
393
|
+
if (i >= 0) toasts.splice(i, 1)
|
|
394
|
+
item.el.remove()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------- small parts ----------
|
|
398
|
+
function ago(iso) {
|
|
399
|
+
const ts = Date.parse(iso)
|
|
400
|
+
if (!Number.isFinite(ts)) return ''
|
|
401
|
+
const s = Math.max(0, (Date.now() - ts) / 1000)
|
|
402
|
+
if (s < 45) return t('time.justNow')
|
|
403
|
+
const m = Math.round(s / 60)
|
|
404
|
+
if (m < 60) return t('time.minAgo', { n: m })
|
|
405
|
+
const hr = Math.round(m / 60)
|
|
406
|
+
if (hr < 24) return t('time.hAgo', { n: hr })
|
|
407
|
+
return t('time.dAgo', { n: Math.round(hr / 24) })
|
|
408
|
+
}
|
|
409
|
+
// The header's "Updated …" age (see updatedAge).
|
|
410
|
+
function agoShort(ms) {
|
|
411
|
+
const a = updatedAge(ms)
|
|
412
|
+
return t(a.key, { n: a.n })
|
|
413
|
+
}
|
|
414
|
+
function clock(iso) {
|
|
415
|
+
const d = new Date(iso)
|
|
416
|
+
if (!Number.isFinite(d.getTime())) return '?'
|
|
417
|
+
const time = d.toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' })
|
|
418
|
+
const now = new Date()
|
|
419
|
+
if (d.toDateString() === now.toDateString()) return time
|
|
420
|
+
if (Math.abs(d - now) < 6 * 864e5) return d.toLocaleDateString(locale(), { weekday: 'short' }) + ' ' + time
|
|
421
|
+
return d.toLocaleDateString(locale(), { month: 'short', day: 'numeric' }) + ' ' + time
|
|
422
|
+
}
|
|
423
|
+
function day(iso) {
|
|
424
|
+
const d = new Date(iso)
|
|
425
|
+
return Number.isFinite(d.getTime()) ? d.toLocaleDateString(locale(), { month: 'short', day: 'numeric' }) : '?'
|
|
426
|
+
}
|
|
427
|
+
function countdown(iso, now) {
|
|
428
|
+
const at = Date.parse(iso)
|
|
429
|
+
if (!Number.isFinite(at)) return '?'
|
|
430
|
+
return t('time.in', { d: formatDuration(at - now, lang) })
|
|
431
|
+
}
|
|
432
|
+
function tokens(n) {
|
|
433
|
+
if (!Number.isFinite(n)) return '0'
|
|
434
|
+
const units = [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']]
|
|
435
|
+
for (const [v, u] of units) if (n >= v) return (n / v).toFixed(n / v >= 100 ? 0 : 1).replace(/\.0$/, '') + u
|
|
436
|
+
return String(Math.round(n))
|
|
437
|
+
}
|
|
438
|
+
function isStale(iso, now) {
|
|
439
|
+
const ts = Date.parse(iso)
|
|
440
|
+
return Number.isFinite(ts) && now - ts > STALE_MS
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function family(id) { return (S.state && S.state.families.find((f) => f.id === id)) || { id, title: id, bin: id, installUrl: '', installed: false } }
|
|
444
|
+
function famTitle(a) { return a.familyTitle || family(a.family).title }
|
|
445
|
+
// The email as the page shows it: masked everywhere while "Hide emails" is on (copying still gives the real one).
|
|
446
|
+
function emailText(email) { return prefs.hideEmail ? maskEmail(email) : email }
|
|
447
|
+
// A Family's mark: the logo its FamilyDef sets (checked by the loader), or its initials on a color derived from the id.
|
|
448
|
+
// size: '' (cards), 'sm' (rows, groups, dialogs) or 'xs' (inside a chip; no initials fallback there).
|
|
449
|
+
function badge(id, size) {
|
|
450
|
+
const logo = family(id).logo
|
|
451
|
+
const cls = 'badge' + (size ? ' badge-' + size : '')
|
|
452
|
+
if (logo) {
|
|
453
|
+
const svg = document.createElementNS(SVG_NS, 'svg')
|
|
454
|
+
svg.setAttribute('viewBox', '0 0 24 24')
|
|
455
|
+
svg.setAttribute('focusable', 'false')
|
|
456
|
+
const path = document.createElementNS(SVG_NS, 'path')
|
|
457
|
+
path.setAttribute('d', logo.path)
|
|
458
|
+
if (logo.fillRule) path.setAttribute('fill-rule', logo.fillRule)
|
|
459
|
+
svg.append(path)
|
|
460
|
+
return h('span', { class: cls + ' badge-logo', css: logo.color ? { color: logo.color } : null, title: logo.title || family(id).title, 'aria-hidden': 'true' }, svg)
|
|
461
|
+
}
|
|
462
|
+
if (size === 'xs') return null
|
|
463
|
+
let x = 0
|
|
464
|
+
for (const c of id) x = (x * 31 + c.charCodeAt(0)) % 360
|
|
465
|
+
return h('span', { class: cls, css: { background: 'hsl(' + x + ' 55% 46%)' }, 'aria-hidden': 'true' }, id.slice(0, 2).toUpperCase())
|
|
466
|
+
}
|
|
467
|
+
function spinner() { return h('span', { class: 'spin', 'aria-hidden': 'true' }) }
|
|
468
|
+
function link(text, url, cls) {
|
|
469
|
+
let ok = false
|
|
470
|
+
try { const u = new URL(url); ok = u.protocol === 'https:' || u.protocol === 'http:' } catch {}
|
|
471
|
+
return ok ? h('a', { class: cls, href: url, target: '_blank', rel: 'noopener noreferrer' }, text) : null
|
|
472
|
+
}
|
|
473
|
+
async function copyText(text) {
|
|
474
|
+
let ok = true
|
|
475
|
+
try { await navigator.clipboard.writeText(text) } catch {
|
|
476
|
+
const ta = h('textarea', { css: { position: 'fixed', opacity: '0' } })
|
|
477
|
+
ta.value = text
|
|
478
|
+
document.body.append(ta)
|
|
479
|
+
ta.select()
|
|
480
|
+
try { ok = document.execCommand('copy') } catch { ok = false }
|
|
481
|
+
ta.remove()
|
|
482
|
+
}
|
|
483
|
+
if (ok) toast(t('toast.copied', { text: prefs.hideEmail && text.includes('@') ? maskEmail(text) : text }))
|
|
484
|
+
else toast(t('toast.copyFailed'), { kind: 'fail' })
|
|
485
|
+
}
|
|
486
|
+
function codeCopy(text) {
|
|
487
|
+
return h('span', { class: 'copy' }, h('code', null, text),
|
|
488
|
+
h('button', { class: 'btn btn-sm', type: 'button', 'aria-label': t('btn.copyText', { text }), onclick: () => copyText(text) }, icon('copy'), t('btn.copy')))
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// A "?" next to a term; the explanation shows on hover and on keyboard focus.
|
|
492
|
+
let tipSeq = 0
|
|
493
|
+
function tipRaw(term, body) {
|
|
494
|
+
const id = 'tip-' + (++tipSeq)
|
|
495
|
+
return h('span', { class: 'tip' },
|
|
496
|
+
h('button', { class: 'tip-btn', type: 'button', 'aria-label': t('tip.label', { term }), 'aria-describedby': id }, '?'),
|
|
497
|
+
h('span', { class: 'tip-body', role: 'tooltip', id }, body))
|
|
498
|
+
}
|
|
499
|
+
function tip(key) { return tipRaw(t('tip.' + key + '.term'), t('tip.' + key + '.body')) }
|
|
500
|
+
function showTip(btn) {
|
|
501
|
+
const body = btn.nextElementSibling
|
|
502
|
+
if (!body) return
|
|
503
|
+
body.parentElement.classList.add('is-open')
|
|
504
|
+
const r = btn.getBoundingClientRect()
|
|
505
|
+
const w = body.offsetWidth
|
|
506
|
+
const hgt = body.offsetHeight
|
|
507
|
+
const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2))
|
|
508
|
+
const top = r.top - hgt - 8 >= 8 ? r.top - hgt - 8 : r.bottom + 8
|
|
509
|
+
body.style.setProperty('left', left + 'px')
|
|
510
|
+
body.style.setProperty('top', top + 'px')
|
|
511
|
+
}
|
|
512
|
+
function hideTip(btn) { if (btn.parentElement) btn.parentElement.classList.remove('is-open') }
|
|
513
|
+
document.addEventListener('pointerover', (e) => { const b = e.target.closest && e.target.closest('.tip-btn'); if (b) showTip(b) })
|
|
514
|
+
document.addEventListener('pointerout', (e) => { const b = e.target.closest && e.target.closest('.tip-btn'); if (b && !b.contains(e.relatedTarget)) hideTip(b) })
|
|
515
|
+
document.addEventListener('focusin', (e) => { if (e.target.classList && e.target.classList.contains('tip-btn')) showTip(e.target) })
|
|
516
|
+
document.addEventListener('focusout', (e) => { if (e.target.classList && e.target.classList.contains('tip-btn')) hideTip(e.target) })
|
|
517
|
+
|
|
518
|
+
// ---------- row menu ----------
|
|
519
|
+
const menu = { el: null, anchor: null }
|
|
520
|
+
function openMenu(anchor, items, label) {
|
|
521
|
+
closeMenu()
|
|
522
|
+
const el = h('div', { class: 'menu', role: 'menu', 'aria-label': label },
|
|
523
|
+
items.filter(Boolean).map((it) => it === '-' ? h('div', { class: 'menu-sep', role: 'separator' })
|
|
524
|
+
: h('button', { class: 'menu-item', type: 'button', role: 'menuitem', tabindex: '-1', disabled: it.disabled, onclick: () => { closeMenu(true); it.run() } },
|
|
525
|
+
icon(it.icon), h('span', { class: 'menu-label' }, it.label), it.hint ? h('code', { class: 'menu-hint', title: it.hint }, it.hint.length > 24 ? '…' + it.hint.slice(-23) : it.hint) : null)))
|
|
526
|
+
el.addEventListener('keydown', menuKeys)
|
|
527
|
+
document.body.append(el)
|
|
528
|
+
const r = anchor.getBoundingClientRect()
|
|
529
|
+
const w = el.offsetWidth
|
|
530
|
+
const hgt = el.offsetHeight
|
|
531
|
+
const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.right - w))
|
|
532
|
+
const top = r.bottom + 4 + hgt > window.innerHeight - 8 && r.top - hgt - 4 > 8 ? r.top - hgt - 4 : r.bottom + 4
|
|
533
|
+
el.style.setProperty('left', left + 'px')
|
|
534
|
+
el.style.setProperty('top', top + 'px')
|
|
535
|
+
anchor.setAttribute('aria-expanded', 'true')
|
|
536
|
+
menu.el = el
|
|
537
|
+
menu.anchor = anchor
|
|
538
|
+
const first = el.querySelector('.menu-item:not(:disabled)')
|
|
539
|
+
if (first) first.focus()
|
|
540
|
+
}
|
|
541
|
+
function closeMenu(restore) {
|
|
542
|
+
if (!menu.el) return
|
|
543
|
+
menu.el.remove()
|
|
544
|
+
menu.anchor.setAttribute('aria-expanded', 'false')
|
|
545
|
+
if (restore && menu.anchor.isConnected) menu.anchor.focus()
|
|
546
|
+
menu.el = null
|
|
547
|
+
menu.anchor = null
|
|
548
|
+
}
|
|
549
|
+
function menuKeys(e) {
|
|
550
|
+
const items = Array.from(menu.el.querySelectorAll('.menu-item:not(:disabled)'))
|
|
551
|
+
const i = items.indexOf(document.activeElement)
|
|
552
|
+
if (e.key === 'ArrowDown') items[(i + 1) % items.length].focus()
|
|
553
|
+
else if (e.key === 'ArrowUp') items[(i - 1 + items.length) % items.length].focus()
|
|
554
|
+
else if (e.key === 'Home') items[0].focus()
|
|
555
|
+
else if (e.key === 'End') items[items.length - 1].focus()
|
|
556
|
+
else if (e.key === 'Escape') closeMenu(true)
|
|
557
|
+
else if (e.key === 'Tab') { closeMenu(true); return }
|
|
558
|
+
else return
|
|
559
|
+
e.preventDefault()
|
|
560
|
+
}
|
|
561
|
+
document.addEventListener('pointerdown', (e) => { if (menu.el && !menu.el.contains(e.target) && !menu.anchor.contains(e.target)) closeMenu() })
|
|
562
|
+
window.addEventListener('resize', () => closeMenu())
|
|
563
|
+
window.addEventListener('scroll', () => closeMenu(), { passive: true })
|
|
564
|
+
|
|
565
|
+
function accountMenu(a, anchor) {
|
|
566
|
+
const run = 'sideby run ' + a.ref
|
|
567
|
+
const items = [
|
|
568
|
+
{ icon: 'terminal', label: t('menu.copyRun'), hint: run, run: () => copyText(run) },
|
|
569
|
+
...(a.aliases || []).slice(0, 2).map((x) => ({ icon: 'terminal', label: t('menu.copyAlias'), hint: x, run: () => copyText(x) })),
|
|
570
|
+
a.kind !== 'api' ? { icon: 'key', label: t('menu.copyLogin'), hint: 'sideby login ' + a.ref, run: () => copyText('sideby login ' + a.ref) } : null,
|
|
571
|
+
a.dir ? { icon: 'folder', label: t('menu.copyDir'), hint: a.dir, run: () => copyText(a.dir) } : null,
|
|
572
|
+
a.identity && a.identity.email ? { icon: 'mail', label: t('menu.copyEmail'), hint: emailText(a.identity.email), run: () => copyText(a.identity.email) } : null,
|
|
573
|
+
'-',
|
|
574
|
+
{ icon: 'check', label: t('menu.check'), disabled: S.checking || S.fixing || Boolean(ui.checkingRef), run: () => checkAccount(a.ref) },
|
|
575
|
+
BOOT.readOnly ? null : { icon: 'userPlus', label: t('menu.like'), run: () => openCreate({ family: a.family, api: a.kind === 'api' }) },
|
|
576
|
+
]
|
|
577
|
+
openMenu(anchor, items, t('menu.label', { ref: a.ref }))
|
|
578
|
+
}
|
|
579
|
+
function moreButton(a) {
|
|
580
|
+
const b = h('button', { class: 'icon-btn more', type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': 'false', 'aria-label': t('menu.label', { ref: a.ref }), title: t('menu.title') }, icon('more'))
|
|
581
|
+
b.addEventListener('click', () => { if (menu.anchor === b) closeMenu(); else accountMenu(a, b) })
|
|
582
|
+
return b
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---------- Doctor history ----------
|
|
586
|
+
// Health comes from the Doctor history, which keeps the latest result per Account: an Account that was
|
|
587
|
+
// never checked shows "Not checked", never "Healthy".
|
|
588
|
+
function entries() {
|
|
589
|
+
const hist = S.history
|
|
590
|
+
return hist ? Object.values(hist.general).concat(Object.values(hist.accounts)) : []
|
|
591
|
+
}
|
|
592
|
+
function findingsNow() {
|
|
593
|
+
const list = entries()
|
|
594
|
+
return list.length ? [].concat(...list.map((e) => e.findings)) : null
|
|
595
|
+
}
|
|
596
|
+
function lastCheckAt() {
|
|
597
|
+
const times = entries().map((e) => e.at).sort()
|
|
598
|
+
return times.length ? times[times.length - 1] : null
|
|
599
|
+
}
|
|
600
|
+
function healthOf(ref) {
|
|
601
|
+
const e = S.history && S.history.accounts[ref]
|
|
602
|
+
if (!e) return null
|
|
603
|
+
let fail = 0, warn = 0
|
|
604
|
+
for (const f of e.findings) { if (f.level === 'fail') fail++; else if (f.level === 'warn') warn++ }
|
|
605
|
+
return { fail, warn }
|
|
606
|
+
}
|
|
607
|
+
function reasonsOf(a, now) { return attentionReasons(a, healthOf(a.ref), now, WARN) }
|
|
608
|
+
function setupFor(fam) { return S.state && S.state.quotaSetups.find((s) => s.family === fam) }
|
|
609
|
+
|
|
610
|
+
// ---------- header and summary ----------
|
|
611
|
+
function renderUpdated() {
|
|
612
|
+
const busy = S.loading && !S.silent
|
|
613
|
+
const text = S.loading && !S.state ? t('top.loading') : busy ? t('top.refreshing') : S.loadedAt ? t('top.updated', { ago: agoShort(Date.now() - S.loadedAt) }) : ''
|
|
614
|
+
const el = $('updated')
|
|
615
|
+
if (el.textContent !== text) el.textContent = text
|
|
616
|
+
el.title = S.loadedAt ? t('top.updatedTitle', { at: clock(new Date(S.loadedAt).toISOString()), s: Math.round((S.fastUntil > Date.now() ? FAST_MS : AUTO_MS) / 1000) }) : ''
|
|
617
|
+
}
|
|
618
|
+
function renderTop() {
|
|
619
|
+
renderUpdated()
|
|
620
|
+
// A background refresh keeps the button still; only one the viewer asked for spins it.
|
|
621
|
+
const busy = S.loading && !S.silent
|
|
622
|
+
const refresh = $('refresh')
|
|
623
|
+
refresh.disabled = busy
|
|
624
|
+
refresh.classList.toggle('is-spinning', busy)
|
|
625
|
+
if (busy) refresh.setAttribute('aria-busy', 'true'); else refresh.removeAttribute('aria-busy')
|
|
626
|
+
}
|
|
627
|
+
function renderStatic() {
|
|
628
|
+
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'
|
|
629
|
+
for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.getAttribute('data-i18n'))
|
|
630
|
+
$('refresh').setAttribute('aria-label', t('top.refresh'))
|
|
631
|
+
const lb = $('lang')
|
|
632
|
+
lb.hidden = LANG_FIXED
|
|
633
|
+
lb.textContent = lang === 'zh' ? 'EN' : '中文'
|
|
634
|
+
lb.setAttribute('aria-label', t('top.language'))
|
|
635
|
+
lb.title = t('top.language')
|
|
636
|
+
const tb = $('theme')
|
|
637
|
+
tb.hidden = BOOT.colorScheme !== 'auto'
|
|
638
|
+
setKids(tb, icon(prefs.theme === 'light' ? 'sun' : prefs.theme === 'dark' ? 'moon' : 'monitor'))
|
|
639
|
+
tb.title = t('theme.' + prefs.theme)
|
|
640
|
+
tb.setAttribute('aria-label', t('theme.' + prefs.theme))
|
|
641
|
+
}
|
|
642
|
+
function applyTheme() {
|
|
643
|
+
if (BOOT.colorScheme !== 'auto') return
|
|
644
|
+
if (prefs.theme === 'system') document.documentElement.removeAttribute('data-theme')
|
|
645
|
+
else document.documentElement.setAttribute('data-theme', prefs.theme)
|
|
646
|
+
}
|
|
647
|
+
function stat(label, value, sub, extra, opts) {
|
|
648
|
+
const kids = [h('div', { class: 'stat-text' }, h('div', { class: 'stat-label' }, label), h('div', { class: 'stat-value' }, value), h('div', { class: 'stat-sub' }, sub)), extra]
|
|
649
|
+
if (opts && opts.onclick) return h('button', { class: 'stat stat-btn' + (opts.cls ? ' ' + opts.cls : ''), type: 'button', 'aria-pressed': String(Boolean(opts.pressed)), 'aria-label': t('stat.btnLabel', { label, value, sub, action: opts.title || '' }), title: opts.title, onclick: opts.onclick }, kids)
|
|
650
|
+
return h('div', { class: 'stat' + (opts && opts.cls ? ' ' + opts.cls : '') }, kids)
|
|
651
|
+
}
|
|
652
|
+
function spark(daily, cls) {
|
|
653
|
+
if (!Array.isArray(daily) || !daily.length) return null
|
|
654
|
+
const max = Math.max(0, ...daily.map((d) => Number(d.totalTokens) || 0))
|
|
655
|
+
return h('span', { class: 'spark' + (cls ? ' ' + cls : ''), role: 'img', 'aria-label': t('usage.sparkLabel', { n: daily.length }) },
|
|
656
|
+
daily.map((d, i) => {
|
|
657
|
+
const v = Number(d.totalTokens) || 0
|
|
658
|
+
const date = new Date(d.date + 'T12:00:00')
|
|
659
|
+
const day = Number.isFinite(date.getTime()) ? date.toLocaleDateString(locale(), { weekday: 'short', month: 'numeric', day: 'numeric' }) : d.date
|
|
660
|
+
// Every day keeps its slot; an empty day shows only a short stub on the baseline, so it reads as a gap in time.
|
|
661
|
+
return h('i', { class: (i === daily.length - 1 ? 'today' : '') + (v ? '' : ' zero') || null, title: day + ': ' + tokens(v) },
|
|
662
|
+
h('b', { css: { height: v && max ? Math.max(10, Math.round((v / max) * 100)) + '%' : null } }))
|
|
663
|
+
}))
|
|
664
|
+
}
|
|
665
|
+
function renderSummary() {
|
|
666
|
+
const box = $('summary')
|
|
667
|
+
if (!S.state) { if (S.loadError) box.hidden = true; return }
|
|
668
|
+
const list = S.state.accounts
|
|
669
|
+
box.hidden = !list.length
|
|
670
|
+
box.removeAttribute('aria-busy')
|
|
671
|
+
if (!list.length) return
|
|
672
|
+
const now = Date.now()
|
|
673
|
+
const famCount = new Set(list.map((a) => a.family)).size
|
|
674
|
+
const apiCount = list.filter((a) => a.kind === 'api').length
|
|
675
|
+
const counts = {}
|
|
676
|
+
let need = 0
|
|
677
|
+
for (const a of list) {
|
|
678
|
+
const rs = reasonsOf(a, now)
|
|
679
|
+
if (rs.length) need++
|
|
680
|
+
for (const r of rs) counts[r] = (counts[r] || 0) + 1
|
|
681
|
+
}
|
|
682
|
+
const order = ['error', 'health-fail', 'signed-out', 'not-installed', 'quota-high', 'health-warn']
|
|
683
|
+
const needSub = need ? order.filter((r) => counts[r]).slice(0, 2).map((r) => tn('need.' + r, counts[r])).join(' · ') : t('stat.allClear')
|
|
684
|
+
const usages = list.filter((a) => a.usage && a.usage.status === 'ok')
|
|
685
|
+
const total = usages.reduce((s, a) => s + (Number(a.usage.totalTokens) || 0), 0)
|
|
686
|
+
const sessions = usages.reduce((s, a) => s + (Number(a.usage.sessions) || 0), 0)
|
|
687
|
+
const daily = mergeDaily(usages.map((a) => a.usage.daily)).slice(-BOOT.usageDays)
|
|
688
|
+
const nr = nextReset(list, now)
|
|
689
|
+
patch(box, [
|
|
690
|
+
stat(t('stat.accounts'), String(list.length), dots([tn('stat.families', famCount), apiCount ? tn('stat.api', apiCount) : null])),
|
|
691
|
+
need
|
|
692
|
+
? stat(t('stat.attention'), String(need), needSub, null, { cls: 'stat-warn', pressed: ui.attention, title: t(ui.attention ? 'stat.attentionOff' : 'stat.attentionOn'), onclick: () => { ui.attention = !ui.attention; renderSummary(); renderAccounts() } })
|
|
693
|
+
: stat(t('stat.attention'), '0', needSub, null, { cls: 'stat-ok' }),
|
|
694
|
+
stat(t('stat.tokens', { n: BOOT.usageDays }), tokens(total), tn('stat.sessions', sessions), spark(daily, 'spark-lg')),
|
|
695
|
+
stat(h('span', { class: 'label-tip' }, t('stat.nextReset'), tip('age')),
|
|
696
|
+
nr ? t('time.in', { d: formatDuration(nr.at - now, lang) }) : '—',
|
|
697
|
+
nr ? dots([h('span', { class: 'mono' }, nr.ref), nr.label, clock(new Date(nr.at).toISOString())]) : t('stat.noQuota')),
|
|
698
|
+
])
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ---------- toolbar ----------
|
|
702
|
+
const tb = {}
|
|
703
|
+
function seg(label, options, value, onPick) {
|
|
704
|
+
const btns = options.map((o) => h('button', { class: 'seg-btn', type: 'button', 'aria-pressed': String(o[0] === value), title: o[2] || null, 'aria-label': o[2] || t('tb.segLabel', { group: label, option: o[1] }), onclick: () => {
|
|
705
|
+
for (const b of btns) b.setAttribute('aria-pressed', String(b === btn(o[0])))
|
|
706
|
+
onPick(o[0])
|
|
707
|
+
} }, o[1]))
|
|
708
|
+
const btn = (v) => btns[options.findIndex((o) => o[0] === v)]
|
|
709
|
+
return h('div', { class: 'seg', role: 'group', 'aria-label': label }, btns)
|
|
710
|
+
}
|
|
711
|
+
function buildToolbar() {
|
|
712
|
+
const hadFocus = tb.search && document.activeElement === tb.search
|
|
713
|
+
tb.search = h('input', { type: 'search', id: 'q', class: 'search-input', autocomplete: 'off', spellcheck: 'false', 'aria-label': t('tb.search'), placeholder: t('tb.searchPh'), 'aria-keyshortcuts': '/ Meta+F Control+F' })
|
|
714
|
+
tb.search.value = ui.query
|
|
715
|
+
tb.search.addEventListener('input', () => { ui.query = tb.search.value; renderAccounts() })
|
|
716
|
+
tb.search.addEventListener('keydown', (e) => {
|
|
717
|
+
if (e.key !== 'Escape') return
|
|
718
|
+
e.preventDefault()
|
|
719
|
+
if (tb.search.value) { tb.search.value = ''; ui.query = ''; renderAccounts() } else tb.search.blur()
|
|
720
|
+
})
|
|
721
|
+
tb.family = h('select', { class: 'tb-select', 'aria-label': t('tb.family') })
|
|
722
|
+
tb.family.addEventListener('change', () => { prefs.family = tb.family.value; savePrefs(); renderAccounts() })
|
|
723
|
+
tb.sort = h('select', { class: 'tb-select', 'aria-label': t('tb.sort') },
|
|
724
|
+
CHOICES.sort.map((v) => h('option', { value: v }, t('sort.' + v))))
|
|
725
|
+
tb.sort.value = prefs.sort
|
|
726
|
+
tb.sort.addEventListener('change', () => { prefs.sort = tb.sort.value; savePrefs(); renderAccounts() })
|
|
727
|
+
const quota = seg(t('tb.quota'), [['used', t('tb.used')], ['left', t('tb.left')]], prefs.quota, (v) => { prefs.quota = v; savePrefs(); renderAccounts() })
|
|
728
|
+
const view = seg(t('tb.view'), [['list', icon('list'), t('tb.list')], ['cards', icon('grid'), t('tb.cards')]], prefs.view, (v) => { prefs.view = v; savePrefs(); ui.fade = true; renderAccounts() })
|
|
729
|
+
tb.hideEmail = h('button', { class: 'icon-btn tb-icon', type: 'button', 'aria-pressed': String(prefs.hideEmail), onclick: () => {
|
|
730
|
+
prefs.hideEmail = !prefs.hideEmail
|
|
731
|
+
savePrefs()
|
|
732
|
+
renderEmailToggle()
|
|
733
|
+
renderAccounts()
|
|
734
|
+
} })
|
|
735
|
+
const create = BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', 'aria-label': t('tb.new'), onclick: () => openCreate(null) }, icon('plus'), h('span', null, t('tb.new')))
|
|
736
|
+
setKids($('toolbar'), [
|
|
737
|
+
h('label', { class: 'search' }, icon('search', 'search-ic'), tb.search, h('kbd', { class: 'kbd', 'aria-hidden': 'true' }, '/')),
|
|
738
|
+
h('div', { class: 'tools' }, tb.family, tb.sort, quota, view, tb.hideEmail),
|
|
739
|
+
create,
|
|
740
|
+
])
|
|
741
|
+
renderFamilyFilter()
|
|
742
|
+
renderEmailToggle()
|
|
743
|
+
if (hadFocus) tb.search.focus()
|
|
744
|
+
}
|
|
745
|
+
// "Hide emails": shown once any account has one; its icon and label say what a click does.
|
|
746
|
+
function renderEmailToggle() {
|
|
747
|
+
const b = tb.hideEmail
|
|
748
|
+
if (!b) return
|
|
749
|
+
b.hidden = !(S.state && S.state.accounts.some((a) => a.identity && a.identity.email))
|
|
750
|
+
b.setAttribute('aria-pressed', String(prefs.hideEmail))
|
|
751
|
+
const label = t(prefs.hideEmail ? 'tb.showEmails' : 'tb.hideEmails')
|
|
752
|
+
b.setAttribute('aria-label', label)
|
|
753
|
+
b.title = label
|
|
754
|
+
setKids(b, icon(prefs.hideEmail ? 'eyeOff' : 'eye'))
|
|
755
|
+
}
|
|
756
|
+
function renderFamilyFilter() {
|
|
757
|
+
if (!tb.family) return
|
|
758
|
+
const fams = S.state ? S.state.families.filter((f) => S.state.accounts.some((a) => a.family === f.id)) : []
|
|
759
|
+
tb.family.replaceChildren(h('option', { value: '' }, t('tb.allFamilies')),
|
|
760
|
+
...fams.map((f) => h('option', { value: f.id }, f.title + ' (' + S.state.accounts.filter((a) => a.family === f.id).length + ')')))
|
|
761
|
+
if (prefs.family && S.state && !fams.some((f) => f.id === prefs.family)) prefs.family = ''
|
|
762
|
+
tb.family.value = prefs.family
|
|
763
|
+
tb.family.hidden = fams.length < 2 && !prefs.family
|
|
764
|
+
}
|
|
765
|
+
document.addEventListener('keydown', (e) => {
|
|
766
|
+
if (dlg.el && dlg.el.open) return
|
|
767
|
+
const el = e.target
|
|
768
|
+
const typing = el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)
|
|
769
|
+
const find = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')
|
|
770
|
+
if ((find || (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey)) && tb.search && S.state && S.state.accounts.length) {
|
|
771
|
+
e.preventDefault()
|
|
772
|
+
tb.search.focus()
|
|
773
|
+
tb.search.select()
|
|
774
|
+
}
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
// ---------- accounts ----------
|
|
778
|
+
// One column per Quota window label. 5h and 7d always have one, so rows of every group line up under the same
|
|
779
|
+
// header; a label only some account reports (such as 60m) adds a column, at most three in all.
|
|
780
|
+
const KNOWN_WINDOWS = { '5h': 300, '7d': 10080 }
|
|
781
|
+
function quotaColumns(list) {
|
|
782
|
+
const mins = Object.assign({}, KNOWN_WINDOWS)
|
|
783
|
+
for (const a of list)
|
|
784
|
+
if (a.quota && a.quota.status === 'ok')
|
|
785
|
+
for (const w of a.quota.windows || []) if (!Object.hasOwn(mins, w.label)) mins[w.label] = Number(w.windowMinutes) || 0
|
|
786
|
+
return Object.keys(mins).sort((x, y) => mins[x] - mins[y]).slice(0, 3)
|
|
787
|
+
}
|
|
788
|
+
// A window's cell: percent and countdown on one line, the bar under them. The label shows where no column
|
|
789
|
+
// header does (cards, narrow screens).
|
|
790
|
+
function meter(w, now, stale, observedAt) {
|
|
791
|
+
const st = windowState(w, now, WARN, FAIL)
|
|
792
|
+
const passed = passedWindow(w, observedAt, now)
|
|
793
|
+
const left = prefs.quota === 'left'
|
|
794
|
+
const shown = left ? 100 - st.pct : st.pct
|
|
795
|
+
const title = passed ? t('quota.resetNoData', { date: day(passed.since) }) : t('quota.resetsAt', { at: clock(w.resetsAt) })
|
|
796
|
+
return h('div', { class: 'meter lvl-' + st.level + (stale || passed ? ' lvl-stale' : ''), title },
|
|
797
|
+
h('div', { class: 'm-top' },
|
|
798
|
+
h('span', { class: 'm-label' }, w.label),
|
|
799
|
+
h('span', { class: 'm-pct' }, shown + '%', h('span', { class: 'm-unit' }, ' ' + t(left ? 'quota.unitLeft' : 'quota.unitUsed'))),
|
|
800
|
+
h('span', { class: 'm-reset' + (passed ? ' m-passed' : '') }, passed ? t('quota.resetShort') : countdown(w.resetsAt, now))),
|
|
801
|
+
h('div', { class: 'bar', role: 'meter', 'aria-label': t(left ? 'quota.meterLeft' : 'quota.meterUsed', { label: w.label }), 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(shown), 'aria-valuetext': t(left ? 'quota.pctLeft' : 'quota.pctUsed', { n: shown }) }, h('span', { css: { width: shown + '%' } })))
|
|
802
|
+
}
|
|
803
|
+
// A column whose window this account does not report right now, such as a 5h window that has not started.
|
|
804
|
+
function noMeter(label) {
|
|
805
|
+
return h('div', { class: 'meter meter-none', title: t('quota.missing', { label }) },
|
|
806
|
+
h('div', { class: 'm-top' }, h('span', { class: 'm-label' }, label), h('span', { class: 'm-none', 'aria-label': t('quota.missing', { label }) }, '—')))
|
|
807
|
+
}
|
|
808
|
+
function quotaNote(a, q) {
|
|
809
|
+
const setup = setupFor(a.family)
|
|
810
|
+
// One setup turns quota on for every account of the Family, so the switch lives in the group header only.
|
|
811
|
+
if (q.reason === 'not-enabled') {
|
|
812
|
+
const host = famTitle(a)
|
|
813
|
+
const body = setup && setup.plan.status === 'ready' && !BOOT.readOnly ? t('tip.quotaOff.body', { host })
|
|
814
|
+
: setup && setup.plan.status === 'blocked' ? setup.plan.message
|
|
815
|
+
: t('tip.quotaOff.cli', { host, family: a.family })
|
|
816
|
+
return h('div', { class: 'q-note q-off' }, h('span', { class: 'muted' }, t('quota.off')), tipRaw(t('tip.quotaOff.term'), body))
|
|
817
|
+
}
|
|
818
|
+
if (q.reason === 'api-account') return h('div', { class: 'q-note' }, t('quota.api'), tip('api'))
|
|
819
|
+
const text = { 'no-session': 'quota.noSession', 'no-source': 'quota.noSource', unrecognized: 'quota.unrecognized' }[q.reason]
|
|
820
|
+
return h('div', { class: 'q-note', title: q.reason === 'no-session' ? t('quota.noSessionHint') : q.detail || null }, text ? t(text) : t('quota.unavailable', { reason: q.reason }))
|
|
821
|
+
}
|
|
822
|
+
function quotaBlock(a, now, cols) {
|
|
823
|
+
const q = a.quota
|
|
824
|
+
if (!q) return h('div', { class: 'quota' }, h('div', { class: 'q-note' }, '—'))
|
|
825
|
+
if (q.status !== 'ok') return h('div', { class: 'quota' }, quotaNote(a, q))
|
|
826
|
+
const stale = isStale(q.observedAt, now)
|
|
827
|
+
const wins = q.windows || []
|
|
828
|
+
return h('div', { class: 'quota' + (stale ? ' is-stale' : ''), title: stale ? null : t('quota.dataFrom', { ago: ago(q.observedAt) }) },
|
|
829
|
+
wins.length ? cols.map((c) => {
|
|
830
|
+
const w = wins.find((x) => x.label === c)
|
|
831
|
+
return w ? meter(w, now, stale, q.observedAt) : noMeter(c)
|
|
832
|
+
}) : h('div', { class: 'q-note' }, t('quota.noWindows')),
|
|
833
|
+
stale ? h('div', { class: 'q-asof' }, icon('clock'), t('quota.asOf', { ago: ago(q.observedAt) })) : null)
|
|
834
|
+
}
|
|
835
|
+
function usageBlock(a) {
|
|
836
|
+
const u = a.usage
|
|
837
|
+
if (!u) return h('div', { class: 'usage' })
|
|
838
|
+
if (u.status !== 'ok') {
|
|
839
|
+
const key = u.reason === 'no-session' ? 'usage.none' : u.reason === 'no-source' ? 'usage.noSource' : 'usage.unreadable'
|
|
840
|
+
return h('div', { class: 'usage' }, h('span', { class: 'u-note', title: u.detail || null }, t(key, { n: BOOT.usageDays })))
|
|
841
|
+
}
|
|
842
|
+
const rate = cacheHitRate(u)
|
|
843
|
+
const detail = t('usage.detail', { days: u.days, sessions: u.sessions, input: tokens(u.inputTokens), output: tokens(u.outputTokens), cache: tokens(u.cacheReadTokens + u.cacheWriteTokens) })
|
|
844
|
+
return h('div', { class: 'usage', title: detail },
|
|
845
|
+
h('div', { class: 'u-text' },
|
|
846
|
+
h('span', { class: 'u-num' }, tokens(u.totalTokens), h('span', { class: 'u-unit' }, ' ' + t('usage.tokens'))),
|
|
847
|
+
h('div', { class: 'u-sub' }, rate === null ? tn('usage.sessions', u.sessions) : t('usage.cache', { n: Math.round(rate * 100) }))),
|
|
848
|
+
u.totalTokens ? spark(u.daily) : null)
|
|
849
|
+
}
|
|
850
|
+
function lastUsed(a) {
|
|
851
|
+
const at = a.usage && a.usage.status === 'ok' && a.usage.lastActivityAt
|
|
852
|
+
return h('div', { class: 'last', title: at ? t('last.title', { at: clock(at) }) : t('last.unknown') }, at ? ago(at) : h('span', { class: 'faint' }, '—'))
|
|
853
|
+
}
|
|
854
|
+
function healthChip(a) {
|
|
855
|
+
const hs = healthOf(a.ref)
|
|
856
|
+
let lvl = ''
|
|
857
|
+
let text
|
|
858
|
+
if (ui.checkingRef === a.ref || (S.checking && !hs)) text = t('health.checking')
|
|
859
|
+
else if (a.error) { lvl = 'fail'; text = t('health.error') }
|
|
860
|
+
else if (!hs) text = t('health.notChecked')
|
|
861
|
+
else if (hs.fail) { lvl = 'fail'; text = tn('health.issues', hs.fail) }
|
|
862
|
+
else if (hs.warn) { lvl = 'warn'; text = tn('health.warnings', hs.warn) }
|
|
863
|
+
else { lvl = 'ok'; text = t('health.ok') }
|
|
864
|
+
return h('button', { class: 'chip chip-btn health' + (lvl ? ' chip-' + lvl : ''), type: 'button', title: t('health.filterHint'), 'aria-label': t('health.chipLabel', { ref: a.ref, status: text }), onclick: () => filterHealth(a.ref) },
|
|
865
|
+
h('span', { class: 'dot' + (lvl ? ' dot-' + lvl : ''), 'aria-hidden': 'true' }), text)
|
|
866
|
+
}
|
|
867
|
+
function nameLine(a) {
|
|
868
|
+
return h('div', { class: 'ident-name' },
|
|
869
|
+
h('span', { class: 'name', title: a.name }, a.name),
|
|
870
|
+
(a.aliases || []).slice(0, 3).map((x) => {
|
|
871
|
+
const args = aliasArgsOf(a, x)
|
|
872
|
+
return h('span', { class: 'tag tag-alias', title: args ? t('acct.aliasArgs', { args: args.join(' ') }) : t('acct.alias') }, x)
|
|
873
|
+
}),
|
|
874
|
+
a.kind === 'api' ? h('span', { class: 'tag tag-warn', title: t('tip.api.body') }, t('acct.api')) : null,
|
|
875
|
+
isNext(a)
|
|
876
|
+
? h('button', { class: 'tag tag-ok tag-btn', type: 'button', title: t('acct.nextHint', { family: a.family }), 'aria-label': t('acct.nextLabel', { ref: a.ref, family: a.family }), onclick: () => copyText('sideby next ' + a.family) }, t('acct.next'))
|
|
877
|
+
: null,
|
|
878
|
+
a.login === 'logged-out' && a.kind !== 'api'
|
|
879
|
+
? h('button', { class: 'tag tag-fail tag-btn', type: 'button', title: t('acct.signInHint'), 'aria-label': t('acct.signedOutLabel', { ref: a.ref }), onclick: () => copyText('sideby login ' + a.ref) }, h('span', { class: 'dot dot-fail', 'aria-hidden': 'true' }), t('acct.signedOut'))
|
|
880
|
+
: null,
|
|
881
|
+
a.hostInstalled === false ? h('span', { class: 'tag tag-warn' }, t('acct.notInstalled')) : null)
|
|
882
|
+
}
|
|
883
|
+
// Who the account is signed in as, under its name; masked while "Hide emails" is on.
|
|
884
|
+
function emailLine(a) {
|
|
885
|
+
const id = a.identity
|
|
886
|
+
if (!id || (!id.email && !id.org)) return null
|
|
887
|
+
const email = id.email ? emailText(id.email) : ''
|
|
888
|
+
return h('div', { class: 'ident-email', title: [email, id.org].filter(Boolean).join(' · ') },
|
|
889
|
+
dots([email ? h('span', { class: 'email' }, email) : null, id.org ? h('span', { class: 'org' }, id.org) : null]))
|
|
890
|
+
}
|
|
891
|
+
function subLine(a) {
|
|
892
|
+
const plan = a.quota && a.quota.status === 'ok' && a.quota.plan
|
|
893
|
+
return h('div', { class: 'ident-sub' }, dots([
|
|
894
|
+
h('span', { class: 'mono' }, a.ref),
|
|
895
|
+
a.kind === 'api' && a.model ? h('span', { class: 'mono', title: t('acct.model') }, a.model) : null,
|
|
896
|
+
a.kind !== 'api' && plan ? h('span', { class: 'plan' }, plan) : null,
|
|
897
|
+
]))
|
|
898
|
+
}
|
|
899
|
+
function notes(a) {
|
|
900
|
+
const fam = family(a.family)
|
|
901
|
+
const out = []
|
|
902
|
+
if (a.error) out.push(h('div', { class: 'note note-fail' }, h('b', null, t('acct.readError')), ' ', h('span', null, a.error)))
|
|
903
|
+
if (a.hostInstalled === false) out.push(h('div', { class: 'note note-warn' }, tx('acct.installHost', { host: fam.title, link: link(t('acct.installIt'), fam.installUrl) || '' })))
|
|
904
|
+
return out.length ? h('div', { class: 'row-notes' }, out) : null
|
|
905
|
+
}
|
|
906
|
+
// What a row or card shows depends on more than the account (health, view preferences, language); the
|
|
907
|
+
// fingerprint covers what its click handlers use.
|
|
908
|
+
function rowSig(a) { return sig([a, healthOf(a.ref), isNext(a)]) }
|
|
909
|
+
// The Account 'sideby next <family>' would pick; the server ranks it with the CLI's rule (spec §3.13).
|
|
910
|
+
function isNext(a) { return Boolean(S.state && S.state.handoff && S.state.handoff[a.family] === a.ref) }
|
|
911
|
+
function row(a, now, cols) {
|
|
912
|
+
return h('div', { class: 'row' + (a.error ? ' row-error' : ''), role: 'listitem', 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a) },
|
|
913
|
+
h('div', { class: 'ident' }, badge(a.family, 'sm'), h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a))),
|
|
914
|
+
quotaBlock(a, now, cols),
|
|
915
|
+
usageBlock(a),
|
|
916
|
+
lastUsed(a),
|
|
917
|
+
h('div', { class: 'cell-health' }, healthChip(a)),
|
|
918
|
+
moreButton(a),
|
|
919
|
+
notes(a))
|
|
920
|
+
}
|
|
921
|
+
function card(a, now, cols) {
|
|
922
|
+
return h('article', { class: 'card' + (a.error ? ' card-error' : ''), 'data-ref': a.ref, 'data-key': a.ref, 'data-sig': rowSig(a), 'aria-label': a.ref },
|
|
923
|
+
h('header', { class: 'card-head' },
|
|
924
|
+
badge(a.family, 'sm'),
|
|
925
|
+
h('div', { class: 'ident-text' }, nameLine(a), emailLine(a), subLine(a)),
|
|
926
|
+
moreButton(a)),
|
|
927
|
+
notes(a),
|
|
928
|
+
quotaBlock(a, now, cols),
|
|
929
|
+
h('footer', { class: 'card-foot' }, usageBlock(a), h('div', { class: 'card-side' }, lastUsed(a), healthChip(a))))
|
|
930
|
+
}
|
|
931
|
+
// The list's column titles, once per group under its header. Screen readers get each cell's own label instead.
|
|
932
|
+
function colHead(cols, list) {
|
|
933
|
+
const left = prefs.quota === 'left'
|
|
934
|
+
// A group where no account reports windows (no public source, quota off) keeps the columns but not their titles.
|
|
935
|
+
const titled = list.some((a) => a.quota && a.quota.status === 'ok')
|
|
936
|
+
return h('div', { class: 'col-head', 'aria-hidden': 'true', 'data-key': 'cols' },
|
|
937
|
+
h('span', { class: 'ch-id' }, t('col.account')),
|
|
938
|
+
h('span', { class: 'quota' }, titled ? cols.map((c) => h('span', null, t(left ? 'col.left' : 'col.used', { label: c }))) : null),
|
|
939
|
+
h('span', { class: 'ch-usage' }, t('col.tokens', { n: BOOT.usageDays })),
|
|
940
|
+
h('span', { class: 'ch-last' }, t('col.last')),
|
|
941
|
+
h('span', { class: 'ch-health' }, t('col.health')),
|
|
942
|
+
h('span', { class: 'ch-more' }))
|
|
943
|
+
}
|
|
944
|
+
// Quota is on but no account of the Family has numbers yet: say what brings the first ones.
|
|
945
|
+
function waitingForQuota(fam) {
|
|
946
|
+
const setup = setupFor(fam)
|
|
947
|
+
if (!setup || setup.plan.status !== 'enabled') return false
|
|
948
|
+
const all = S.state.accounts.filter((a) => a.family === fam && a.kind !== 'api')
|
|
949
|
+
const waiting = all.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'no-session')
|
|
950
|
+
const any = all.some((a) => a.quota && a.quota.status === 'ok')
|
|
951
|
+
return waiting && !any
|
|
952
|
+
}
|
|
953
|
+
function group(fam, list, total, now, cols) {
|
|
954
|
+
const info = family(fam)
|
|
955
|
+
const filtering = Boolean(ui.query.trim()) || ui.attention
|
|
956
|
+
const collapsed = !filtering && prefs.collapsed[fam] === true
|
|
957
|
+
const need = list.filter((a) => reasonsOf(a, now).length).length
|
|
958
|
+
const setup = setupFor(fam)
|
|
959
|
+
const off = list.some((a) => a.quota && a.quota.status === 'unavailable' && a.quota.reason === 'not-enabled')
|
|
960
|
+
const bodyId = 'grp-' + fam.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
961
|
+
const cards = prefs.view === 'cards'
|
|
962
|
+
const head = h('div', { class: 'group-head', 'data-key': 'head' },
|
|
963
|
+
h('button', { class: 'group-toggle', type: 'button', 'aria-expanded': String(!collapsed), 'aria-controls': bodyId, title: t(collapsed ? 'group.expand' : 'group.collapse'), 'aria-label': t('group.toggle', { title: info.title, count: list.length === total ? String(total) : list.length + ' / ' + total, action: t(collapsed ? 'group.expand' : 'group.collapse') }), onclick: () => {
|
|
964
|
+
if (collapsed) delete prefs.collapsed[fam]; else prefs.collapsed[fam] = true
|
|
965
|
+
savePrefs()
|
|
966
|
+
renderAccounts()
|
|
967
|
+
} },
|
|
968
|
+
icon('chevron', 'chev'), badge(fam, 'sm'), h('span', { class: 'group-title' }, info.title),
|
|
969
|
+
h('span', { class: 'group-count' }, list.length === total ? String(total) : list.length + ' / ' + total)),
|
|
970
|
+
h('div', { class: 'group-meta' },
|
|
971
|
+
waitingForQuota(fam) ? h('span', { class: 'group-wait', role: 'status' }, h('span', { class: 'pulse', 'aria-hidden': 'true' }), t('quota.waiting', { host: info.title })) : null,
|
|
972
|
+
need ? h('span', { class: 'tag tag-warn' }, tn('group.attention', need)) : null,
|
|
973
|
+
off && setup && setup.plan.status === 'ready' && !BOOT.readOnly
|
|
974
|
+
? h('span', { class: 'group-quota' },
|
|
975
|
+
h('span', { class: 'muted small' }, t('quota.turnOnFamilyNote', { host: info.title })),
|
|
976
|
+
h('button', { class: 'btn btn-sm', type: 'button', title: setup.summary, 'aria-label': t('quota.turnOnFamilyLabel', { host: info.title }), onclick: () => openSetup(fam) }, t('quota.turnOnFamily')))
|
|
977
|
+
: null,
|
|
978
|
+
off && setup && setup.plan.status === 'blocked' ? h('span', { class: 'muted small label-tip' }, t('quota.blocked'), tipRaw(t('quota.blocked'), setup.plan.message)) : null))
|
|
979
|
+
const body = collapsed ? null : cards
|
|
980
|
+
? h('div', { class: 'grid', id: bodyId, 'data-key': 'cards', 'data-patch': '' }, list.map((a) => card(a, now, cols)))
|
|
981
|
+
: h('div', { class: 'rows', id: bodyId, role: 'list', 'data-key': 'rows', 'data-patch': '' }, list.map((a) => row(a, now, cols)))
|
|
982
|
+
return h('section', { class: 'group' + (cards ? ' group-cards' : ' group-list') + (collapsed ? ' is-collapsed' : ''), 'aria-label': info.title, 'data-key': 'g:' + fam, 'data-patch': '' },
|
|
983
|
+
head, collapsed || cards ? null : colHead(cols, list), body)
|
|
984
|
+
}
|
|
985
|
+
function emptyState() {
|
|
986
|
+
const fams = S.state.families.slice().sort((x, y) => Number(y.installed) - Number(x.installed))
|
|
987
|
+
return h('div', { class: 'empty' },
|
|
988
|
+
h('span', { class: 'logo', 'aria-hidden': 'true' }, h('i'), h('i')),
|
|
989
|
+
h('h3', null, t('empty.title')),
|
|
990
|
+
h('p', { class: 'muted' }, t('empty.body')),
|
|
991
|
+
fams.length ? h('ul', { class: 'host-list' }, fams.map((f) => h('li', null, badge(f.id, 'sm'), h('span', null, f.title),
|
|
992
|
+
f.installed ? h('span', { class: 'muted small' }, tx('empty.runOnce', { bin: h('code', null, f.bin) })) : link(t('empty.install'), f.installUrl, 'btn btn-sm')))) : h('p', { class: 'muted small' }, t('empty.noFamilies')),
|
|
993
|
+
BOOT.readOnly ? null : h('button', { class: 'btn', type: 'button', onclick: () => openCreate(null) }, icon('plus'), t('empty.create')))
|
|
994
|
+
}
|
|
995
|
+
function clearFilters() {
|
|
996
|
+
ui.query = ''
|
|
997
|
+
ui.attention = false
|
|
998
|
+
prefs.family = ''
|
|
999
|
+
savePrefs()
|
|
1000
|
+
if (tb.search) tb.search.value = ''
|
|
1001
|
+
if (tb.family) tb.family.value = ''
|
|
1002
|
+
renderSummary()
|
|
1003
|
+
renderAccounts()
|
|
1004
|
+
}
|
|
1005
|
+
function noMatch() {
|
|
1006
|
+
return h('div', { class: 'empty empty-sm' },
|
|
1007
|
+
h('h3', null, t('filter.none')),
|
|
1008
|
+
h('p', { class: 'muted' }, ui.query.trim() ? t('filter.noneQuery', { q: ui.query.trim() }) : t('filter.noneBody')),
|
|
1009
|
+
h('button', { class: 'btn', type: 'button', onclick: clearFilters }, t('filter.clear')))
|
|
1010
|
+
}
|
|
1011
|
+
function loadErrorBlock() {
|
|
1012
|
+
return h('div', { class: 'empty empty-sm empty-fail', role: 'alert' },
|
|
1013
|
+
h('h3', null, t('load.failed')),
|
|
1014
|
+
h('p', null, S.loadError.message),
|
|
1015
|
+
h('button', { class: 'btn', type: 'button', onclick: () => load({ check: !S.report }) }, icon('refresh'), t('btn.retry')))
|
|
1016
|
+
}
|
|
1017
|
+
function renderAccounts() {
|
|
1018
|
+
closeMenu()
|
|
1019
|
+
const box = $('accounts')
|
|
1020
|
+
const count = $('acct-count')
|
|
1021
|
+
if (!S.state) {
|
|
1022
|
+
if (S.loadError) { box.removeAttribute('aria-busy'); patch(box, loadErrorBlock()); setKids(count, null) }
|
|
1023
|
+
return
|
|
1024
|
+
}
|
|
1025
|
+
box.removeAttribute('aria-busy')
|
|
1026
|
+
const all = S.state.accounts
|
|
1027
|
+
const now = Date.now()
|
|
1028
|
+
$('toolbar').hidden = !all.length
|
|
1029
|
+
if (!all.length) { setKids(count, null); patch(box, emptyState()); return }
|
|
1030
|
+
const shown = all.filter((a) => (!prefs.family || a.family === prefs.family) && matchesQuery(a, ui.query, famTitle(a)) && (!ui.attention || reasonsOf(a, now).length))
|
|
1031
|
+
const filtered = shown.length !== all.length
|
|
1032
|
+
patch(count, filtered
|
|
1033
|
+
? [t('count.filtered', { n: shown.length, total: all.length }), ' ', h('button', { class: 'link-btn', type: 'button', onclick: clearFilters }, t('filter.clear'))]
|
|
1034
|
+
: tn('count.accounts', all.length))
|
|
1035
|
+
if (!shown.length) { patch(box, noMatch()); return }
|
|
1036
|
+
const cols = quotaColumns(shown)
|
|
1037
|
+
box.style.setProperty('--qn', String(cols.length))
|
|
1038
|
+
const ids = S.state.families.map((f) => f.id)
|
|
1039
|
+
for (const a of all) if (!ids.includes(a.family)) ids.push(a.family)
|
|
1040
|
+
const groups = []
|
|
1041
|
+
for (const fam of ids) {
|
|
1042
|
+
const list = shown.filter((a) => a.family === fam)
|
|
1043
|
+
if (list.length) groups.push(group(fam, sortAccounts(list, prefs.sort, now), all.filter((a) => a.family === fam).length, now, cols))
|
|
1044
|
+
}
|
|
1045
|
+
patch(box, groups)
|
|
1046
|
+
if (ui.fade && !REDUCED) {
|
|
1047
|
+
box.classList.add('view-in')
|
|
1048
|
+
setTimeout(() => box.classList.remove('view-in'), 400)
|
|
1049
|
+
}
|
|
1050
|
+
ui.fade = false
|
|
1051
|
+
// A new account is highlighted once its dialog is closed, so the flash is not spent behind it.
|
|
1052
|
+
if (ui.highlight && !(dlg.el && dlg.el.open)) flashRow(ui.highlight)
|
|
1053
|
+
}
|
|
1054
|
+
function flashRow(ref) {
|
|
1055
|
+
const el = document.querySelector('[data-ref="' + CSS.escape(ref) + '"]')
|
|
1056
|
+
if (!el) return
|
|
1057
|
+
ui.highlight = null
|
|
1058
|
+
el.classList.add('flash')
|
|
1059
|
+
el.scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'center' })
|
|
1060
|
+
setTimeout(() => el.classList.remove('flash'), 2600)
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ---------- Health check ----------
|
|
1064
|
+
async function runCheck() {
|
|
1065
|
+
if (S.checking || S.fixing) return
|
|
1066
|
+
S.checking = true
|
|
1067
|
+
S.checkError = null
|
|
1068
|
+
renderCheckup(); renderAccounts()
|
|
1069
|
+
try {
|
|
1070
|
+
const r = await api('/api/doctor', {})
|
|
1071
|
+
S.report = r.report
|
|
1072
|
+
S.history = r.history
|
|
1073
|
+
} catch (e) { S.checkError = e; S.checkRetry = runCheck }
|
|
1074
|
+
S.checking = false
|
|
1075
|
+
renderCheckup(); renderAccounts(); renderSummary()
|
|
1076
|
+
// A check can follow a change made outside the Panel; pick up the rest of the state too.
|
|
1077
|
+
load({ silent: true })
|
|
1078
|
+
}
|
|
1079
|
+
async function checkAccount(ref) {
|
|
1080
|
+
if (S.checking || S.fixing || ui.checkingRef) return
|
|
1081
|
+
ui.checkingRef = ref
|
|
1082
|
+
renderAccounts(); renderCheckup()
|
|
1083
|
+
try {
|
|
1084
|
+
const r = await api('/api/doctor', { target: ref })
|
|
1085
|
+
S.history = r.history
|
|
1086
|
+
const hs = healthOf(ref)
|
|
1087
|
+
if (!hs || (!hs.fail && !hs.warn)) toast(t('toast.checkOk', { ref }))
|
|
1088
|
+
else toast(t('toast.checkFound', { ref, what: hs.fail ? tn('health.issues', hs.fail) : tn('health.warnings', hs.warn) }), { kind: hs.fail ? 'fail' : 'info', action: { label: t('toast.show'), run: () => filterHealth(ref) } })
|
|
1089
|
+
} catch (e) { toast(t('toast.checkFailed', { ref, msg: e.message }), { kind: 'fail' }) }
|
|
1090
|
+
ui.checkingRef = null
|
|
1091
|
+
renderAccounts(); renderCheckup(); renderSummary()
|
|
1092
|
+
load({ silent: true })
|
|
1093
|
+
}
|
|
1094
|
+
async function runFix() {
|
|
1095
|
+
if (S.checking || S.fixing) return
|
|
1096
|
+
S.fixing = true
|
|
1097
|
+
S.checkError = null
|
|
1098
|
+
S.fixes = null
|
|
1099
|
+
renderCheckup()
|
|
1100
|
+
try {
|
|
1101
|
+
const r = await api('/api/fix', {})
|
|
1102
|
+
S.report = r.report
|
|
1103
|
+
S.history = r.history
|
|
1104
|
+
S.fixes = r.report.fixes
|
|
1105
|
+
const failed = S.fixes.filter((f) => !f.ok).length
|
|
1106
|
+
toast(S.fixes.length ? t('fix.result', { ok: S.fixes.length - failed, n: S.fixes.length }) : t('fix.nothing'), { kind: failed ? 'fail' : 'ok' })
|
|
1107
|
+
} catch (e) { S.checkError = e; S.checkRetry = runFix }
|
|
1108
|
+
S.fixing = false
|
|
1109
|
+
renderCheckup()
|
|
1110
|
+
load()
|
|
1111
|
+
}
|
|
1112
|
+
function filterHealth(ref) {
|
|
1113
|
+
ui.healthRef = ref
|
|
1114
|
+
renderCheckup()
|
|
1115
|
+
$('checkup').scrollIntoView({ behavior: REDUCED ? 'auto' : 'smooth', block: 'start' })
|
|
1116
|
+
}
|
|
1117
|
+
function findingRow(f) {
|
|
1118
|
+
return h('li', { class: 'finding lvl-' + f.level },
|
|
1119
|
+
h('span', { class: 'dot', title: f.level }),
|
|
1120
|
+
h('div', { class: 'finding-body' },
|
|
1121
|
+
h('div', { class: 'finding-top' },
|
|
1122
|
+
ui.healthRef ? null : h('button', { class: 'chip chip-btn mono', type: 'button', title: t('health.only', { ref: f.account }), 'aria-label': t('health.only', { ref: f.account }), onclick: () => filterHealth(f.account) }, badge(f.account.split(':')[0], 'xs'), f.account),
|
|
1123
|
+
h('span', { class: 'muted small mono' }, f.item),
|
|
1124
|
+
f.fixable ? h('span', { class: 'chip chip-sm chip-accent' }, t('health.autoFix')) : null,
|
|
1125
|
+
f.source && f.source !== 'core' ? h('span', { class: 'muted small' }, t('health.from', { source: f.source })) : null),
|
|
1126
|
+
h('p', null, f.message),
|
|
1127
|
+
f.hint ? h('p', { class: 'hint' }, panelHint(f.hint, f.fixable && !BOOT.readOnly, t('fix.hintAction'))) : null))
|
|
1128
|
+
}
|
|
1129
|
+
function renderCheckup() {
|
|
1130
|
+
const box = $('checkup')
|
|
1131
|
+
const all = findingsNow()
|
|
1132
|
+
const ref = ui.healthRef
|
|
1133
|
+
const findings = all && ref ? all.filter((f) => f.account === ref) : all
|
|
1134
|
+
const at = lastCheckAt()
|
|
1135
|
+
const busy = S.checking || S.fixing
|
|
1136
|
+
const fixable = all ? all.filter((f) => f.fixable).length : 0
|
|
1137
|
+
// Say why Fix all is off, unless a running check or fix already explains it.
|
|
1138
|
+
const fixWhy = busy || fixable ? null : !all ? t('fix.whyNoCheck') : all.length ? t('fix.whyManual') : t('fix.whyNothing')
|
|
1139
|
+
const status = S.checking ? t('health.running') : S.fixing ? t('health.fixing') : at ? t('health.last', { ago: ago(at) }) : t('health.never')
|
|
1140
|
+
const out = [h('div', { class: 'section-head' },
|
|
1141
|
+
h('div', null,
|
|
1142
|
+
h('h2', { id: 'h-checkup' }, t('health.title')),
|
|
1143
|
+
h('p', { class: 'muted small' }, status, h('span', { class: 'sep', 'aria-hidden': 'true' }, '·'),
|
|
1144
|
+
tx('health.lead', { shared: h('span', { class: 'label-tip' }, t('health.sharedTerm'), tip('shared')), drift: h('span', { class: 'label-tip' }, t('health.driftTerm'), tip('drift')) }))),
|
|
1145
|
+
h('div', { class: 'actions' },
|
|
1146
|
+
h('button', { class: 'btn', type: 'button', disabled: busy || !S.state, 'aria-busy': S.checking ? 'true' : null, title: !S.state && !busy ? t('health.waiting') : null, onclick: runCheck }, S.checking ? spinner() : null, S.checking ? t('health.running') : t('health.run')),
|
|
1147
|
+
BOOT.readOnly ? null : h('button', { class: 'btn btn-primary', type: 'button', disabled: busy || !fixable, 'aria-busy': S.fixing ? 'true' : null, title: fixWhy, onclick: runFix },
|
|
1148
|
+
S.fixing ? spinner() : null, S.fixing ? t('health.fixing') : t('fix.all') + (fixable ? ' (' + fixable + ')' : ''))))]
|
|
1149
|
+
if (ref) out.push(h('div', { class: 'filter-bar' },
|
|
1150
|
+
h('span', { class: 'chip chip-accent mono' }, badge(ref.split(':')[0], 'xs'), ref),
|
|
1151
|
+
h('span', { class: 'muted small' }, t('health.filtered')),
|
|
1152
|
+
ui.checkingRef === ref ? h('span', { class: 'muted small' }, spinner(), ' ', t('health.checking'))
|
|
1153
|
+
: h('button', { class: 'link-btn', type: 'button', disabled: busy || Boolean(ui.checkingRef), onclick: () => checkAccount(ref) }, t('health.checkThis')),
|
|
1154
|
+
h('button', { class: 'link-btn', type: 'button', onclick: () => { ui.healthRef = null; renderCheckup() } }, t('health.showAll'))))
|
|
1155
|
+
if (S.checkError) out.push(errorNote(S.checkError, S.checkRetry))
|
|
1156
|
+
if (!BOOT.readOnly && all && all.length && fixWhy && !ref) out.push(h('p', { class: 'disabled-why' }, t('fix.off', { why: fixWhy })))
|
|
1157
|
+
if (S.fixes) {
|
|
1158
|
+
const failed = S.fixes.filter((f) => !f.ok)
|
|
1159
|
+
out.push(h('div', { class: 'fix-result ' + (failed.length ? 'is-warn' : 'is-ok'), role: 'status' },
|
|
1160
|
+
S.fixes.length ? t('fix.result', { ok: S.fixes.length - failed.length, n: S.fixes.length }) : t('fix.nothing'),
|
|
1161
|
+
failed.length ? h('ul', null, failed.map((f) => h('li', null, h('b', null, f.account + ' · ' + f.item + ': '), f.message))) : null))
|
|
1162
|
+
}
|
|
1163
|
+
if (!findings) {
|
|
1164
|
+
if (busy || !S.state) out.push(h('span', { class: 'sk sk-line w70' }), h('span', { class: 'sk sk-line w50' }))
|
|
1165
|
+
else out.push(h('p', { class: 'muted' }, t('health.intro')))
|
|
1166
|
+
} else if (ref && !healthOf(ref) && !findings.length) {
|
|
1167
|
+
out.push(h('p', { class: 'muted' }, t('health.refNever', { ref })))
|
|
1168
|
+
} else if (!findings.length) {
|
|
1169
|
+
out.push(h('div', { class: 'all-good' }, h('span', { class: 'check-icon', 'aria-hidden': 'true' }, '✓'),
|
|
1170
|
+
h('div', null, h('b', null, ref ? t('health.refGood', { ref }) : t('health.allGood')), h('p', { class: 'muted small' }, t('health.allGoodSub')))))
|
|
1171
|
+
} else {
|
|
1172
|
+
// Long lists show the most serious first and fold the rest behind one button.
|
|
1173
|
+
const rank = { fail: 0, warn: 1, ok: 2 }
|
|
1174
|
+
const sorted = findings.slice().sort((x, y) => rank[x.level] - rank[y.level])
|
|
1175
|
+
const cut = ui.allFindings || sorted.length <= FINDINGS_SHOWN + 1 ? sorted.length : FINDINGS_SHOWN
|
|
1176
|
+
out.push(h('ul', { class: 'findings' }, sorted.slice(0, cut).map(findingRow)))
|
|
1177
|
+
if (cut < sorted.length) out.push(h('button', { class: 'btn btn-sm more-findings', type: 'button', onclick: () => { ui.allFindings = true; renderCheckup() } }, tn('health.more', sorted.length - cut)))
|
|
1178
|
+
}
|
|
1179
|
+
patch(box, out)
|
|
1180
|
+
}
|
|
1181
|
+
// An error from a server call, with a Retry button when trying again can help.
|
|
1182
|
+
function errorNote(err, retry, extra) {
|
|
1183
|
+
return h('div', { class: 'note note-fail error-note', role: 'alert' },
|
|
1184
|
+
h('span', null, err.message),
|
|
1185
|
+
err.retryable && retry ? h('button', { class: 'btn btn-sm', type: 'button', onclick: retry }, t('btn.retry')) : null,
|
|
1186
|
+
extra ? h('span', { class: 'note-sub' }, extra) : null)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ---------- dialogs ----------
|
|
1190
|
+
const dlg = { el: null, kind: null, busy: false, onClose: null }
|
|
1191
|
+
function ensureDialog() {
|
|
1192
|
+
if (dlg.el) return dlg.el
|
|
1193
|
+
dlg.el = h('dialog', { class: 'dialog', 'aria-labelledby': 'dlg-title' })
|
|
1194
|
+
dlg.el.addEventListener('cancel', (e) => { if (dlg.busy) e.preventDefault() })
|
|
1195
|
+
dlg.el.addEventListener('click', (e) => { if (e.target === dlg.el && !dlg.busy) dlg.el.close() })
|
|
1196
|
+
dlg.el.addEventListener('close', () => {
|
|
1197
|
+
const after = dlg.onClose
|
|
1198
|
+
dlg.kind = null
|
|
1199
|
+
dlg.onClose = null
|
|
1200
|
+
if (after) after()
|
|
1201
|
+
})
|
|
1202
|
+
document.body.append(dlg.el)
|
|
1203
|
+
return dlg.el
|
|
1204
|
+
}
|
|
1205
|
+
function dialogFrame(title, fam, body, foot) {
|
|
1206
|
+
return [
|
|
1207
|
+
h('div', { class: 'dlg-head' },
|
|
1208
|
+
h('h2', { id: 'dlg-title', class: 'title-logo' }, fam ? badge(fam, 'sm') : null, title),
|
|
1209
|
+
h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('btn.close'), disabled: dlg.busy, onclick: () => dlg.el.close() }, icon('x'))),
|
|
1210
|
+
h('div', { class: 'dlg-body' }, body),
|
|
1211
|
+
foot ? h('div', { class: 'dlg-foot' }, foot) : null,
|
|
1212
|
+
]
|
|
1213
|
+
}
|
|
1214
|
+
function showDialog(kind) {
|
|
1215
|
+
const el = ensureDialog()
|
|
1216
|
+
dlg.kind = kind
|
|
1217
|
+
if (!el.open) { el.showModal(); raiseToasts() }
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// New account. The form is built once per opening so typing keeps focus; only its derived parts re-render.
|
|
1221
|
+
let nf = null
|
|
1222
|
+
function familyStatus(f) {
|
|
1223
|
+
const hasMain = S.state && S.state.accounts.some((a) => a.family === f.id && a.isMain)
|
|
1224
|
+
if (hasMain) return 'ready'
|
|
1225
|
+
return f.installed ? 'no-main' : 'missing'
|
|
1226
|
+
}
|
|
1227
|
+
function openCreate(prefill) {
|
|
1228
|
+
if (BOOT.readOnly || !S.state) return
|
|
1229
|
+
const fams = S.state.families.slice().sort((x, y) => {
|
|
1230
|
+
const rank = (f) => ({ ready: 0, 'no-main': 1, missing: 2 })[familyStatus(f)]
|
|
1231
|
+
return rank(x) - rank(y)
|
|
1232
|
+
})
|
|
1233
|
+
const ready = fams.filter((f) => familyStatus(f) === 'ready')
|
|
1234
|
+
const pick = prefill && ready.some((f) => f.id === prefill.family) ? prefill.family : prefs.family && ready.some((f) => f.id === prefs.family) ? prefs.family : ready.length ? ready[0].id : ''
|
|
1235
|
+
// name / alias: what the user typed, or null while the field shows the suggestion (an emptied alias stays empty).
|
|
1236
|
+
nf = { fams, family: pick, api: Boolean(prefill && prefill.api), name: null, alias: null, creating: false, result: null, error: null, els: {} }
|
|
1237
|
+
showDialog('create')
|
|
1238
|
+
renderCreate()
|
|
1239
|
+
if (nf.els.name) { nf.els.name.focus(); nf.els.name.select() }
|
|
1240
|
+
}
|
|
1241
|
+
function suggestionFor(fam) {
|
|
1242
|
+
return fam && S.state ? suggestName(S.state.accounts.filter((a) => a.family === fam).map((a) => a.name)) : ''
|
|
1243
|
+
}
|
|
1244
|
+
function nameProblem() {
|
|
1245
|
+
const name = nf.els.name.value.trim()
|
|
1246
|
+
if (!name) return ''
|
|
1247
|
+
if (name === 'main') return t('nf.mainTaken')
|
|
1248
|
+
if (!NAME_RE.test(name)) return t('nf.badName')
|
|
1249
|
+
if (S.state && S.state.accounts.some((a) => a.ref === nf.family + ':' + name)) return t('nf.exists', { ref: nf.family + ':' + name })
|
|
1250
|
+
return ''
|
|
1251
|
+
}
|
|
1252
|
+
// The short command offered for the typed name: the Family's alias pattern (cc001… gives cc008), if it is usable.
|
|
1253
|
+
function aliasSuggestion() {
|
|
1254
|
+
const name = nf.els.name.value.trim()
|
|
1255
|
+
if (!nf.family || !name || nameProblem()) return ''
|
|
1256
|
+
const sug = suggestAlias(S.state.accounts, nf.family, name)
|
|
1257
|
+
return sug && !aliasIssue(sug, BOOT.aliasPattern, BOOT.reservedAliases, S.state.configAliases, nf.family + ':' + name) ? sug : ''
|
|
1258
|
+
}
|
|
1259
|
+
function followAlias() {
|
|
1260
|
+
if (nf.alias !== null) return
|
|
1261
|
+
nf.els.alias.value = aliasSuggestion()
|
|
1262
|
+
}
|
|
1263
|
+
function aliasProblem() {
|
|
1264
|
+
const alias = nf.els.alias.value.trim()
|
|
1265
|
+
const ref = nf.family + ':' + nf.els.name.value.trim()
|
|
1266
|
+
const issue = aliasIssue(alias, BOOT.aliasPattern, BOOT.reservedAliases, S.state.configAliases, ref)
|
|
1267
|
+
if (issue === 'pattern') return t('nf.aliasBad')
|
|
1268
|
+
if (issue === 'reserved') return t('nf.aliasReserved', { alias })
|
|
1269
|
+
if (issue === 'taken') return t('nf.aliasTaken', { alias, ref: S.state.configAliases[alias] })
|
|
1270
|
+
return ''
|
|
1271
|
+
}
|
|
1272
|
+
function renderCreate() {
|
|
1273
|
+
const el = dlg.el
|
|
1274
|
+
if (nf.result) { setKids(el, createdView()); return }
|
|
1275
|
+
const e = nf.els
|
|
1276
|
+
const sug = suggestionFor(nf.family)
|
|
1277
|
+
e.name = h('input', { id: 'nf-name', name: 'name', type: 'text', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', placeholder: sug || 'work', maxlength: '32', 'aria-label': t('nf.name'), 'aria-describedby': 'nf-sug nf-err', 'aria-invalid': 'false' })
|
|
1278
|
+
e.name.value = nf.name === null ? sug : nf.name
|
|
1279
|
+
e.alias = h('input', { id: 'nf-alias', name: 'alias', type: 'text', autocomplete: 'off', autocapitalize: 'none', spellcheck: 'false', placeholder: t('nf.aliasPh'), maxlength: '64', 'aria-describedby': 'nf-alias-hint nf-alias-err', 'aria-invalid': 'false' })
|
|
1280
|
+
e.aliasHint = h('p', { class: 'field-hint', id: 'nf-alias-hint' })
|
|
1281
|
+
e.aliasErr = h('p', { class: 'field-error', id: 'nf-alias-err', role: 'alert' })
|
|
1282
|
+
e.api = h('input', { id: 'nf-api', type: 'checkbox', 'aria-label': t('nf.api') })
|
|
1283
|
+
e.api.checked = nf.api
|
|
1284
|
+
e.suggest = h('p', { class: 'field-hint', id: 'nf-sug' })
|
|
1285
|
+
e.error = h('p', { class: 'field-error', id: 'nf-err', role: 'alert' })
|
|
1286
|
+
e.preview = h('dl', { class: 'preview' })
|
|
1287
|
+
e.submit = h('button', { class: 'btn btn-primary', type: 'submit', form: 'nf-form', 'aria-label': t('nf.create') }, t('nf.create'))
|
|
1288
|
+
e.name.addEventListener('input', () => { nf.name = e.name.value; followAlias(); validateCreate(); renderPreview() })
|
|
1289
|
+
e.alias.addEventListener('input', () => { nf.alias = e.alias.value; validateCreate(); renderPreview() })
|
|
1290
|
+
e.api.addEventListener('change', () => {
|
|
1291
|
+
nf.api = e.api.checked
|
|
1292
|
+
// A refusal was about the old choice; the hint under it no longer applies.
|
|
1293
|
+
if (e.errNote) { e.errNote.remove(); e.errNote = null; nf.error = null }
|
|
1294
|
+
})
|
|
1295
|
+
const cards = nf.fams.map((f) => {
|
|
1296
|
+
const st = familyStatus(f)
|
|
1297
|
+
const input = h('input', { type: 'radio', name: 'nf-family', value: f.id, class: 'sr-only', disabled: st !== 'ready', 'aria-label': f.title })
|
|
1298
|
+
input.checked = f.id === nf.family
|
|
1299
|
+
input.addEventListener('change', () => {
|
|
1300
|
+
// A name the user has not changed follows the Family: Claude's next free name differs from Cursor's.
|
|
1301
|
+
const typed = e.name.value.trim()
|
|
1302
|
+
const untouched = nf.name === null || !typed || typed === suggestionFor(nf.family)
|
|
1303
|
+
nf.family = f.id
|
|
1304
|
+
if (untouched) { nf.name = null; e.name.value = suggestionFor(f.id); e.name.placeholder = e.name.value || 'work' }
|
|
1305
|
+
followAlias()
|
|
1306
|
+
validateCreate()
|
|
1307
|
+
renderPreview()
|
|
1308
|
+
})
|
|
1309
|
+
const n = S.state.accounts.filter((a) => a.family === f.id).length
|
|
1310
|
+
const sub = st === 'ready' ? tn('nf.accounts', n) : st === 'no-main' ? tx('nf.runOnce', { bin: h('code', null, f.bin) }) : t('nf.notInstalled')
|
|
1311
|
+
return h('label', { class: 'fam-card' + (st === 'ready' ? '' : ' is-off') }, input, badge(f.id, 'sm'),
|
|
1312
|
+
h('span', { class: 'fam-text' }, h('span', { class: 'fam-title' }, f.title), h('span', { class: 'fam-sub' }, sub)))
|
|
1313
|
+
})
|
|
1314
|
+
const body = h('form', { id: 'nf-form', class: 'form', novalidate: true, onsubmit: (ev) => { ev.preventDefault(); create() } },
|
|
1315
|
+
h('p', { class: 'muted small' }, t('nf.intro')),
|
|
1316
|
+
h('fieldset', { class: 'field fam-field' }, h('legend', null, t('nf.tool')), nf.fams.length ? h('div', { class: 'fam-grid' }, cards) : h('p', { class: 'muted small' }, t('empty.noFamilies'))),
|
|
1317
|
+
h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-name' }, h('span', null, t('nf.name')), e.name), e.suggest),
|
|
1318
|
+
h('div', { class: 'field' }, h('label', { class: 'field', for: 'nf-alias' }, h('span', null, t('nf.alias'), ' ', h('span', { class: 'muted small' }, t('nf.optional'))), e.alias), e.aliasHint, e.aliasErr),
|
|
1319
|
+
h('div', { class: 'check-row' }, h('label', { class: 'check' }, e.api, h('span', null, h('b', null, t('nf.api')), h('span', { class: 'muted small' }, t('nf.apiHint')))), tip('api')),
|
|
1320
|
+
e.preview,
|
|
1321
|
+
e.error,
|
|
1322
|
+
e.errNote = nf.error ? errorNote(nf.error, create, nf.error.code === 'create-refused' && !nf.api ? t('nf.refusedApi') : null) : null)
|
|
1323
|
+
setKids(el, dialogFrame(t('nf.title'), null, body, [
|
|
1324
|
+
h('button', { class: 'btn', type: 'button', onclick: () => el.close() }, t('btn.cancel')),
|
|
1325
|
+
e.submit,
|
|
1326
|
+
]))
|
|
1327
|
+
if (nf.alias === null) followAlias(); else e.alias.value = nf.alias
|
|
1328
|
+
validateCreate()
|
|
1329
|
+
if (!nf.family) e.error.textContent = t('nf.noReady')
|
|
1330
|
+
renderPreview()
|
|
1331
|
+
}
|
|
1332
|
+
// Create stays off until the name is usable; the message under the field says why.
|
|
1333
|
+
function validateCreate() {
|
|
1334
|
+
const e = nf.els
|
|
1335
|
+
const msg = nameProblem()
|
|
1336
|
+
const name = e.name.value.trim()
|
|
1337
|
+
e.name.classList.toggle('invalid', Boolean(msg))
|
|
1338
|
+
e.name.setAttribute('aria-invalid', String(Boolean(msg)))
|
|
1339
|
+
e.error.textContent = msg
|
|
1340
|
+
const sug = suggestionFor(nf.family)
|
|
1341
|
+
e.suggest.textContent = sug && name === sug ? t('nf.suggested') : ''
|
|
1342
|
+
const aliasMsg = aliasProblem()
|
|
1343
|
+
const alias = e.alias.value.trim()
|
|
1344
|
+
e.alias.classList.toggle('invalid', Boolean(aliasMsg))
|
|
1345
|
+
e.alias.setAttribute('aria-invalid', String(Boolean(aliasMsg)))
|
|
1346
|
+
e.aliasErr.textContent = aliasMsg
|
|
1347
|
+
e.aliasHint.textContent = alias && alias === aliasSuggestion() ? t('nf.aliasSuggested', { host: family(nf.family).title }) : t('nf.aliasHint')
|
|
1348
|
+
if (!nf.creating) e.submit.disabled = Boolean(msg) || Boolean(aliasMsg) || !name || !nf.family
|
|
1349
|
+
return msg || aliasMsg
|
|
1350
|
+
}
|
|
1351
|
+
function renderPreview() {
|
|
1352
|
+
const name = nf.els.name.value.trim()
|
|
1353
|
+
const ok = name && !nameProblem() && nf.family
|
|
1354
|
+
const shown = ok ? name : '<' + t('nf.namePh') + '>'
|
|
1355
|
+
const dir = nf.family ? newAccountDir(S.state.accounts, nf.family, shown) : null
|
|
1356
|
+
const alias = nf.els.alias.value.trim()
|
|
1357
|
+
const run = nf.family ? 'sideby run ' + nf.family + ':' + shown : '—'
|
|
1358
|
+
setKids(nf.els.preview, [
|
|
1359
|
+
dir ? [h('dt', null, t('nf.dir')), h('dd', { class: 'mono' }, dir)] : null,
|
|
1360
|
+
h('dt', null, t('nf.launch')),
|
|
1361
|
+
alias && !aliasProblem() && nf.family
|
|
1362
|
+
? h('dd', null, h('span', { class: 'mono' }, alias), h('span', { class: 'muted small' }, ' ' + t('nf.or') + ' '), h('span', { class: 'mono' }, run))
|
|
1363
|
+
: h('dd', { class: 'mono' }, run),
|
|
1364
|
+
])
|
|
1365
|
+
nf.els.preview.classList.toggle('is-draft', !ok)
|
|
1366
|
+
}
|
|
1367
|
+
async function create() {
|
|
1368
|
+
if (nf.creating) return
|
|
1369
|
+
const name = nf.els.name.value.trim()
|
|
1370
|
+
const alias = nf.els.alias.value.trim()
|
|
1371
|
+
const aliasMsg = aliasProblem()
|
|
1372
|
+
const msg = nameProblem() || (nf.family ? '' : t('nf.noReady')) || (name ? '' : t('nf.enterName'))
|
|
1373
|
+
validateCreate()
|
|
1374
|
+
if (msg) { nf.els.error.textContent = msg; nf.els.name.focus(); return }
|
|
1375
|
+
if (aliasMsg) { nf.els.alias.focus(); return }
|
|
1376
|
+
nf.creating = true
|
|
1377
|
+
nf.error = null
|
|
1378
|
+
dlg.busy = true
|
|
1379
|
+
nf.els.submit.disabled = true
|
|
1380
|
+
nf.els.submit.setAttribute('aria-busy', 'true')
|
|
1381
|
+
nf.els.submit.replaceChildren(spinner(), t('nf.creating'))
|
|
1382
|
+
labelFromText(nf.els.submit)
|
|
1383
|
+
try {
|
|
1384
|
+
nf.result = await api('/api/accounts', alias ? { family: nf.family, name, api: nf.els.api.checked, alias } : { family: nf.family, name, api: nf.els.api.checked })
|
|
1385
|
+
toast(t(nf.result.ok ? 'toast.created' : 'toast.createdProblems', { ref: nf.result.account.ref }), { kind: nf.result.ok ? 'ok' : 'info' })
|
|
1386
|
+
ui.highlight = nf.result.account.ref
|
|
1387
|
+
dlg.onClose = () => { if (ui.highlight) flashRow(ui.highlight) }
|
|
1388
|
+
load({ check: true })
|
|
1389
|
+
} catch (e) { nf.error = e }
|
|
1390
|
+
nf.creating = false
|
|
1391
|
+
dlg.busy = false
|
|
1392
|
+
renderCreate()
|
|
1393
|
+
if (!nf.result && nf.els.name) {
|
|
1394
|
+
nf.els.name.value = name
|
|
1395
|
+
nf.alias = alias
|
|
1396
|
+
nf.els.alias.value = alias
|
|
1397
|
+
validateCreate()
|
|
1398
|
+
renderPreview()
|
|
1399
|
+
// A refused subscription account is usually fixed by turning on "API key account".
|
|
1400
|
+
if (nf.error && nf.error.code === 'create-refused' && !nf.api) nf.els.api.focus()
|
|
1401
|
+
else if (nf.error && nf.error.code === 'alias-invalid') nf.els.alias.focus()
|
|
1402
|
+
else nf.els.name.focus()
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
function stepRow(s) {
|
|
1406
|
+
const fill = /^fill in (.+)$/.exec(s)
|
|
1407
|
+
if (fill) return h('li', null, h('span', null, t('nf.fillIn')), codeCopy(fill[1]))
|
|
1408
|
+
if (s.startsWith('sideby ')) return h('li', null, codeCopy(s))
|
|
1409
|
+
return h('li', null, s)
|
|
1410
|
+
}
|
|
1411
|
+
function createdView() {
|
|
1412
|
+
const r = nf.result
|
|
1413
|
+
const failed = r.steps.filter((s) => !s.ok)
|
|
1414
|
+
// An incomplete account creation is not shown as a success.
|
|
1415
|
+
const body = h('div', { class: 'created', role: 'status' },
|
|
1416
|
+
r.ok
|
|
1417
|
+
? h('div', { class: 'created-head' }, h('span', { class: 'check-icon', 'aria-hidden': 'true' }, '✓'), h('span', null, tx('nf.created', { ref: h('b', { class: 'mono' }, r.account.ref) })))
|
|
1418
|
+
: h('div', { class: 'created-head' }, h('span', { class: 'warn-icon', 'aria-hidden': 'true' }, '!'), h('span', null, tx('nf.createdProblems', { ref: h('b', { class: 'mono' }, r.account.ref) }))),
|
|
1419
|
+
failed.length || r.hookErrors.length ? h('div', { class: 'note note-warn' },
|
|
1420
|
+
failed.map((s) => h('span', { class: 'small' }, s.item + ': ' + (s.message || 'failed'))),
|
|
1421
|
+
r.hookErrors.map((e) => h('span', { class: 'small' }, 'plugin ' + e.plugin + ': ' + e.message))) : null,
|
|
1422
|
+
r.account.dir ? h('p', { class: 'small' }, h('span', { class: 'muted' }, t('nf.dir') + ' '), h('code', null, r.account.dir)) : null,
|
|
1423
|
+
r.alias && r.alias.added ? h('div', { class: 'small created-alias' }, h('span', { class: 'muted' }, t('nf.alias') + ' '), codeCopy(r.alias.name), h('span', { class: 'muted' }, ' ' + t('nf.aliasNewShell'))) : null,
|
|
1424
|
+
r.alias && !r.alias.added ? h('div', { class: 'note note-warn' }, h('b', null, t('nf.aliasNotAdded')), ' ', h('span', { class: 'small' }, r.alias.message || '')) : null,
|
|
1425
|
+
(r.shellInitFiles || []).map((f) => f.ok
|
|
1426
|
+
? h('p', { class: 'muted small' }, t(f.action === 'unchanged' ? 'nf.shellFileSame' : 'nf.shellFileUpdated', { file: f.path }))
|
|
1427
|
+
: h('div', { class: 'note note-warn' }, h('span', { class: 'small' }, f.message))),
|
|
1428
|
+
h('p', { class: 'muted small' }, t('nf.next')),
|
|
1429
|
+
h('ol', { class: 'steps' }, r.nextSteps.map(stepRow)))
|
|
1430
|
+
return dialogFrame(t('nf.title'), r.account.family, body, [
|
|
1431
|
+
h('button', { class: 'btn', type: 'button', onclick: () => openCreate({ family: r.account.family, api: nf.api }) }, t('nf.another')),
|
|
1432
|
+
h('button', { class: 'btn btn-primary', type: 'button', onclick: () => dlg.el.close() }, t('btn.done')),
|
|
1433
|
+
])
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Quota setup: preview the diff, apply, then offer Undo (teardown) in the toast.
|
|
1437
|
+
let qs = null
|
|
1438
|
+
function diffView(diff) {
|
|
1439
|
+
return h('pre', { class: 'diff' }, diff.split('\n').map((l) => h('span', {
|
|
1440
|
+
class: l.startsWith('@@') ? 'hunk' : l.startsWith('+') && !l.startsWith('+++') ? 'add' : l.startsWith('-') && !l.startsWith('---') ? 'del' : null,
|
|
1441
|
+
}, l || ' ')))
|
|
1442
|
+
}
|
|
1443
|
+
async function openSetup(fam) {
|
|
1444
|
+
if (BOOT.readOnly) return
|
|
1445
|
+
qs = { family: fam, loading: true }
|
|
1446
|
+
showDialog('setup')
|
|
1447
|
+
renderSetup()
|
|
1448
|
+
try {
|
|
1449
|
+
const r = await api('/api/quota/setup', { family: fam })
|
|
1450
|
+
if (qs && qs.family === fam) qs = { family: fam, plan: r.plan, summary: r.summary }
|
|
1451
|
+
} catch (e) { if (qs && qs.family === fam) qs = { family: fam, error: e } }
|
|
1452
|
+
if (dlg.kind === 'setup') renderSetup()
|
|
1453
|
+
}
|
|
1454
|
+
function renderSetup() {
|
|
1455
|
+
const fam = family(qs.family)
|
|
1456
|
+
const body = []
|
|
1457
|
+
const foot = []
|
|
1458
|
+
if (qs.loading) body.push(h('p', { class: 'muted' }, spinner(), ' ', t('qs.preparing')))
|
|
1459
|
+
// With a plan on screen, its Apply button is the retry.
|
|
1460
|
+
if (qs.error) body.push(errorNote(qs.error, qs.plan ? null : () => openSetup(qs.family)))
|
|
1461
|
+
if (qs.plan) {
|
|
1462
|
+
const p = qs.plan
|
|
1463
|
+
if (p.status === 'enabled') body.push(h('p', null, p.message || t('qs.alreadyOn', { host: fam.title })))
|
|
1464
|
+
else if (p.status === 'blocked') body.push(h('p', { class: 'note note-warn' }, p.message))
|
|
1465
|
+
else {
|
|
1466
|
+
body.push(h('p', { class: 'muted' }, qs.summary || p.message))
|
|
1467
|
+
if (p.file) body.push(h('p', { class: 'small' }, tx('qs.changes', { file: h('code', null, p.file) })))
|
|
1468
|
+
if (p.diff) body.push(diffView(p.diff))
|
|
1469
|
+
body.push(h('p', { class: 'muted small' }, t('qs.undoHint')))
|
|
1470
|
+
foot.push(h('button', { class: 'btn', type: 'button', disabled: qs.applying, onclick: () => dlg.el.close() }, t('btn.cancel')),
|
|
1471
|
+
h('button', { class: 'btn btn-primary', type: 'button', disabled: qs.applying, 'aria-busy': qs.applying ? 'true' : null, onclick: applySetup }, qs.applying ? spinner() : null, qs.applying ? t('qs.applying') : t('qs.apply')))
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (!foot.length) foot.push(h('button', { class: 'btn', type: 'button', onclick: () => dlg.el.close() }, t('btn.close')))
|
|
1475
|
+
setKids(dlg.el, dialogFrame(t('qs.title', { host: fam.title }), qs.family, body, foot))
|
|
1476
|
+
}
|
|
1477
|
+
async function applySetup() {
|
|
1478
|
+
const st = qs
|
|
1479
|
+
st.applying = true
|
|
1480
|
+
st.error = null
|
|
1481
|
+
dlg.busy = true
|
|
1482
|
+
renderSetup()
|
|
1483
|
+
try {
|
|
1484
|
+
const r = await api('/api/quota/setup', { family: st.family, confirm: true })
|
|
1485
|
+
dlg.busy = false
|
|
1486
|
+
if (r.plan.status === 'enabled') {
|
|
1487
|
+
const fam = st.family
|
|
1488
|
+
dlg.el.close()
|
|
1489
|
+
toast(t('toast.quotaOn', { host: family(fam).title }), { action: { label: t('toast.undo'), run: () => undoSetup(fam) }, duration: 10000 })
|
|
1490
|
+
// The first numbers arrive with the next status line refresh; poll quickly for a while to show them soon.
|
|
1491
|
+
S.fastUntil = Date.now() + FAST_FOR_MS
|
|
1492
|
+
S.fastFamily = fam
|
|
1493
|
+
load()
|
|
1494
|
+
return
|
|
1495
|
+
}
|
|
1496
|
+
qs = { family: st.family, plan: r.plan, summary: r.summary }
|
|
1497
|
+
} catch (e) { st.applying = false; st.error = e; dlg.busy = false }
|
|
1498
|
+
renderSetup()
|
|
1499
|
+
}
|
|
1500
|
+
async function undoSetup(fam) {
|
|
1501
|
+
S.fastUntil = 0
|
|
1502
|
+
S.fastFamily = null
|
|
1503
|
+
try {
|
|
1504
|
+
const r = await api('/api/quota/teardown', { family: fam })
|
|
1505
|
+
if (r && r.ok === false) toast(r.message || t('toast.undoFailed', { msg: '' }), { kind: 'fail' })
|
|
1506
|
+
else toast(t('toast.quotaOff', { host: family(fam).title }))
|
|
1507
|
+
} catch (e) {
|
|
1508
|
+
toast(e.status === 404 ? t('toast.undoMissing') : t('toast.undoFailed', { msg: e.message }), { kind: 'fail' })
|
|
1509
|
+
}
|
|
1510
|
+
load()
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// ---------- banners ----------
|
|
1514
|
+
function renderBanners() {
|
|
1515
|
+
const out = []
|
|
1516
|
+
if (S.loadError && S.state) out.push(h('div', { class: 'banner banner-fail', role: 'alert' },
|
|
1517
|
+
h('div', null, h('b', null, t('load.failedShort') + ' '), S.loadError.message),
|
|
1518
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => load({ check: !S.report }) }, t('btn.retry'))))
|
|
1519
|
+
if (S.state) {
|
|
1520
|
+
if (BOOT.readOnly) out.push(h('div', { class: 'banner banner-quiet' }, h('div', { class: 'muted small' }, t('banner.readOnly'))))
|
|
1521
|
+
const errs = S.state.pluginErrors
|
|
1522
|
+
if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
|
|
1523
|
+
h('b', null, tn('banner.plugins', errs.length) + ' '), t('banner.pluginsHint'),
|
|
1524
|
+
h('ul', null, errs.map((e) => h('li', null, h('code', null, e.where), ': ', e.message))))))
|
|
1525
|
+
}
|
|
1526
|
+
patch($('banners'), out)
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// ---------- loading ----------
|
|
1530
|
+
function renderAll() { renderTop(); renderBanners(); renderSummary(); renderFamilyFilter(); renderEmailToggle(); renderAccounts(); renderCheckup() }
|
|
1531
|
+
// A load that starts while another runs is queued, never dropped, so a change made meanwhile (a new
|
|
1532
|
+
// account, a fix) always ends up on screen; and only the newest response is ever rendered. A silent load is a
|
|
1533
|
+
// background refresh: no spinner, and its render waits while the viewer is busy (see uiBusy).
|
|
1534
|
+
let loadSeq = 0
|
|
1535
|
+
let queued = null
|
|
1536
|
+
async function load(opts) {
|
|
1537
|
+
opts = opts || {}
|
|
1538
|
+
if (S.loading) {
|
|
1539
|
+
queued = {
|
|
1540
|
+
check: Boolean((queued && queued.check) || opts.check),
|
|
1541
|
+
announce: Boolean((queued && queued.announce) || opts.announce),
|
|
1542
|
+
silent: Boolean((queued ? queued.silent : true) && opts.silent),
|
|
1543
|
+
}
|
|
1544
|
+
return
|
|
1545
|
+
}
|
|
1546
|
+
const seq = ++loadSeq
|
|
1547
|
+
S.loading = true
|
|
1548
|
+
S.silent = Boolean(opts.silent)
|
|
1549
|
+
S.lastTry = Date.now()
|
|
1550
|
+
renderTop()
|
|
1551
|
+
let state = null
|
|
1552
|
+
let error = null
|
|
1553
|
+
try { state = await api('/api/state') } catch (e) { error = e }
|
|
1554
|
+
if (seq === loadSeq) {
|
|
1555
|
+
if (state) {
|
|
1556
|
+
if (!S.state) ui.fade = true
|
|
1557
|
+
S.state = state; S.loadError = null; S.loadedAt = Date.now()
|
|
1558
|
+
// The server now runs another build (sideby was reinstalled and restarted): this page's script is old.
|
|
1559
|
+
if (state.build && BOOT.build && state.build !== BOOT.build && !S.newBuild) {
|
|
1560
|
+
S.newBuild = true
|
|
1561
|
+
if (uiBusy()) toast(t('toast.updated'))
|
|
1562
|
+
}
|
|
1563
|
+
// A read-only Panel never records checks, so keep the result of its own last check over the disk.
|
|
1564
|
+
if (!BOOT.readOnly || !S.history) S.history = state.history || null
|
|
1565
|
+
} else if (!error.network) S.loadError = error // a lost connection has its own notice
|
|
1566
|
+
}
|
|
1567
|
+
S.loading = false
|
|
1568
|
+
S.silent = false
|
|
1569
|
+
const next = queued
|
|
1570
|
+
queued = null
|
|
1571
|
+
if (next) return load(next)
|
|
1572
|
+
if (opts.silent && uiBusy()) { S.pending = true; renderTop() } else { S.pending = false; renderAll() }
|
|
1573
|
+
if (opts.announce) {
|
|
1574
|
+
if (state) toast(t('toast.refreshed'))
|
|
1575
|
+
else toast(t('toast.refreshFailed', { msg: error.message }), { kind: 'fail' })
|
|
1576
|
+
}
|
|
1577
|
+
if (S.state && opts.check) runCheck()
|
|
1578
|
+
reloadForNewBuild()
|
|
1579
|
+
}
|
|
1580
|
+
// Reloads into the new build once the viewer is not in the middle of something (a dialog, a form, a selection).
|
|
1581
|
+
function reloadForNewBuild() {
|
|
1582
|
+
if (S.newBuild && !uiBusy() && !S.fixing && !S.checking && !dlg.busy) location.reload()
|
|
1583
|
+
}
|
|
1584
|
+
// Once a second: the "Updated" age ticks, a deferred render lands once the viewer is done, and the state
|
|
1585
|
+
// reloads on schedule while the page is visible.
|
|
1586
|
+
function tick() {
|
|
1587
|
+
renderUpdated()
|
|
1588
|
+
reloadForNewBuild()
|
|
1589
|
+
if (document.visibilityState !== 'visible' || !S.state) return
|
|
1590
|
+
if (S.pending && !S.loading && !uiBusy()) { S.pending = false; renderAll() }
|
|
1591
|
+
const now = Date.now()
|
|
1592
|
+
const every = now < S.fastUntil ? FAST_MS : AUTO_MS
|
|
1593
|
+
if (!S.loading && !conn.lost && !S.fixing && !S.checking && !dlg.busy && now - S.lastTry >= every) load({ silent: true })
|
|
1594
|
+
}
|
|
1595
|
+
// Coming back to the page refreshes at once.
|
|
1596
|
+
function wake() {
|
|
1597
|
+
if (document.visibilityState !== 'visible' || !S.state || S.loading || conn.lost) return
|
|
1598
|
+
if (Date.now() - S.lastTry > 3000) load({ silent: true })
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// ---------- start ----------
|
|
1602
|
+
$('ver').textContent = BOOT.version ? 'v' + BOOT.version : ''
|
|
1603
|
+
$('refresh').addEventListener('click', () => load({ check: true, announce: true }))
|
|
1604
|
+
$('lang').addEventListener('click', () => {
|
|
1605
|
+
if (LANG_FIXED) return
|
|
1606
|
+
lang = lang === 'zh' ? 'en' : 'zh'
|
|
1607
|
+
prefs.lang = lang
|
|
1608
|
+
savePrefs()
|
|
1609
|
+
renderStatic()
|
|
1610
|
+
buildToolbar()
|
|
1611
|
+
renderAll()
|
|
1612
|
+
if (dlg.kind === 'create' && nf && !nf.creating) renderCreate()
|
|
1613
|
+
else if (dlg.kind === 'setup' && qs) renderSetup()
|
|
1614
|
+
})
|
|
1615
|
+
$('theme').addEventListener('click', () => {
|
|
1616
|
+
const order = CHOICES.theme
|
|
1617
|
+
prefs.theme = order[(order.indexOf(prefs.theme) + 1) % order.length]
|
|
1618
|
+
savePrefs()
|
|
1619
|
+
applyTheme()
|
|
1620
|
+
renderStatic()
|
|
1621
|
+
toast(t('theme.' + prefs.theme), { key: 'theme', kind: 'plain' })
|
|
1622
|
+
})
|
|
1623
|
+
applyTheme()
|
|
1624
|
+
renderStatic()
|
|
1625
|
+
buildToolbar()
|
|
1626
|
+
raiseToasts()
|
|
1627
|
+
load({ check: true })
|
|
1628
|
+
setInterval(tick, 1000)
|
|
1629
|
+
document.addEventListener('visibilitychange', wake)
|
|
1630
|
+
window.addEventListener('focus', wake)
|
|
1631
|
+
`;
|
|
1632
|
+
/** The page script: the tested helpers, then the page logic, in one strict-mode function. */
|
|
1633
|
+
export const PAGE_SCRIPT = `(() => {\n 'use strict'\n${LOGIC}\n${MAIN}})()\n`;
|