feat: visualization of conversation affinity in dashboard
This commit is contained in:
parent
4acbaeb29c
commit
aa7ec6354a
5 changed files with 306 additions and 19 deletions
|
|
@ -121,6 +121,45 @@
|
|||
.ps-subrow + .ps-subrow {
|
||||
margin-top: 2px;
|
||||
}
|
||||
#ps-table .affinity-col,
|
||||
#ps-table .affinity-cell {
|
||||
display: none;
|
||||
}
|
||||
#ps-table.affinity-on .affinity-col,
|
||||
#ps-table.affinity-on .affinity-cell {
|
||||
display: table-cell;
|
||||
width: 90px;
|
||||
text-align: center;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
#ps-table.affinity-on .affinity-dots {
|
||||
max-width: 78px;
|
||||
}
|
||||
.affinity-dots {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.affinity-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #2e7d32;
|
||||
display: inline-block;
|
||||
transition: opacity 1s linear;
|
||||
}
|
||||
.affinity-overflow {
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.affinity-empty {
|
||||
color: #bbb;
|
||||
font-size: 11px;
|
||||
}
|
||||
#ps-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
|
|
@ -131,13 +170,13 @@
|
|||
max-width: 300px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Optimize narrow columns */
|
||||
#ps-table th:nth-child(3),
|
||||
#ps-table td:nth-child(3),
|
||||
/* Optimize narrow columns (Params / Quant / Ctx) */
|
||||
#ps-table th:nth-child(4),
|
||||
#ps-table td:nth-child(4),
|
||||
#ps-table th:nth-child(5),
|
||||
#ps-table td:nth-child(5) {
|
||||
#ps-table td:nth-child(5),
|
||||
#ps-table th:nth-child(6),
|
||||
#ps-table td:nth-child(6) {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -395,6 +434,7 @@
|
|||
<tr>
|
||||
<th class="model-col">Model</th>
|
||||
<th>Endpoint</th>
|
||||
<th class="affinity-col" title="Live conversation-affinity pins (KV-cache warm). One dot per pinned conversation; opacity fades toward TTL expiry.">Affinity</th>
|
||||
<th>Params</th>
|
||||
<th>Quant</th>
|
||||
<th>Ctx</th>
|
||||
|
|
@ -406,7 +446,7 @@
|
|||
</thead>
|
||||
<tbody id="ps-body">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">Loading…</td>
|
||||
<td colspan="10" class="loading">Loading…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -932,6 +972,14 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
|
|||
return items.map((item) => `<div class="ps-subrow">${item || ""}</div>`).join("");
|
||||
};
|
||||
|
||||
const escapeAttr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
const renderAffinitySlots = (endpoints, modelName) => {
|
||||
if (!endpoints.length) return "";
|
||||
return endpoints
|
||||
.map((ep) => `<div class="ps-subrow"><span class="affinity-dots" data-endpoint="${escapeAttr(ep)}" data-model="${escapeAttr(modelName)}"></span></div>`)
|
||||
.join("");
|
||||
};
|
||||
|
||||
body.innerHTML = Array.from(grouped.entries())
|
||||
.map(([modelName, modelInstances]) => {
|
||||
const existingRow = psRows.get(modelName);
|
||||
|
|
@ -955,6 +1003,7 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
|
|||
return `<tr data-model="${modelName}" data-endpoints="${endpointsData}">
|
||||
<td class="model"><span style="color:${getColor(modelName)}">${modelName}</span> <a href="#" class="stats-link" data-model="${modelName}">stats</a></td>
|
||||
<td>${renderInstanceList(endpoints)}</td>
|
||||
<td class="affinity-cell">${renderAffinitySlots(endpoints, modelName)}</td>
|
||||
<td>${params}</td>
|
||||
<td>${quant}</td>
|
||||
<td>${ctx}</td>
|
||||
|
|
@ -972,11 +1021,83 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
|
|||
const model = row.dataset.model;
|
||||
if (model) psRows.set(model, row);
|
||||
});
|
||||
renderAffinityDots();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Conversation-affinity dots ---------- */
|
||||
const AFFINITY_MAX_DOTS = 12;
|
||||
let affinityIndex = new Map(); // `${endpoint}|${model}` -> array of {expiresAt}
|
||||
let affinityTtl = 300;
|
||||
let affinityEnabled = false;
|
||||
|
||||
async function loadAffinity() {
|
||||
try {
|
||||
const data = await fetchJSON("/api/affinity_stats");
|
||||
affinityEnabled = !!data.enabled;
|
||||
affinityTtl = Number(data.ttl) || 300;
|
||||
const now = Date.now() / 1000;
|
||||
const idx = new Map();
|
||||
for (const e of data.entries || []) {
|
||||
const key = `${e.endpoint}|${e.model}`;
|
||||
if (!idx.has(key)) idx.set(key, []);
|
||||
idx.get(key).push({ expiresAt: now + Number(e.remaining) });
|
||||
}
|
||||
affinityIndex = idx;
|
||||
applyAffinityColumnVisibility();
|
||||
renderAffinityDots();
|
||||
} catch (err) {
|
||||
// Endpoint may 404 on older deployments — silently degrade.
|
||||
affinityEnabled = false;
|
||||
affinityIndex = new Map();
|
||||
applyAffinityColumnVisibility();
|
||||
renderAffinityDots();
|
||||
}
|
||||
}
|
||||
|
||||
function applyAffinityColumnVisibility() {
|
||||
const table = document.getElementById("ps-table");
|
||||
if (!table) return;
|
||||
table.classList.toggle("affinity-on", affinityEnabled);
|
||||
}
|
||||
|
||||
function renderAffinityDots() {
|
||||
const spans = document.querySelectorAll(".affinity-dots");
|
||||
if (!spans.length) return;
|
||||
const now = Date.now() / 1000;
|
||||
spans.forEach((span) => {
|
||||
const ep = span.dataset.endpoint;
|
||||
const mdl = span.dataset.model;
|
||||
const key = `${ep}|${mdl}`;
|
||||
const pins = (affinityIndex.get(key) || []).filter((p) => p.expiresAt > now);
|
||||
if (pins.length !== (affinityIndex.get(key) || []).length) {
|
||||
if (pins.length) affinityIndex.set(key, pins);
|
||||
else affinityIndex.delete(key);
|
||||
}
|
||||
if (!pins.length) {
|
||||
span.innerHTML = affinityEnabled
|
||||
? `<span class="affinity-empty">—</span>`
|
||||
: "";
|
||||
return;
|
||||
}
|
||||
// Sort freshest first so visible dots are the most "recent".
|
||||
pins.sort((a, b) => b.expiresAt - a.expiresAt);
|
||||
const visible = pins.slice(0, AFFINITY_MAX_DOTS);
|
||||
const overflow = pins.length - visible.length;
|
||||
const dotsHtml = visible
|
||||
.map((p) => {
|
||||
const remaining = Math.max(0, p.expiresAt - now);
|
||||
const opacity = Math.max(0.15, Math.min(1, remaining / affinityTtl));
|
||||
const secs = Math.round(remaining);
|
||||
return `<span class="affinity-dot" style="opacity:${opacity.toFixed(2)}" title="pin expires in ${secs}s"></span>`;
|
||||
})
|
||||
.join("");
|
||||
span.innerHTML = dotsHtml + (overflow > 0 ? `<span class="affinity-overflow">+${overflow}</span>` : "");
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Usage Chart (stacked‑percentage) ---------- */
|
||||
function getColor(seed) {
|
||||
const h = Math.abs(hashString(seed) % 360);
|
||||
|
|
@ -1173,10 +1294,13 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
|
|||
loadEndpoints();
|
||||
loadTags();
|
||||
loadPS();
|
||||
loadAffinity();
|
||||
loadUsage();
|
||||
initHeaderChart();
|
||||
setInterval(tickTpsChart, 1000);
|
||||
setInterval(loadPS, 60_000);
|
||||
setInterval(loadAffinity, 15_000);
|
||||
setInterval(renderAffinityDots, 2_000);
|
||||
setInterval(loadEndpoints, 300_000);
|
||||
|
||||
/* show logic */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue