v3 M1c (host): provider selector — control panel + live-switch plumbing
Panel: new Providers tab (data-driven from GET /api/providers — labels/accents from the daemon registry, enable/active/creds from config). z.ai base_url + key, api_key masked and preserved on save like the HA token. Daemon: request_provider_switch() lets the panel push an active-provider change into the live BLE session (same _set_active_provider path as the watch) so it takes effect now, not on the next 60s poll; _active_session exposes the session to the panel thread. On-watch cycle groundwork: provnext -> _cycle_provider (daemon owns the enabled set/order), and pnm/pi/pc pushed in the payload so the watch's Provider screen can show the name + "1/3". server.py: ConfigIn gains providers/active_provider/display_order; secrets masked in _masked; active_provider change notifies the injected _command_sink. tray wires set_command_sink(request_provider_switch). Tests: test_server_providers (masking + sink), test_provider_switch (cycle + offline persist). 106 passed, 2 Linux-only failures (baseline). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,10 @@
|
||||
.stat .k{font-size:12px;color:var(--dim)}
|
||||
.stat .v{font-size:22px;font-weight:500;margin-top:4px}
|
||||
.muted{color:var(--dim);font-size:12.5px}
|
||||
.swatch{width:13px;height:13px;border-radius:50%;flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.12)}
|
||||
label.inline{display:inline-flex;align-items:center;gap:7px;margin:0;color:var(--text);font-size:13px;cursor:pointer}
|
||||
label.inline input{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
|
||||
label.inline.off{color:var(--dim);cursor:default}
|
||||
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:10px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s}
|
||||
#toast.show{opacity:1}
|
||||
</style>
|
||||
@@ -60,6 +64,7 @@
|
||||
<nav class="nav">
|
||||
<div class="brand"><span class="dot"></span>Clawdmeter</div>
|
||||
<button class="tabbtn active" data-tab="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>Status</button>
|
||||
<button class="tabbtn" data-tab="providers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>Providers</button>
|
||||
<button class="tabbtn" data-tab="ha"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 1 4 10 4 4 0 0 0-1 3H9a4 4 0 0 0-1-3 6 6 0 0 1 4-10z"/></svg>Home Assistant</button>
|
||||
<button class="tabbtn" data-tab="buttons"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Buttons</button>
|
||||
<button class="tabbtn" data-tab="settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="var(--bg)"/><circle cx="15" cy="12" r="2" fill="var(--bg)"/><circle cx="8" cy="18" r="2" fill="var(--bg)"/></svg>Settings</button>
|
||||
@@ -79,6 +84,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PROVIDERS -->
|
||||
<section class="tab" data-tab="providers">
|
||||
<h1>Providers</h1>
|
||||
<div class="sub">Which usage source the watch shows — and its brand colour</div>
|
||||
<div id="prov-list"></div>
|
||||
<div class="actions"><button class="primary" id="prov-save">Save</button></div>
|
||||
</section>
|
||||
|
||||
<!-- HOME ASSISTANT -->
|
||||
<section class="tab" data-tab="ha">
|
||||
<h1>Home Assistant</h1>
|
||||
@@ -146,6 +159,7 @@ document.querySelectorAll(".tabbtn").forEach(b=>b.onclick=()=>{
|
||||
document.querySelectorAll(".tabbtn").forEach(x=>x.classList.toggle("active",x===b));
|
||||
const id=b.dataset.tab;
|
||||
document.querySelectorAll(".tab").forEach(s=>s.classList.toggle("active",s.dataset.tab===id));
|
||||
if(id==="providers") loadProviders().catch(e=>toast("Load failed: "+e.message));
|
||||
});
|
||||
|
||||
// ---- load ----
|
||||
@@ -251,6 +265,64 @@ $("#set-save").onclick = async ()=>{
|
||||
}catch(e){ toast("Error: "+e.message); }
|
||||
};
|
||||
|
||||
// ---- Providers ----
|
||||
let providers = []; // [{id,label,accent,enabled,needs_key,base_url,has_key,...}]
|
||||
let activeProv = "anthropic";
|
||||
async function loadProviders(){
|
||||
const r = await api("/api/providers");
|
||||
providers = r.providers; activeProv = r.active;
|
||||
// Seed the editable secret from has_key: a stored key shows (and re-saves) as the
|
||||
// mask, so leaving it untouched preserves it; typing over it sends the new key.
|
||||
providers.forEach(p=>{ if(p.needs_key) p.api_key = p.has_key ? MASK : ""; });
|
||||
renderProviders();
|
||||
}
|
||||
function esc(s){return (s||"").replace(/"/g,""");}
|
||||
function renderProviders(){
|
||||
const list=$("#prov-list"); list.innerHTML="";
|
||||
providers.forEach(p=>{
|
||||
const card=document.createElement("div"); card.className="card";
|
||||
const keyBlock = p.needs_key ? `
|
||||
<div data-keys="${p.id}" style="${p.enabled?"":"display:none"};margin-top:12px">
|
||||
<label>Base URL</label>
|
||||
<input type="text" data-pf="base_url" data-pid="${p.id}" value="${esc(p.base_url)}" placeholder="https://api.z.ai/api/anthropic" autocomplete="off">
|
||||
<label>API key</label>
|
||||
<input type="password" data-pf="api_key" data-pid="${p.id}" value="${esc(p.api_key)}" placeholder="Paste key" autocomplete="off">
|
||||
</div>` : "";
|
||||
const actCls = p.enabled?"inline":"inline off";
|
||||
card.innerHTML=`
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div class="row" style="gap:10px"><span class="swatch" style="background:#${p.accent}"></span><b style="font-weight:500">${p.label}</b></div>
|
||||
<div class="row" style="gap:18px">
|
||||
<label class="inline"><input type="checkbox" data-pen="${p.id}" ${p.enabled?"checked":""}> Enabled</label>
|
||||
<label class="${actCls}"><input type="radio" name="activeprov" data-pact="${p.id}" ${activeProv===p.id?"checked":""} ${p.enabled?"":"disabled"}> Show on watch</label>
|
||||
</div>
|
||||
</div>${keyBlock}`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
list.querySelectorAll("[data-pen]").forEach(el=>el.onchange=()=>{
|
||||
const p=providers.find(x=>x.id===el.dataset.pen); p.enabled=el.checked;
|
||||
if(!el.checked && activeProv===p.id){ // disabling the shown one → hand off
|
||||
const alt=providers.find(x=>x.enabled); activeProv = alt?alt.id:"anthropic";
|
||||
}
|
||||
if(el.checked && !providers.some(x=>x.enabled&&x.id===activeProv)) activeProv=p.id;
|
||||
renderProviders();
|
||||
});
|
||||
list.querySelectorAll("[data-pact]").forEach(el=>el.onchange=()=>{activeProv=el.dataset.pact;});
|
||||
list.querySelectorAll("[data-pf]").forEach(el=>el.oninput=()=>{
|
||||
providers.find(x=>x.id===el.dataset.pid)[el.dataset.pf]=el.value;
|
||||
});
|
||||
}
|
||||
$("#prov-save").onclick = async ()=>{
|
||||
const payload={providers:{}, active_provider:activeProv};
|
||||
providers.forEach(p=>{
|
||||
const pc={enabled:!!p.enabled};
|
||||
if(p.needs_key){ pc.base_url=(p.base_url||"").trim(); pc.api_key=p.api_key||""; }
|
||||
payload.providers[p.id]=pc;
|
||||
});
|
||||
try{ await api("/api/config","PUT",payload); toast("Providers saved"); await loadProviders(); }
|
||||
catch(e){ toast("Error: "+e.message); }
|
||||
};
|
||||
|
||||
// ---- Status poll ----
|
||||
async function pollStatus(){
|
||||
try{
|
||||
|
||||
Reference in New Issue
Block a user