feat(routing): linked complexity-boundary sliders — your own low/medium/high
The tier boundaries lived on the router and were global for every customer of a hosted router; per-gateway tuning meant hand-editing score ranges. The Routing page now has one track with two draggable handles (the low|medium and medium|high boundaries) over three colored zones: handles push each other (low can't cross medium — it shoves it along, and vice versa), the numbers ride on the handles and the legend updates live, so nobody types ranges by hand. Unchecked = the router's own bands, untouched behavior. Under the hood the gateway re-maps the router's CONTINUOUS complexity score with the user's boundaries ([routing.bands] low/medium in gateway.toml, validated 0 < low < medium < 1) before tier chains, escalation and stats — verified live: with bands at 0.70/0.72 a score-0.27 request that used to be medium routed low, and back after restoring the defaults. 2 new strings in all 7 languages.
This commit is contained in:
Vendored
+89
-1
@@ -71,6 +71,7 @@ export async function renderRouting() {
|
||||
const st = {
|
||||
tiers,
|
||||
taskPairs,
|
||||
bands: routing.bands || null, // null = пороги роутера как есть
|
||||
default: routing.default || ids[0] || "",
|
||||
policy: routing.policy || { mode: "fixed_table", min_class: {} },
|
||||
};
|
||||
@@ -120,6 +121,92 @@ export async function renderRouting() {
|
||||
);
|
||||
}
|
||||
|
||||
// Linked boundary sliders: one track, two handles (low|medium boundaries),
|
||||
// three colored zones. Handles push each other, numbers update live —
|
||||
// nobody types score ranges by hand.
|
||||
function bandsCard() {
|
||||
const DEF = { low: 0.33, medium: 0.48 };
|
||||
const enabled = !!st.bands;
|
||||
const b = st.bands || { ...DEF };
|
||||
const MIN_GAP = 0.02;
|
||||
|
||||
const track = h("div", { class: "bands-track" });
|
||||
const zoneLow = h("div", { class: "bands-zone z-low" });
|
||||
const zoneMed = h("div", { class: "bands-zone z-med" });
|
||||
const zoneHigh = h("div", { class: "bands-zone z-high" });
|
||||
const hLow = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" }));
|
||||
const hMed = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" }));
|
||||
const legend = h("div", { class: "bands-legend" });
|
||||
track.append(zoneLow, zoneMed, zoneHigh, hLow, hMed);
|
||||
|
||||
function paint() {
|
||||
const lo = b.low, me = b.medium;
|
||||
zoneLow.style.width = lo * 100 + "%";
|
||||
zoneMed.style.left = lo * 100 + "%";
|
||||
zoneMed.style.width = (me - lo) * 100 + "%";
|
||||
zoneHigh.style.left = me * 100 + "%";
|
||||
zoneHigh.style.width = (1 - me) * 100 + "%";
|
||||
hLow.style.left = lo * 100 + "%";
|
||||
hMed.style.left = me * 100 + "%";
|
||||
hLow.querySelector(".bands-val").textContent = lo.toFixed(2);
|
||||
hMed.querySelector(".bands-val").textContent = me.toFixed(2);
|
||||
// подписи не наезжают, когда ручки рядом
|
||||
hMed.querySelector(".bands-val").classList.toggle("raised", me - lo < 0.09);
|
||||
mount(legend,
|
||||
h("span", { class: "badge tier-low" }, `low ≤ ${lo.toFixed(2)}`),
|
||||
h("span", { class: "badge tier-medium" }, `medium ≤ ${me.toFixed(2)}`),
|
||||
h("span", { class: "badge tier-high" }, `high > ${me.toFixed(2)}`));
|
||||
track.classList.toggle("disabled", !st.bands);
|
||||
}
|
||||
|
||||
function drag(handle, which) {
|
||||
handle.addEventListener("pointerdown", (e) => {
|
||||
if (!st.bands) return;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
const rect = track.getBoundingClientRect();
|
||||
const move = (ev) => {
|
||||
let v = (ev.clientX - rect.left) / rect.width;
|
||||
v = Math.min(0.98, Math.max(0.02, Math.round(v * 100) / 100));
|
||||
if (which === "low") {
|
||||
b.low = v;
|
||||
// связанность: low толкает medium перед собой
|
||||
if (b.medium < b.low + MIN_GAP) b.medium = Math.min(0.99, b.low + MIN_GAP);
|
||||
} else {
|
||||
b.medium = v;
|
||||
// и medium толкает low назад
|
||||
if (b.low > b.medium - MIN_GAP) b.low = Math.max(0.01, b.medium - MIN_GAP);
|
||||
}
|
||||
st.bands = { low: b.low, medium: b.medium };
|
||||
paint();
|
||||
};
|
||||
const up = () => {
|
||||
handle.removeEventListener("pointermove", move);
|
||||
handle.removeEventListener("pointerup", up);
|
||||
};
|
||||
handle.addEventListener("pointermove", move);
|
||||
handle.addEventListener("pointerup", up);
|
||||
});
|
||||
}
|
||||
drag(hLow, "low");
|
||||
drag(hMed, "medium");
|
||||
|
||||
const toggle = h("input", { type: "checkbox", checked: enabled ? "" : null });
|
||||
toggle.checked = enabled;
|
||||
toggle.addEventListener("change", () => {
|
||||
st.bands = toggle.checked ? { low: b.low, medium: b.medium } : null;
|
||||
paint();
|
||||
});
|
||||
paint();
|
||||
return h(
|
||||
"div",
|
||||
{ class: "bands-wrap" },
|
||||
h("label", { class: "check", style: "margin-bottom:8px" }, toggle, t("routing.bands.enable")),
|
||||
h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.bands.hint")),
|
||||
track,
|
||||
legend
|
||||
);
|
||||
}
|
||||
|
||||
function tierRow(tier) {
|
||||
return h(
|
||||
"div",
|
||||
@@ -257,7 +344,7 @@ export async function renderRouting() {
|
||||
}
|
||||
if (Object.keys(rule).length) tasks[k] = rule;
|
||||
}
|
||||
await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy });
|
||||
await api.putRouting({ tiers: st.tiers, tasks, bands: st.bands, default: st.default, policy: st.policy });
|
||||
toast(t("toast.saved"), "good");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
@@ -282,6 +369,7 @@ export async function renderRouting() {
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("routing.tiers")), h("span", { class: "card-head sub" }, t("routing.tierHint"))),
|
||||
bandsCard(),
|
||||
...Object.keys(st.tiers).map(tierRow)
|
||||
),
|
||||
tasksCard(),
|
||||
|
||||
Reference in New Issue
Block a user