free-coding-models 0.5.14 → 0.5.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog/v0.5.12.md +86 -2
- package/changelog/v0.5.13.md +19 -53
- package/changelog/v0.5.15.md +17 -0
- package/package.json +4 -3
- package/src/tui/app.js +16 -4
- package/src/tui/cli-help.js +87 -0
- package/src/tui/key-handler.js +2 -14
- package/src/tui/render-table.js +46 -26
- package/src/tui/theme.js +70 -0
- package/web/README.md +1 -1
- package/web/dist/assets/index-BRqzsHVw.css +1 -0
- package/web/dist/assets/index-CUYQh5_t.js +39 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +83 -14
- package/web/src/App.jsx +15 -1
- package/web/src/components/dashboard/DetailPanel.jsx +1 -1
- package/web/src/components/dashboard/DetailPanel.module.css +5 -0
- package/web/src/components/dashboard/FilterBar.jsx +98 -58
- package/web/src/components/dashboard/FilterBar.module.css +103 -33
- package/web/src/components/dashboard/ProviderDropdown.jsx +156 -0
- package/web/src/components/dashboard/ProviderDropdown.module.css +248 -0
- package/web/src/components/help/HelpView.jsx +13 -0
- package/web/src/components/playground/PlaygroundView.jsx +193 -12
- package/web/src/components/playground/PlaygroundView.module.css +82 -0
- package/web/src/components/router/RouterView.jsx +15 -6
- package/web/vite.config.js +1 -1
- package/web/dist/assets/index-Bzqz7KbT.js +0 -39
- package/web/dist/assets/index-H9JWDRIh.css +0 -1
package/web/dist/index.html
CHANGED
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
49
49
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
50
50
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
51
|
-
<script type="module" crossorigin src="/assets/index-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-CUYQh5_t.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BRqzsHVw.css">
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
55
55
|
<div id="root"></div>
|
package/web/server.js
CHANGED
|
@@ -1360,32 +1360,101 @@ async function handleRequest(req, res) {
|
|
|
1360
1360
|
}
|
|
1361
1361
|
|
|
1362
1362
|
case '/api/playground/chat': {
|
|
1363
|
-
// 📖 Playground proxy:
|
|
1364
|
-
// 📖 local router daemon
|
|
1365
|
-
// 📖
|
|
1366
|
-
// 📖
|
|
1367
|
-
// 📖 an external domain (no CORS, no exposed provider keys).
|
|
1363
|
+
// 📖 Playground proxy: routes chat-completions requests either to
|
|
1364
|
+
// 📖 the local router daemon (for "fcm" auto-router) or directly
|
|
1365
|
+
// 📖 to the provider (for specific models). This way the playground
|
|
1366
|
+
// 📖 works immediately with any "up" model, even without the daemon.
|
|
1368
1367
|
if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1368
|
+
const body = await readJsonBody(req)
|
|
1369
|
+
const wantsStream = body?.stream === true
|
|
1370
|
+
const requestedModel = body?.model || 'fcm'
|
|
1371
|
+
const isFcm = requestedModel === 'fcm'
|
|
1372
|
+
|
|
1373
|
+
// 📖 Resolve direct provider routing for non-fcm models.
|
|
1374
|
+
// 📖 Format: "providerKey/modelId" → look up source URL + API key.
|
|
1375
|
+
let directUrl = null
|
|
1376
|
+
let directApiKey = null
|
|
1377
|
+
let directModelId = null
|
|
1378
|
+
if (!isFcm) {
|
|
1379
|
+
const slashIdx = requestedModel.indexOf('/')
|
|
1380
|
+
if (slashIdx > 0) {
|
|
1381
|
+
const providerKey = requestedModel.slice(0, slashIdx)
|
|
1382
|
+
const modelId = requestedModel.slice(slashIdx + 1)
|
|
1383
|
+
const source = sources[providerKey]
|
|
1384
|
+
if (source?.url) {
|
|
1385
|
+
directApiKey = getApiKey(config, providerKey)
|
|
1386
|
+
// 📖 Always resolve the URL so we can differentiate "no key"
|
|
1387
|
+
// 📖 from "unknown model" in the error handler below.
|
|
1388
|
+
let baseUrl = source.url
|
|
1389
|
+
if (!baseUrl.includes('/chat/completions')) {
|
|
1390
|
+
baseUrl = baseUrl.replace(/\/+$/, '') + '/v1/chat/completions'
|
|
1391
|
+
}
|
|
1392
|
+
directUrl = baseUrl
|
|
1393
|
+
directModelId = modelId
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// 📖 Decide routing: direct to provider (non-fcm, has key) or via daemon.
|
|
1399
|
+
const useDirectRoute = !isFcm && directUrl && directApiKey
|
|
1400
|
+
|
|
1401
|
+
// 📖 If user picked a specific model but has no API key for it,
|
|
1402
|
+
// 📖 tell them instead of falling through to the daemon 503.
|
|
1403
|
+
if (!isFcm && directUrl && !directApiKey) {
|
|
1404
|
+
const providerKey = requestedModel.slice(0, requestedModel.indexOf('/'))
|
|
1405
|
+
sendJson(res, 401, { ok: false, error: `No API key configured for ${sources[providerKey]?.name || providerKey}. Add one in Settings to chat directly with this model.` })
|
|
1406
|
+
return
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// 📖 If user picked a specific model but we couldn't resolve it,
|
|
1410
|
+
// 📖 tell them instead of falling through to the daemon 503.
|
|
1411
|
+
if (!isFcm && !directUrl) {
|
|
1412
|
+
sendJson(res, 404, { ok: false, error: `Could not resolve provider for model "${requestedModel}". Try selecting a different model or use the auto-router (fcm).` })
|
|
1413
|
+
return
|
|
1414
|
+
}
|
|
1415
|
+
const upstreamUrl = useDirectRoute
|
|
1416
|
+
? directUrl
|
|
1417
|
+
: `http://127.0.0.1:${await readDaemonPort()}/v1/chat/completions`
|
|
1418
|
+
|
|
1419
|
+
if (!useDirectRoute && !await readDaemonPort()) {
|
|
1371
1420
|
sendJson(res, 503, { ok: false, error: 'Router daemon is not running. Start it from the Router card or with `free-coding-models --daemon-bg`.' })
|
|
1372
1421
|
return
|
|
1373
1422
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1423
|
+
|
|
1424
|
+
// 📖 Build the upstream payload. For direct routing, extract the
|
|
1425
|
+
// 📖 actual model ID from the providerKey/modelId format.
|
|
1426
|
+
const upstreamPayload = {
|
|
1427
|
+
...body,
|
|
1428
|
+
stream: wantsStream,
|
|
1429
|
+
model: useDirectRoute ? directModelId : requestedModel,
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// 📖 Build headers: direct routing needs auth + any provider-specific
|
|
1433
|
+
// 📖 headers. Daemon routing is just JSON content-type.
|
|
1434
|
+
const upstreamHeaders = { 'Content-Type': 'application/json' }
|
|
1435
|
+
if (useDirectRoute) {
|
|
1436
|
+
// 📖 Most providers use standard Bearer auth. A few need extra
|
|
1437
|
+
// 📖 headers (OpenRouter wants Referer + X-Title for ex).
|
|
1438
|
+
upstreamHeaders['Authorization'] = `Bearer ${directApiKey}`
|
|
1439
|
+
const providerKey = requestedModel.slice(0, requestedModel.indexOf('/'))
|
|
1440
|
+
if (providerKey === 'openrouter') {
|
|
1441
|
+
upstreamHeaders['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
|
|
1442
|
+
upstreamHeaders['X-Title'] = 'free-coding-models'
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1377
1446
|
const controller = new AbortController()
|
|
1378
1447
|
const timeout = setTimeout(() => controller.abort(), 120000)
|
|
1379
1448
|
req.on('close', () => controller.abort())
|
|
1380
1449
|
try {
|
|
1381
1450
|
const upstreamResp = await fetch(upstreamUrl, {
|
|
1382
1451
|
method: 'POST',
|
|
1383
|
-
headers:
|
|
1384
|
-
body: JSON.stringify(
|
|
1452
|
+
headers: upstreamHeaders,
|
|
1453
|
+
body: JSON.stringify(upstreamPayload),
|
|
1385
1454
|
signal: controller.signal,
|
|
1386
1455
|
})
|
|
1387
1456
|
if (wantsStream) {
|
|
1388
|
-
// 📖 Pipe SSE events straight from the
|
|
1457
|
+
// 📖 Pipe SSE events straight from the upstream to the browser.
|
|
1389
1458
|
res.writeHead(upstreamResp.status, {
|
|
1390
1459
|
'Content-Type': 'text/event-stream',
|
|
1391
1460
|
'Cache-Control': 'no-cache',
|
|
@@ -1407,7 +1476,7 @@ async function handleRequest(req, res) {
|
|
|
1407
1476
|
return
|
|
1408
1477
|
}
|
|
1409
1478
|
const json = await upstreamResp.json().catch(() => null)
|
|
1410
|
-
sendJson(res, upstreamResp.status, json || { ok: false, error: 'Empty response from
|
|
1479
|
+
sendJson(res, upstreamResp.status, json || { ok: false, error: 'Empty response from upstream' })
|
|
1411
1480
|
return
|
|
1412
1481
|
} catch (err) {
|
|
1413
1482
|
sendJson(res, 502, { ok: false, error: `Playground proxy failed: ${err.message || String(err)}` })
|
package/web/src/App.jsx
CHANGED
|
@@ -171,11 +171,25 @@ export default function App() {
|
|
|
171
171
|
} = useUpdateChecker({ onToast: addToast })
|
|
172
172
|
|
|
173
173
|
// 📖 Build the provider list for the FilterBar dropdown.
|
|
174
|
+
// 📖 Build the provider list for the FilterBar dropdown with aggregated health.
|
|
175
|
+
// 📖 `hasKey` = at least one model has an API key configured.
|
|
176
|
+
// 📖 `anyUp` = at least one model with status 'up' (key works).
|
|
177
|
+
// 📖 These drive the colored indicator dot in the custom provider dropdown.
|
|
174
178
|
const providers = useMemo(() => {
|
|
175
179
|
const map = {}
|
|
176
180
|
models.forEach((m) => {
|
|
177
|
-
if (!map[m.providerKey])
|
|
181
|
+
if (!map[m.providerKey]) {
|
|
182
|
+
map[m.providerKey] = {
|
|
183
|
+
key: m.providerKey,
|
|
184
|
+
name: m.origin,
|
|
185
|
+
count: 0,
|
|
186
|
+
hasKey: false,
|
|
187
|
+
anyUp: false,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
178
190
|
map[m.providerKey].count++
|
|
191
|
+
if (m.hasApiKey) map[m.providerKey].hasKey = true
|
|
192
|
+
if (m.status === 'up') map[m.providerKey].anyUp = true
|
|
179
193
|
})
|
|
180
194
|
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
|
|
181
195
|
}, [models])
|
|
@@ -87,7 +87,7 @@ export default function DetailPanel({
|
|
|
87
87
|
const compatible = isModelCompatibleWithTool(model.providerKey, toolMode)
|
|
88
88
|
|
|
89
89
|
return (
|
|
90
|
-
<div className={styles.panel}>
|
|
90
|
+
<div className={`${styles.panel} ${styles.panelOpen}`}>
|
|
91
91
|
<div className={styles.header}>
|
|
92
92
|
<h3 className={styles.title}>{model.label}</h3>
|
|
93
93
|
<button className={styles.closeBtn} onClick={onClose}>×</button>
|
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
* 📖 + custom text filter chip with "X" clear + reset view button (N).
|
|
6
6
|
* 📖 Each chip is a cycling button matching the TUI single-key behavior.
|
|
7
7
|
* 📖 The "Next ping in Xs" countdown still shows the live status.
|
|
8
|
+
* 📖 Filter groups (Tier, Status, Verdict, Health) are collapsible by default
|
|
9
|
+
* — click the trigger to expand the chip row, click again or outside to close.
|
|
8
10
|
*/
|
|
9
|
-
import { useState, useEffect, useMemo } from 'react'
|
|
10
|
-
import { IconRefresh, IconX, IconFilter,
|
|
11
|
+
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
|
12
|
+
import { IconRefresh, IconX, IconFilter, IconChevronDown } from '@tabler/icons-react'
|
|
11
13
|
import { getToolMeta } from '../../../../src/core/tool-metadata.js'
|
|
14
|
+
import ProviderDropdown from './ProviderDropdown.jsx'
|
|
12
15
|
import styles from './FilterBar.module.css'
|
|
13
16
|
|
|
14
17
|
// 📖 Chip sets match the TUI cycles 1:1 (see useFilter.js). Keep these in sync.
|
|
@@ -65,30 +68,84 @@ const PING_MODES = [
|
|
|
65
68
|
|
|
66
69
|
function formatCountdown(ms) {
|
|
67
70
|
if (ms == null) return null
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
return `${m}m${rem > 0 ? rem + 's' : ''}`
|
|
71
|
+
const totalSec = Math.max(0, ms / 1000)
|
|
72
|
+
if (totalSec < 60) return `${totalSec.toFixed(2)}s`
|
|
73
|
+
const m = Math.floor(totalSec / 60)
|
|
74
|
+
const s = totalSec % 60
|
|
75
|
+
return `${m}m ${s.toFixed(2)}s`
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
|
|
78
|
+
/**
|
|
79
|
+
* 📖 FilterGroup — collapsible chip selector. Shows label + active value as a compact
|
|
80
|
+
* trigger. Click to expand a dropdown with all options grouped as a segmented control.
|
|
81
|
+
* Click outside or pick a value to collapse. Designed to keep the filter bar minimal
|
|
82
|
+
* by default while exposing the full chip set on demand.
|
|
83
|
+
*/
|
|
84
|
+
function FilterGroup({ label, items, value, onChange, colorMap }) {
|
|
85
|
+
const [open, setOpen] = useState(false)
|
|
86
|
+
const ref = useRef(null)
|
|
87
|
+
|
|
88
|
+
const close = useCallback(() => setOpen(false), [])
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!open) return
|
|
92
|
+
const handler = (e) => {
|
|
93
|
+
if (ref.current && !ref.current.contains(e.target)) close()
|
|
94
|
+
}
|
|
95
|
+
const keyHandler = (e) => { if (e.key === 'Escape') close() }
|
|
96
|
+
document.addEventListener('mousedown', handler)
|
|
97
|
+
document.addEventListener('keydown', keyHandler)
|
|
98
|
+
return () => {
|
|
99
|
+
document.removeEventListener('mousedown', handler)
|
|
100
|
+
document.removeEventListener('keydown', keyHandler)
|
|
101
|
+
}
|
|
102
|
+
}, [open, close])
|
|
103
|
+
|
|
104
|
+
const activeItem = items.find(i => i.key === value)
|
|
105
|
+
const activeLabel = activeItem?.label ?? value ?? 'All'
|
|
106
|
+
const isFiltered = value !== 'all'
|
|
107
|
+
|
|
77
108
|
return (
|
|
78
|
-
<div className={styles.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
<div className={styles.filterGroup} ref={ref}>
|
|
110
|
+
<button
|
|
111
|
+
className={`${styles.filterTrigger} ${isFiltered ? styles.filterTriggerActive : ''} ${open ? styles.filterTriggerExpanded : ''}`}
|
|
112
|
+
onClick={() => setOpen(!open)}
|
|
113
|
+
title={`${label}: ${activeLabel}`}
|
|
114
|
+
aria-expanded={open}
|
|
115
|
+
aria-haspopup="listbox"
|
|
116
|
+
>
|
|
117
|
+
<span className={styles.filterTriggerLabel}>{label}</span>
|
|
118
|
+
<span className={styles.filterTriggerSep}>:</span>
|
|
119
|
+
<span className={styles.filterTriggerValue}>{activeLabel}</span>
|
|
120
|
+
<IconChevronDown
|
|
121
|
+
size={12}
|
|
122
|
+
stroke={2}
|
|
123
|
+
className={`${styles.filterTriggerChevron} ${open ? styles.chevronOpen : ''}`}
|
|
124
|
+
/>
|
|
125
|
+
</button>
|
|
126
|
+
{open && (
|
|
127
|
+
<div className={styles.filterDropdown} role="listbox">
|
|
128
|
+
<div className={styles.filterChipRow}>
|
|
129
|
+
{items.map((item, i) => {
|
|
130
|
+
const active = value === item.key
|
|
131
|
+
const customColor = colorMap?.[item.key]
|
|
132
|
+
return (
|
|
133
|
+
<button
|
|
134
|
+
key={item.key}
|
|
135
|
+
role="option"
|
|
136
|
+
aria-selected={active}
|
|
137
|
+
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
|
|
138
|
+
style={active && customColor ? { '--chip-active-color': customColor } : {}}
|
|
139
|
+
onClick={() => { onChange(item.key); close() }}
|
|
140
|
+
title={item.hint || item.label}
|
|
141
|
+
>
|
|
142
|
+
{item.label}
|
|
143
|
+
</button>
|
|
144
|
+
)
|
|
145
|
+
})}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
92
149
|
</div>
|
|
93
150
|
)
|
|
94
151
|
}
|
|
@@ -121,7 +178,7 @@ export default function FilterBar({
|
|
|
121
178
|
setCountdown(rem > 0 ? rem : 0)
|
|
122
179
|
}
|
|
123
180
|
tick()
|
|
124
|
-
const id = setInterval(tick,
|
|
181
|
+
const id = setInterval(tick, 100)
|
|
125
182
|
return () => clearInterval(id)
|
|
126
183
|
}, [nextPingAt])
|
|
127
184
|
|
|
@@ -167,10 +224,10 @@ export default function FilterBar({
|
|
|
167
224
|
</div>
|
|
168
225
|
)}
|
|
169
226
|
|
|
170
|
-
<
|
|
171
|
-
<
|
|
172
|
-
<
|
|
173
|
-
<
|
|
227
|
+
<FilterGroup label="Tier" items={TIERS} value={filterTier} onChange={setFilterTier} />
|
|
228
|
+
<FilterGroup label="Status" items={STATUSES} value={filterStatus} onChange={setFilterStatus} />
|
|
229
|
+
<FilterGroup label="Verdict" items={VERDICTS} value={filterVerdict} onChange={setFilterVerdict} />
|
|
230
|
+
<FilterGroup label="Health" items={HEALTHS} value={filterHealth} onChange={setFilterHealth} />
|
|
174
231
|
|
|
175
232
|
<div className={styles.group}>
|
|
176
233
|
<label className={styles.filterLabel} htmlFor="visibility-select">Visibility</label>
|
|
@@ -189,19 +246,12 @@ export default function FilterBar({
|
|
|
189
246
|
</div>
|
|
190
247
|
|
|
191
248
|
<div className={styles.group}>
|
|
192
|
-
<label className={styles.filterLabel}
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
className={styles.providerSelect}
|
|
249
|
+
<label className={styles.filterLabel}>Provider</label>
|
|
250
|
+
<ProviderDropdown
|
|
251
|
+
providers={providers}
|
|
196
252
|
value={filterProvider}
|
|
197
|
-
onChange={
|
|
198
|
-
|
|
199
|
-
>
|
|
200
|
-
<option value="all">All Providers</option>
|
|
201
|
-
{providers.map((p) => (
|
|
202
|
-
<option key={p.key} value={p.key}>{p.name} ({p.count})</option>
|
|
203
|
-
))}
|
|
204
|
-
</select>
|
|
253
|
+
onChange={setFilterProvider}
|
|
254
|
+
/>
|
|
205
255
|
</div>
|
|
206
256
|
|
|
207
257
|
<div className={styles.group}>
|
|
@@ -246,23 +296,14 @@ export default function FilterBar({
|
|
|
246
296
|
</button>
|
|
247
297
|
)}
|
|
248
298
|
|
|
249
|
-
{/* ── Ping interval selector ── */}
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
style={pingMode === m.key ? { '--ping-active-color': m.color } : {}}
|
|
258
|
-
onClick={() => setPingMode(m.key)}
|
|
259
|
-
title={`${m.interval} interval`}
|
|
260
|
-
>
|
|
261
|
-
{m.label}
|
|
262
|
-
</button>
|
|
263
|
-
))}
|
|
264
|
-
</div>
|
|
265
|
-
</div>
|
|
299
|
+
{/* ── Ping interval selector (collapsible group) ── */}
|
|
300
|
+
<FilterGroup
|
|
301
|
+
label="Ping"
|
|
302
|
+
items={PING_MODES}
|
|
303
|
+
value={pingMode}
|
|
304
|
+
onChange={setPingMode}
|
|
305
|
+
colorMap={Object.fromEntries(PING_MODES.map(m => [m.key, m.color]))}
|
|
306
|
+
/>
|
|
266
307
|
|
|
267
308
|
{/* ── Next ping countdown (TUI parity: always show the delay) ──
|
|
268
309
|
📖 The TUI footer always renders `next : Xs` regardless of whether
|
|
@@ -275,7 +316,6 @@ export default function FilterBar({
|
|
|
275
316
|
<div className={styles.nextPing} title="Next ping countdown">
|
|
276
317
|
<span className={styles.nextPingLabel}>next ping in</span>
|
|
277
318
|
<span className={styles.nextPingTime}>{countdownDisplay ?? '—'}</span>
|
|
278
|
-
{isPinging && <span className={styles.pingingDot} aria-hidden="true" />}
|
|
279
319
|
</div>
|
|
280
320
|
</div>
|
|
281
321
|
</section>
|
|
@@ -24,19 +24,112 @@
|
|
|
24
24
|
white-space: nowrap;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/* ──
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/* ── Collapsible filter groups (Tier, Status, Verdict, Health) ── */
|
|
28
|
+
.filterGroup {
|
|
29
|
+
position: relative;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.filterTrigger {
|
|
33
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
34
|
+
font-size: 11px; font-family: var(--font-mono);
|
|
31
35
|
padding: 4px 8px; border-radius: 5px;
|
|
32
|
-
border: 1px solid var(--color-border);
|
|
33
|
-
|
|
36
|
+
border: 1px solid var(--color-border);
|
|
37
|
+
background: var(--color-surface);
|
|
38
|
+
color: var(--color-text-muted);
|
|
39
|
+
cursor: pointer; transition: all 150ms;
|
|
40
|
+
white-space: nowrap;
|
|
41
|
+
user-select: none;
|
|
42
|
+
}
|
|
43
|
+
.filterTrigger:hover {
|
|
44
|
+
background: var(--color-bg-hover);
|
|
45
|
+
color: var(--color-text);
|
|
46
|
+
border-color: var(--color-text-muted);
|
|
47
|
+
}
|
|
48
|
+
.filterTriggerActive {
|
|
49
|
+
border-color: var(--color-accent);
|
|
50
|
+
color: var(--color-text);
|
|
51
|
+
}
|
|
52
|
+
.filterTriggerExpanded {
|
|
53
|
+
border-color: var(--color-accent);
|
|
54
|
+
background: var(--color-bg-hover);
|
|
55
|
+
color: var(--color-text);
|
|
56
|
+
border-bottom-left-radius: 0;
|
|
57
|
+
border-bottom-right-radius: 0;
|
|
58
|
+
z-index: 101;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.filterTriggerLabel {
|
|
62
|
+
font-size: 10px; font-weight: 700;
|
|
63
|
+
text-transform: uppercase; letter-spacing: 0.5px;
|
|
64
|
+
color: var(--color-text-muted);
|
|
65
|
+
}
|
|
66
|
+
.filterTriggerSep {
|
|
67
|
+
color: var(--color-border);
|
|
68
|
+
margin: 0 -2px;
|
|
69
|
+
}
|
|
70
|
+
.filterTriggerValue {
|
|
71
|
+
font-weight: 700;
|
|
72
|
+
color: var(--color-text);
|
|
73
|
+
}
|
|
74
|
+
.filterTriggerActive .filterTriggerValue {
|
|
75
|
+
color: var(--color-accent);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.filterTriggerChevron {
|
|
79
|
+
flex-shrink: 0;
|
|
80
|
+
transition: transform 200ms ease;
|
|
81
|
+
opacity: 0.6;
|
|
82
|
+
}
|
|
83
|
+
.chevronOpen {
|
|
84
|
+
transform: rotate(180deg);
|
|
85
|
+
opacity: 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.filterDropdown {
|
|
89
|
+
position: absolute; top: 100%; left: 0;
|
|
90
|
+
z-index: 100;
|
|
91
|
+
padding: 6px 8px 7px;
|
|
92
|
+
background: var(--color-surface);
|
|
93
|
+
border: 1px solid var(--color-accent);
|
|
94
|
+
border-top: none;
|
|
95
|
+
border-radius: 0 0 6px 6px;
|
|
96
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
|
97
|
+
min-width: max-content;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.filterChipRow {
|
|
101
|
+
display: flex;
|
|
102
|
+
gap: 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.filterChip {
|
|
106
|
+
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
107
|
+
padding: 4px 9px;
|
|
108
|
+
border: 1px solid var(--color-border);
|
|
109
|
+
background: var(--color-bg-card);
|
|
110
|
+
color: var(--color-text-muted);
|
|
111
|
+
cursor: pointer; transition: all 120ms;
|
|
34
112
|
white-space: nowrap;
|
|
113
|
+
margin-left: -1px;
|
|
35
114
|
}
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
115
|
+
.filterChip:first-child {
|
|
116
|
+
border-radius: 4px 0 0 4px;
|
|
117
|
+
margin-left: 0;
|
|
118
|
+
}
|
|
119
|
+
.filterChip:last-child {
|
|
120
|
+
border-radius: 0 4px 4px 0;
|
|
121
|
+
}
|
|
122
|
+
.filterChip:hover {
|
|
123
|
+
background: var(--color-bg-hover);
|
|
124
|
+
color: var(--color-text);
|
|
125
|
+
z-index: 1;
|
|
126
|
+
}
|
|
127
|
+
.filterChipActive {
|
|
128
|
+
background: var(--chip-active-color, var(--color-accent)) !important;
|
|
129
|
+
color: #fff !important;
|
|
130
|
+
border-color: var(--chip-active-color, var(--color-accent)) !important;
|
|
131
|
+
z-index: 2;
|
|
132
|
+
font-weight: 700;
|
|
40
133
|
}
|
|
41
134
|
|
|
42
135
|
/* ── Select inputs (provider, visibility) ── */
|
|
@@ -121,23 +214,6 @@
|
|
|
121
214
|
}
|
|
122
215
|
.customFilterClear:hover { background: var(--color-accent); color: var(--color-bg); }
|
|
123
216
|
|
|
124
|
-
/* ── Ping interval selector ── */
|
|
125
|
-
.pingRow { display: flex; gap: 3px; }
|
|
126
|
-
.pingBtn {
|
|
127
|
-
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
128
|
-
padding: 4px 8px; border-radius: 5px;
|
|
129
|
-
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
130
|
-
color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
|
|
131
|
-
white-space: nowrap;
|
|
132
|
-
}
|
|
133
|
-
.pingBtn:hover { background: var(--color-bg-hover); color: var(--color-text); border-color: var(--color-text-muted); }
|
|
134
|
-
.pingBtnActive {
|
|
135
|
-
background: var(--color-bg-hover) !important;
|
|
136
|
-
color: var(--ping-active-color, var(--color-accent)) !important;
|
|
137
|
-
border-color: var(--ping-active-color, var(--color-accent)) !important;
|
|
138
|
-
font-weight: 700;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
217
|
/* ── Next ping countdown (TUI parity) ── */
|
|
142
218
|
.nextPing {
|
|
143
219
|
display: flex; align-items: center; gap: 6px;
|
|
@@ -157,12 +233,6 @@
|
|
|
157
233
|
text-align: right;
|
|
158
234
|
font-variant-numeric: tabular-nums;
|
|
159
235
|
}
|
|
160
|
-
.pingingDot {
|
|
161
|
-
width: 7px; height: 7px; border-radius: 50%;
|
|
162
|
-
background: #ff4466; animation: pulseDot 0.6s ease-in-out infinite;
|
|
163
|
-
flex-shrink: 0;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
236
|
@keyframes pulseDot {
|
|
167
237
|
0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); }
|
|
168
238
|
50% { box-shadow: 0 0 0 6px transparent; }
|