free-coding-models 0.5.4 → 0.5.6
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/README.md +8 -5
- package/bin/free-coding-models.js +29 -8
- package/changelog/v0.5.5.md +16 -0
- package/changelog/v0.5.6.md +24 -0
- package/package.json +4 -4
- package/src/core/changelog-loader.js +5 -1
- package/src/core/router-daemon.js +11 -0
- package/src/core/updater.js +174 -10
- package/src/tui/app.js +11 -31
- package/src/tui/render-table.js +9 -3
- package/src/tui/tui-state.js +6 -0
- package/web/dist/assets/index-Blp9QJev.js +39 -0
- package/web/dist/assets/{index-BrpHevg4.css → index-Cz_aCLTR.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/server.js +158 -2
- package/web/src/App.jsx +107 -58
- package/web/src/components/changelog/ChangelogView.jsx +135 -0
- package/web/src/components/changelog/ChangelogView.module.css +160 -0
- package/web/src/components/help/HelpView.jsx +188 -0
- package/web/src/components/help/HelpView.module.css +157 -0
- package/web/src/components/layout/Header.jsx +8 -3
- package/web/src/components/palette/CommandPalette.jsx +228 -74
- package/web/src/components/settings/SettingsView.jsx +281 -8
- package/web/src/components/settings/SettingsView.module.css +174 -0
- package/web/src/components/update/UpdateChip.jsx +104 -0
- package/web/src/components/update/UpdateChip.module.css +146 -0
- package/web/src/global.css +15 -0
- package/web/src/hooks/urlState.constants.js +25 -0
- package/web/src/hooks/useChangelog.js +51 -0
- package/web/src/hooks/useSocket.js +3 -0
- package/web/src/hooks/useUpdateChecker.js +91 -0
- package/web/src/hooks/useUrlState.js +122 -62
- package/web/dist/assets/index-BoWmUveV.js +0 -39
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/update/UpdateChip.module.css
|
|
3
|
+
* @description Styles for the header update chip + popover.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
.wrap {
|
|
7
|
+
position: relative;
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.chip {
|
|
12
|
+
display: inline-flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
gap: 5px;
|
|
15
|
+
height: 28px;
|
|
16
|
+
padding: 0 10px;
|
|
17
|
+
font-size: 11px;
|
|
18
|
+
font-weight: 700;
|
|
19
|
+
font-family: var(--font-mono);
|
|
20
|
+
background: var(--color-success-dim);
|
|
21
|
+
color: var(--color-success);
|
|
22
|
+
border: 1px solid var(--color-success);
|
|
23
|
+
border-radius: 6px;
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
font-family: var(--font-mono);
|
|
26
|
+
transition: all 150ms;
|
|
27
|
+
white-space: nowrap;
|
|
28
|
+
}
|
|
29
|
+
.chip:hover {
|
|
30
|
+
background: var(--color-success);
|
|
31
|
+
color: var(--color-bg);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.checking {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
width: 22px;
|
|
39
|
+
height: 22px;
|
|
40
|
+
opacity: 0.5;
|
|
41
|
+
}
|
|
42
|
+
.dot {
|
|
43
|
+
width: 6px;
|
|
44
|
+
height: 6px;
|
|
45
|
+
border-radius: 50%;
|
|
46
|
+
background: var(--color-text-muted);
|
|
47
|
+
animation: pulse 1.5s ease-in-out infinite;
|
|
48
|
+
}
|
|
49
|
+
@keyframes pulse {
|
|
50
|
+
0%, 100% { opacity: 0.3; }
|
|
51
|
+
50% { opacity: 1; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.popover {
|
|
55
|
+
position: absolute;
|
|
56
|
+
top: calc(100% + 6px);
|
|
57
|
+
right: 0;
|
|
58
|
+
width: 320px;
|
|
59
|
+
background: var(--color-bg-elevated);
|
|
60
|
+
border: 1px solid var(--color-border-hover);
|
|
61
|
+
border-radius: 10px;
|
|
62
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
|
63
|
+
z-index: 200;
|
|
64
|
+
overflow: hidden;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.popoverHeader {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: space-between;
|
|
71
|
+
padding: 10px 12px;
|
|
72
|
+
border-bottom: 1px solid var(--color-border);
|
|
73
|
+
}
|
|
74
|
+
.popoverTitle {
|
|
75
|
+
display: inline-flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: 6px;
|
|
78
|
+
font-size: 12px;
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
color: var(--color-success);
|
|
81
|
+
}
|
|
82
|
+
.popoverClose {
|
|
83
|
+
background: transparent;
|
|
84
|
+
border: none;
|
|
85
|
+
color: var(--color-text-muted);
|
|
86
|
+
cursor: pointer;
|
|
87
|
+
display: inline-flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
justify-content: center;
|
|
90
|
+
padding: 2px;
|
|
91
|
+
border-radius: 4px;
|
|
92
|
+
}
|
|
93
|
+
.popoverClose:hover { color: var(--color-text); background: var(--color-bg-hover); }
|
|
94
|
+
|
|
95
|
+
.popoverBody {
|
|
96
|
+
padding: 10px 12px;
|
|
97
|
+
margin: 0;
|
|
98
|
+
font-size: 12px;
|
|
99
|
+
line-height: 1.5;
|
|
100
|
+
color: var(--color-text);
|
|
101
|
+
}
|
|
102
|
+
.popoverBody strong {
|
|
103
|
+
color: var(--color-success);
|
|
104
|
+
font-family: var(--font-mono);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.popoverActions {
|
|
108
|
+
display: flex;
|
|
109
|
+
gap: 6px;
|
|
110
|
+
padding: 8px 12px 12px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.primaryAction,
|
|
114
|
+
.secondaryAction {
|
|
115
|
+
display: inline-flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: 5px;
|
|
118
|
+
flex: 1;
|
|
119
|
+
height: 32px;
|
|
120
|
+
padding: 0 10px;
|
|
121
|
+
font-size: 11px;
|
|
122
|
+
font-weight: 600;
|
|
123
|
+
border-radius: 6px;
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
justify-content: center;
|
|
126
|
+
font-family: var(--font-sans);
|
|
127
|
+
transition: all 150ms;
|
|
128
|
+
}
|
|
129
|
+
.primaryAction {
|
|
130
|
+
background: var(--color-success);
|
|
131
|
+
color: var(--color-bg);
|
|
132
|
+
border: 1px solid var(--color-success);
|
|
133
|
+
}
|
|
134
|
+
.primaryAction:hover {
|
|
135
|
+
background: var(--color-success);
|
|
136
|
+
filter: brightness(0.9);
|
|
137
|
+
}
|
|
138
|
+
.secondaryAction {
|
|
139
|
+
background: var(--color-surface);
|
|
140
|
+
color: var(--color-text);
|
|
141
|
+
border: 1px solid var(--color-border);
|
|
142
|
+
}
|
|
143
|
+
.secondaryAction:hover {
|
|
144
|
+
background: var(--color-bg-hover);
|
|
145
|
+
border-color: var(--color-text-muted);
|
|
146
|
+
}
|
package/web/src/global.css
CHANGED
|
@@ -202,6 +202,21 @@ code { font-family: var(--font-mono); font-size: 12px; background: var(--color-s
|
|
|
202
202
|
min-height: 0;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
.update-warning-banner {
|
|
206
|
+
position: sticky;
|
|
207
|
+
top: var(--header-h, 60px);
|
|
208
|
+
z-index: 90;
|
|
209
|
+
padding: 10px 18px;
|
|
210
|
+
background: var(--color-danger);
|
|
211
|
+
color: #fff;
|
|
212
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.22);
|
|
213
|
+
font-weight: 800;
|
|
214
|
+
font-size: 13px;
|
|
215
|
+
letter-spacing: 0.01em;
|
|
216
|
+
text-align: center;
|
|
217
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
|
|
218
|
+
}
|
|
219
|
+
|
|
205
220
|
/* ─── Scrollbar ─── */
|
|
206
221
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
207
222
|
::-webkit-scrollbar-track { background: var(--color-bg); }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/urlState.constants.js
|
|
3
|
+
* @description URL param validation tables — extracted from useUrlState for testability.
|
|
4
|
+
* 📖 Keep these in sync with src/tui/tui-state.js TIER_CYCLE / VERDICT_CYCLE /
|
|
5
|
+
* 📖 HEALTH_CYCLE constants so the URL reflects the same filter universe
|
|
6
|
+
* 📖 the TUI uses.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const TIER_VALUES = new Set(['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C', 'all'])
|
|
10
|
+
const STATUS_VALUES = new Set(['up', 'down', 'pending', 'all'])
|
|
11
|
+
const SORT_VALUES = new Set([
|
|
12
|
+
'mood', 'idx', 'tier', 'sweScore', 'ctx', 'label', 'origin',
|
|
13
|
+
'latestPing', 'avg', 'condition', 'verdict', 'stability', 'uptime',
|
|
14
|
+
'aiLatency', 'tps', 'trend',
|
|
15
|
+
])
|
|
16
|
+
const VIEW_VALUES = new Set(['dashboard', 'settings', 'analytics', 'recommend', 'router', 'help', 'changelog'])
|
|
17
|
+
const DIR_VALUES = new Set(['asc', 'desc'])
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
TIER_VALUES as VALID_TIERS,
|
|
21
|
+
STATUS_VALUES as VALID_STATUS,
|
|
22
|
+
SORT_VALUES as VALID_SORTS,
|
|
23
|
+
VIEW_VALUES as VALID_VIEWS,
|
|
24
|
+
DIR_VALUES as VALID_DIRS,
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useChangelog.js
|
|
3
|
+
* @description React hook for the changelog data — M2 parity with the TUI's `N` key overlay.
|
|
4
|
+
* 📖 Loads `/api/changelog` once on mount, exposes the parsed { versions } map,
|
|
5
|
+
* 📖 and provides helpers for the index/details two-phase modal.
|
|
6
|
+
*
|
|
7
|
+
* @functions
|
|
8
|
+
* → useChangelog() — { versions, sortedVersions, getVersion, loading, error, refresh }
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useMemo, useState, useCallback } from 'react'
|
|
11
|
+
|
|
12
|
+
export function useChangelog() {
|
|
13
|
+
const [versions, setVersions] = useState({})
|
|
14
|
+
const [loading, setLoading] = useState(true)
|
|
15
|
+
const [error, setError] = useState(null)
|
|
16
|
+
|
|
17
|
+
const refresh = useCallback(async () => {
|
|
18
|
+
try {
|
|
19
|
+
const resp = await fetch('/api/changelog')
|
|
20
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
21
|
+
const data = await resp.json()
|
|
22
|
+
setVersions(data?.versions ?? {})
|
|
23
|
+
setError(null)
|
|
24
|
+
} catch (err) {
|
|
25
|
+
setError(err.message || 'Failed to load changelog')
|
|
26
|
+
} finally {
|
|
27
|
+
setLoading(false)
|
|
28
|
+
}
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
useEffect(() => { refresh() }, [refresh])
|
|
32
|
+
|
|
33
|
+
// 📖 Sort versions in descending semver order. We do a string-based compare
|
|
34
|
+
// 📖 on the dotted tuples so '0.10.0' > '0.9.0' works correctly.
|
|
35
|
+
const sortedVersions = useMemo(() => {
|
|
36
|
+
return Object.keys(versions).sort((a, b) => {
|
|
37
|
+
const ap = a.split('.').map(Number)
|
|
38
|
+
const bp = b.split('.').map(Number)
|
|
39
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
40
|
+
const av = ap[i] || 0
|
|
41
|
+
const bv = bp[i] || 0
|
|
42
|
+
if (bv !== av) return bv - av
|
|
43
|
+
}
|
|
44
|
+
return 0
|
|
45
|
+
})
|
|
46
|
+
}, [versions])
|
|
47
|
+
|
|
48
|
+
const getVersion = useCallback((v) => versions[v] ?? null, [versions])
|
|
49
|
+
|
|
50
|
+
return { versions, sortedVersions, getVersion, loading, error, refresh }
|
|
51
|
+
}
|
|
@@ -42,6 +42,7 @@ export function useSocket(serverUrl = '') {
|
|
|
42
42
|
const [globalBenchmarkRunning, setGlobalBenchmarkRunning] = useState(false)
|
|
43
43
|
const [globalBenchmarkTotal, setGlobalBenchmarkTotal] = useState(0)
|
|
44
44
|
const [globalBenchmarkCompleted, setGlobalBenchmarkCompleted] = useState(0)
|
|
45
|
+
const [updateStatus, setUpdateStatus] = useState(null)
|
|
45
46
|
|
|
46
47
|
const socketRef = useRef(null)
|
|
47
48
|
const esRef = useRef(null)
|
|
@@ -62,6 +63,7 @@ export function useSocket(serverUrl = '') {
|
|
|
62
63
|
setGlobalBenchmarkRunning(Boolean(data.globalBenchmarkRunning))
|
|
63
64
|
setGlobalBenchmarkTotal(Number.isFinite(data.globalBenchmarkTotal) ? data.globalBenchmarkTotal : 0)
|
|
64
65
|
setGlobalBenchmarkCompleted(Number.isFinite(data.globalBenchmarkCompleted) ? data.globalBenchmarkCompleted : 0)
|
|
66
|
+
setUpdateStatus(data.updateStatus && data.updateStatus.allowedOutdated ? data.updateStatus : null)
|
|
65
67
|
setUpdateCount((count) => count + 1)
|
|
66
68
|
lastUpdateRef.current = Date.now()
|
|
67
69
|
if (source !== 'poll') {
|
|
@@ -196,5 +198,6 @@ export function useSocket(serverUrl = '') {
|
|
|
196
198
|
globalBenchmarkRunning,
|
|
197
199
|
globalBenchmarkTotal,
|
|
198
200
|
globalBenchmarkCompleted,
|
|
201
|
+
updateStatus,
|
|
199
202
|
}
|
|
200
203
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useUpdateChecker.js
|
|
3
|
+
* @description React hook for the update chip + popover — M2 parity with TUI `Shift+U`.
|
|
4
|
+
* 📖 Polls `/api/version` every 5 minutes (matches the TUI cadence) and exposes
|
|
5
|
+
* 📖 `updateAvailable` (boolean) + `latestVersion` (string) for the header chip.
|
|
6
|
+
* 📖 The chip's "Update now" button calls `/api/update/run` which spawns the
|
|
7
|
+
* 📖 detected package manager in the background; the Web UI just surfaces a
|
|
8
|
+
* 📖 toast and tells the user to restart the dashboard to apply the update.
|
|
9
|
+
*
|
|
10
|
+
* @functions
|
|
11
|
+
* → useUpdateChecker({ onToast }) — { latestVersion, updateAvailable, runUpdate, checkNow, loading }
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useState, useRef } from 'react'
|
|
14
|
+
|
|
15
|
+
const POLL_INTERVAL_MS = 5 * 60_000
|
|
16
|
+
|
|
17
|
+
// 📖 Lightweight semver compare: returns 1 if a > b, -1 if a < b, 0 if equal.
|
|
18
|
+
function semverCompare(a, b) {
|
|
19
|
+
if (!a || !b) return 0
|
|
20
|
+
const ap = a.replace(/^v/, '').split('.').map(Number)
|
|
21
|
+
const bp = b.replace(/^v/, '').split('.').map(Number)
|
|
22
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
23
|
+
const av = ap[i] || 0
|
|
24
|
+
const bv = bp[i] || 0
|
|
25
|
+
if (bv !== av) return bv - av
|
|
26
|
+
}
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useUpdateChecker({ onToast } = {}) {
|
|
31
|
+
const [localVersion, setLocalVersion] = useState(null)
|
|
32
|
+
const [latestVersion, setLatestVersion] = useState(null)
|
|
33
|
+
const [lastReleaseDate, setLastReleaseDate] = useState(null)
|
|
34
|
+
const [error, setError] = useState(null)
|
|
35
|
+
const [loading, setLoading] = useState(false)
|
|
36
|
+
const intervalRef = useRef(null)
|
|
37
|
+
|
|
38
|
+
const checkNow = useCallback(async () => {
|
|
39
|
+
setLoading(true)
|
|
40
|
+
try {
|
|
41
|
+
const resp = await fetch('/api/version')
|
|
42
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
43
|
+
const data = await resp.json()
|
|
44
|
+
setLocalVersion(data.local ?? null)
|
|
45
|
+
setLatestVersion(data.latest ?? null)
|
|
46
|
+
setLastReleaseDate(data.lastReleaseDate ?? null)
|
|
47
|
+
setError(data.error ?? null)
|
|
48
|
+
} catch (err) {
|
|
49
|
+
setError(err.message || 'update check failed')
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false)
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
// 📖 Initial check + 5-minute polling. We stop polling on unmount.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
checkNow()
|
|
58
|
+
intervalRef.current = setInterval(checkNow, POLL_INTERVAL_MS)
|
|
59
|
+
return () => {
|
|
60
|
+
if (intervalRef.current) clearInterval(intervalRef.current)
|
|
61
|
+
}
|
|
62
|
+
}, [checkNow])
|
|
63
|
+
|
|
64
|
+
// 📖 updateAvailable is true when latest > local. A null latest means the
|
|
65
|
+
// 📖 npm registry is unreachable, so we don't surface a chip.
|
|
66
|
+
const updateAvailable = Boolean(latestVersion && localVersion && semverCompare(latestVersion, localVersion) > 0)
|
|
67
|
+
|
|
68
|
+
const runUpdate = useCallback(async () => {
|
|
69
|
+
if (!updateAvailable) {
|
|
70
|
+
onToast?.('No update available.', 'info')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const resp = await fetch('/api/update/run', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ version: latestVersion }),
|
|
78
|
+
})
|
|
79
|
+
const data = await resp.json().catch(() => ({}))
|
|
80
|
+
if (resp.ok && data?.started) {
|
|
81
|
+
onToast?.(`Update to v${latestVersion} started — restart the dashboard to apply.`, 'success')
|
|
82
|
+
} else {
|
|
83
|
+
onToast?.(data?.error || data?.message || 'Update failed', 'error')
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
onToast?.(err.message || 'Update request failed', 'error')
|
|
87
|
+
}
|
|
88
|
+
}, [updateAvailable, latestVersion, onToast])
|
|
89
|
+
|
|
90
|
+
return { localVersion, latestVersion, lastReleaseDate, updateAvailable, checkNow, runUpdate, loading, error }
|
|
91
|
+
}
|
|
@@ -1,52 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/hooks/useUrlState.js
|
|
3
3
|
* @description URL deep-linking hook — the Web's answer to TUI CLI flags (--tier, --sort, etc.).
|
|
4
|
-
* 📖 M1
|
|
5
|
-
* 📖 M2
|
|
4
|
+
* 📖 M1 = read-only hydration on mount.
|
|
5
|
+
* 📖 M2 = adds write-back: every filter / sort / view / tool-mode / palette
|
|
6
|
+
* 📖 change pushes to the URL via `history.replaceState` so the URL stays
|
|
7
|
+
* 📖 in sync with the visible Web state and any URL is shareable.
|
|
6
8
|
*
|
|
7
|
-
* 📖
|
|
8
|
-
* 📖 ?
|
|
9
|
-
* 📖 ?
|
|
10
|
-
* 📖 ?
|
|
11
|
-
* 📖 ?
|
|
12
|
-
* 📖 ?
|
|
13
|
-
* 📖 ?
|
|
14
|
-
* 📖 ?
|
|
15
|
-
* 📖 ?q=<
|
|
16
|
-
* 📖 ?
|
|
9
|
+
* 📖 URL params (all optional, all shareable):
|
|
10
|
+
* 📖 ?view=dashboard|settings|analytics|recommend|router|help|changelog
|
|
11
|
+
* 📖 ?tier=S+|S|A+|A|A-|B+|B|C|all
|
|
12
|
+
* 📖 ?status=up|down|pending|all
|
|
13
|
+
* 📖 ?provider=<providerKey>|all
|
|
14
|
+
* 📖 ?verdict=<verdict>|all
|
|
15
|
+
* 📖 ?health=<health>|all
|
|
16
|
+
* 📖 ?sort=<col>&dir=asc|desc
|
|
17
|
+
* 📖 ?q=<searchText>
|
|
18
|
+
* 📖 ?toolMode=<toolKey>
|
|
19
|
+
* 📖 ?palette=open (when the palette should be open on load)
|
|
17
20
|
*
|
|
18
21
|
* @functions
|
|
19
|
-
* → useUrlState({ currentView, setCurrentView, filterState })
|
|
20
|
-
*
|
|
21
|
-
* @see ideas/tui-web-feature-parity.md §5.4 — full CLI-flag ↔ URL-param mapping
|
|
22
|
+
* → useUrlState({ currentView, setCurrentView, filterState, paletteOpen, setPaletteOpen })
|
|
23
|
+
* → buildUrlParams(state) — pure helper exposed for tests
|
|
22
24
|
*/
|
|
23
|
-
import { useEffect } from 'react'
|
|
24
|
-
|
|
25
|
-
// 📖 Valid enum values for cycle-style params. Keys here determine which params
|
|
26
|
-
// 📖 get accepted silently vs ignored. Anything not in the list is dropped.
|
|
27
|
-
const VALID_TIERS = new Set(['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C', 'all'])
|
|
28
|
-
const VALID_STATUS = new Set(['up', 'down', 'pending', 'all'])
|
|
29
|
-
const VALID_SORTS = new Set([
|
|
30
|
-
'mood', 'idx', 'tier', 'sweScore', 'ctx', 'label', 'origin',
|
|
31
|
-
'latestPing', 'avg', 'condition', 'verdict', 'stability', 'uptime',
|
|
32
|
-
'aiLatency', 'tps', 'trend',
|
|
33
|
-
])
|
|
34
|
-
const VALID_VIEWS = new Set(['dashboard', 'settings', 'analytics'])
|
|
35
|
-
const VALID_DIRS = new Set(['asc', 'desc'])
|
|
25
|
+
import { useEffect, useRef, useCallback } from 'react'
|
|
26
|
+
import { VALID_TIERS, VALID_STATUS, VALID_SORTS, VALID_VIEWS, VALID_DIRS } from './urlState.constants.js'
|
|
36
27
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* 📖 Returns a flat object with camelCase keys, only including recognized params.
|
|
40
|
-
* @returns {{
|
|
41
|
-
* tier: string|null, status: string|null, provider: string|null,
|
|
42
|
-
* verdict: string|null, health: string|null,
|
|
43
|
-
* sort: string|null, dir: string|null, view: string|null, q: string,
|
|
44
|
-
* }}
|
|
45
|
-
*/
|
|
28
|
+
// 📖 Read the current URL params as a normalized object. Returns null on SSR
|
|
29
|
+
// 📖 or when the URL is invalid.
|
|
46
30
|
function parseUrlParams() {
|
|
47
31
|
if (typeof window === 'undefined') return null
|
|
48
32
|
const params = new URLSearchParams(window.location.search)
|
|
49
33
|
const out = {}
|
|
34
|
+
if (params.has('view') && VALID_VIEWS.has(params.get('view'))) out.view = params.get('view')
|
|
50
35
|
if (params.has('tier') && VALID_TIERS.has(params.get('tier'))) out.tier = params.get('tier')
|
|
51
36
|
if (params.has('status') && VALID_STATUS.has(params.get('status'))) out.status = params.get('status')
|
|
52
37
|
if (params.has('provider')) out.provider = params.get('provider')
|
|
@@ -54,40 +39,115 @@ function parseUrlParams() {
|
|
|
54
39
|
if (params.has('health')) out.health = params.get('health')
|
|
55
40
|
if (params.has('sort') && VALID_SORTS.has(params.get('sort'))) out.sort = params.get('sort')
|
|
56
41
|
if (params.has('dir') && VALID_DIRS.has(params.get('dir'))) out.dir = params.get('dir')
|
|
57
|
-
if (params.has('view') && VALID_VIEWS.has(params.get('view'))) out.view = params.get('view')
|
|
58
42
|
if (params.has('q')) out.q = params.get('q')
|
|
43
|
+
if (params.has('toolMode')) out.toolMode = params.get('toolMode')
|
|
44
|
+
if (params.has('palette') && params.get('palette') === 'open') out.palette = 'open'
|
|
59
45
|
return out
|
|
60
46
|
}
|
|
61
47
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
48
|
+
// 📖 Build a URLSearchParams object from the live state. Pure for testing.
|
|
49
|
+
export function buildUrlParams(state) {
|
|
50
|
+
const params = new URLSearchParams()
|
|
51
|
+
if (!state) return params
|
|
52
|
+
if (state.currentView && state.currentView !== 'dashboard') params.set('view', state.currentView)
|
|
53
|
+
if (state.filterTier && state.filterTier !== 'all') params.set('tier', state.filterTier)
|
|
54
|
+
if (state.filterStatus && state.filterStatus !== 'all') params.set('status', state.filterStatus)
|
|
55
|
+
if (state.filterProvider && state.filterProvider !== 'all') params.set('provider', state.filterProvider)
|
|
56
|
+
if (state.filterVerdict && state.filterVerdict !== 'all') params.set('verdict', state.filterVerdict)
|
|
57
|
+
if (state.filterHealth && state.filterHealth !== 'all') params.set('health', state.filterHealth)
|
|
58
|
+
if (state.sortColumn) {
|
|
59
|
+
params.set('sort', state.sortColumn)
|
|
60
|
+
if (state.sortDirection) params.set('dir', state.sortDirection)
|
|
61
|
+
}
|
|
62
|
+
if (state.searchQuery) params.set('q', state.searchQuery)
|
|
63
|
+
if (state.toolMode) params.set('toolMode', state.toolMode)
|
|
64
|
+
if (state.paletteOpen) params.set('palette', 'open')
|
|
65
|
+
return params
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 📖 Debounced write-back helper. We don't want to push 5 history entries per
|
|
69
|
+
// 📖 second when the user is typing in the search box.
|
|
70
|
+
function writeUrl(state) {
|
|
71
|
+
if (typeof window === 'undefined') return
|
|
72
|
+
const params = buildUrlParams(state)
|
|
73
|
+
const search = params.toString()
|
|
74
|
+
const newUrl = search
|
|
75
|
+
? `${window.location.pathname}?${search}${window.location.hash}`
|
|
76
|
+
: `${window.location.pathname}${window.location.hash}`
|
|
77
|
+
// 📖 replaceState (not pushState) so back/forward don't fill up with
|
|
78
|
+
// 📖 every keystroke. The current URL still updates visibly.
|
|
79
|
+
window.history.replaceState({}, '', newUrl)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function useUrlState({
|
|
83
|
+
currentView, setCurrentView,
|
|
84
|
+
filterState = null,
|
|
85
|
+
paletteOpen = false, setPaletteOpen = () => {},
|
|
86
|
+
toolMode = null, setToolMode = () => {},
|
|
87
|
+
}) {
|
|
88
|
+
// 📖 Track a debounce handle so we can coalesce rapid filter / sort changes.
|
|
89
|
+
const writeTimerRef = useRef(null)
|
|
90
|
+
|
|
91
|
+
// 📖 Hydrate from URL on mount, exactly once. We don't want to re-hydrate
|
|
92
|
+
// 📖 on every render — the user's actions should drive the URL, not vice versa.
|
|
93
|
+
const hydratedRef = useRef(false)
|
|
78
94
|
useEffect(() => {
|
|
95
|
+
if (hydratedRef.current) return
|
|
96
|
+
hydratedRef.current = true
|
|
97
|
+
|
|
79
98
|
const params = parseUrlParams()
|
|
80
99
|
if (!params) return
|
|
81
100
|
|
|
82
|
-
if (params.view && params.view !== currentView) {
|
|
101
|
+
if (params.view && setCurrentView && params.view !== currentView) {
|
|
83
102
|
setCurrentView(params.view)
|
|
84
103
|
}
|
|
104
|
+
if (params.tier && filterState?.setFilterTier) filterState.setFilterTier(params.tier)
|
|
105
|
+
if (params.status && filterState?.setFilterStatus) filterState.setFilterStatus(params.status)
|
|
106
|
+
if (params.provider && filterState?.setFilterProvider) filterState.setFilterProvider(params.provider)
|
|
107
|
+
if (params.verdict && filterState?.setFilterVerdict) filterState.setFilterVerdict(params.verdict)
|
|
108
|
+
if (params.health && filterState?.setFilterHealth) filterState.setFilterHealth(params.health)
|
|
109
|
+
if (params.sort && filterState?.toggleSort) {
|
|
110
|
+
// 📖 Re-trigger sort: pass the column to setSortColumn + setSortDirection
|
|
111
|
+
// 📖 The hook exposes toggleSort which is a 3-state cycle; for hydration
|
|
112
|
+
// 📖 we want a deterministic state, so we use the lower-level setters.
|
|
113
|
+
if (filterState.setSortColumn) filterState.setSortColumn(params.sort)
|
|
114
|
+
if (params.dir && filterState.setSortDirection) filterState.setSortDirection(params.dir)
|
|
115
|
+
}
|
|
116
|
+
if (params.q !== undefined && filterState?.setSearchQuery) filterState.setSearchQuery(params.q)
|
|
117
|
+
if (params.toolMode && setToolMode) setToolMode(params.toolMode)
|
|
118
|
+
if (params.palette === 'open' && setPaletteOpen) setPaletteOpen(true)
|
|
119
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
+
}, [])
|
|
85
121
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
122
|
+
// 📖 Write-back: any change to currentView / filter / toolMode / palette
|
|
123
|
+
// 📖 updates the URL. Debounced at 80ms so rapid filter typing doesn't
|
|
124
|
+
// 📖 thrash the history stack.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!hydratedRef.current) return
|
|
127
|
+
if (writeTimerRef.current) clearTimeout(writeTimerRef.current)
|
|
128
|
+
writeTimerRef.current = setTimeout(() => {
|
|
129
|
+
writeUrl({
|
|
130
|
+
currentView,
|
|
131
|
+
filterTier: filterState?.filterTier,
|
|
132
|
+
filterStatus: filterState?.filterStatus,
|
|
133
|
+
filterProvider: filterState?.filterProvider,
|
|
134
|
+
filterVerdict: filterState?.filterVerdict,
|
|
135
|
+
filterHealth: filterState?.filterHealth,
|
|
136
|
+
sortColumn: filterState?.sortColumn,
|
|
137
|
+
sortDirection: filterState?.sortDirection,
|
|
138
|
+
searchQuery: filterState?.searchQuery,
|
|
139
|
+
toolMode,
|
|
140
|
+
paletteOpen,
|
|
141
|
+
})
|
|
142
|
+
}, 80)
|
|
143
|
+
return () => {
|
|
144
|
+
if (writeTimerRef.current) clearTimeout(writeTimerRef.current)
|
|
145
|
+
}
|
|
146
|
+
}, [
|
|
147
|
+
currentView, paletteOpen, toolMode,
|
|
148
|
+
filterState?.filterTier, filterState?.filterStatus, filterState?.filterProvider,
|
|
149
|
+
filterState?.filterVerdict, filterState?.filterHealth,
|
|
150
|
+
filterState?.sortColumn, filterState?.sortDirection,
|
|
151
|
+
filterState?.searchQuery,
|
|
152
|
+
])
|
|
90
153
|
}
|
|
91
|
-
|
|
92
|
-
// 📖 Re-export the parser so M2's write-back path can use the same validator.
|
|
93
|
-
export { parseUrlParams, VALID_TIERS, VALID_STATUS, VALID_SORTS, VALID_VIEWS, VALID_DIRS }
|