Merge pull request #5042 from nesquena/release/stage-4979-extruntime
Release: extension sidecar runtime status in diagnostics panel (#4979)
This commit is contained in:
commit
1b3b49b33d
5 changed files with 156 additions and 7 deletions
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- **The Settings → Extensions diagnostics panel now shows per-extension sidecar runtime status.** When an extension's loopback sidecar health response includes an optional top-level `runtime` object, the diagnostics panel parses it and renders only allowlisted scalar fields (sidecar/native-host/bridge status, last-seen time, and the loopback origin) so you can see at a glance whether an extension's helper process is running, waiting, or stale — without WebUI depending on any one extension's private payload shape. All values are status-enum-allowlisted, HTML-escaped, timestamp-validated, and origin-limited to `http://127.0.0.1`/`localhost`; a missing or malformed `runtime` object degrades cleanly, and a failed diagnostics refresh now renders the error instead of staying stuck on "Loading…". Thanks @franksong2702. (#4979)
|
||||
|
||||
- **The "Response complete" notification now previews the final answer, not the start of the stream.** The browser/PWA completion notification body was built from the live streaming accumulator sliced to 100 chars, so it showed the *beginning* of the response (often a preamble or empty) rather than what the user actually got. It now derives the preview from the settled final answer (anchor projection → message content → raw text), strips inline thinking, normalizes whitespace, and truncates at 100 chars — and works for completions that finish while the session isn't the active pane. Thanks @claw-io. (#5036, fixes #5035)
|
||||
|
||||
- **Onboarding now has the same searchable model picker as Settings.** A fresh install (e.g. an OpenRouter-only first run) previously showed only a small default model list in the onboarding step — no search box and no way to enter a custom model ID — so picking a model not in the short list meant finishing setup and fixing it later. Onboarding now reuses the same searchable picker (with custom-model-ID entry) that Settings uses, instead of a separate onboarding-only dropdown that could drift. The custom-provider free-text branch is unchanged, and the plain-`<select>` fallback (when the shared picker is unavailable) correctly preserves a saved/default model instead of resetting to the first option. Thanks @rodboev. (#5031, fixes #4706)
|
||||
|
|
|
|||
|
|
@ -414,4 +414,9 @@ directly with `fetch(..., { credentials: 'omit', cache: 'no-store' })` and a
|
|||
short timeout. WebUI does **not** proxy sidecar requests and does not send WebUI
|
||||
cookies to sidecars. A successful HTTP response is shown as healthy, a non-OK
|
||||
HTTP response as unhealthy, and CORS/network/timeouts as unreachable or blocked;
|
||||
health response bodies are never rendered.
|
||||
raw health response bodies are never rendered. If a healthy response includes an
|
||||
optional top-level `runtime` object, the panel may parse it and render only
|
||||
allowlisted scalar fields such as `sidecar`, `native_host`, `bridge`,
|
||||
`last_seen_at`, and `webui_origin`. This keeps sidecar-specific diagnostics
|
||||
machine-readable without making WebUI depend on any one extension's private
|
||||
payload shape.
|
||||
|
|
|
|||
114
static/panels.js
114
static/panels.js
|
|
@ -8101,6 +8101,74 @@ function _extensionSidecarHealthBadge(status,label){
|
|||
return `<span class="extension-sidecar-status-badge extension-sidecar-status-${safeStatus}">${esc(label||safeStatus)}</span>`;
|
||||
}
|
||||
|
||||
function _extensionRuntimeStatusValue(value){
|
||||
const normalized=String(value||'').trim().toLowerCase();
|
||||
return ['running','connected','waiting','stale','unloaded','stopped','not_registered','unknown'].includes(normalized)
|
||||
? normalized
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
function _extensionRuntimeStatusLabel(value){
|
||||
const normalized=_extensionRuntimeStatusValue(value);
|
||||
if(normalized==='not_registered') return 'not registered';
|
||||
return normalized.replace(/_/g,' ');
|
||||
}
|
||||
|
||||
function _extensionRuntimeLastSeen(value){
|
||||
const text=String(value??'').trim();
|
||||
if(!/^\d+(?:\.\d+)?$/.test(text)) return '';
|
||||
const raw=Number(text);
|
||||
if(!Number.isFinite(raw)||raw<=0) return '';
|
||||
const seconds=raw>1000000000000?raw/1000:raw;
|
||||
const now=Math.floor(Date.now()/1000);
|
||||
if(seconds>now+300) return '';
|
||||
const age=Math.max(0,Math.floor(now-seconds));
|
||||
if(age<5) return 'just now';
|
||||
if(age<60) return `${age}s ago`;
|
||||
const minutes=Math.floor(age/60);
|
||||
if(minutes<60) return `${minutes}m ago`;
|
||||
const hours=Math.floor(minutes/60);
|
||||
if(hours<24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours/24)}d ago`;
|
||||
}
|
||||
|
||||
function _extensionRuntimeOrigin(value){
|
||||
const text=String(value||'').trim();
|
||||
if(!text) return '';
|
||||
try{
|
||||
const parsed=new URL(text);
|
||||
if(parsed.protocol==='http:'&&(parsed.hostname==='127.0.0.1'||parsed.hostname==='localhost')){
|
||||
return parsed.origin;
|
||||
}
|
||||
}catch(_e){}
|
||||
return '';
|
||||
}
|
||||
|
||||
function _extensionRuntimeRows(runtime){
|
||||
if(!runtime||typeof runtime!=='object') return [];
|
||||
const rows=[];
|
||||
if(Object.prototype.hasOwnProperty.call(runtime,'sidecar')){
|
||||
rows.push(['Sidecar',_extensionRuntimeStatusLabel(runtime.sidecar)]);
|
||||
}
|
||||
if(Object.prototype.hasOwnProperty.call(runtime,'native_host')){
|
||||
rows.push(['Native host',_extensionRuntimeStatusLabel(runtime.native_host)]);
|
||||
}
|
||||
if(Object.prototype.hasOwnProperty.call(runtime,'bridge')){
|
||||
rows.push(['Bridge',_extensionRuntimeStatusLabel(runtime.bridge)]);
|
||||
}
|
||||
const lastSeen=_extensionRuntimeLastSeen(runtime.last_seen_at);
|
||||
if(lastSeen) rows.push(['Last update',lastSeen]);
|
||||
const origin=_extensionRuntimeOrigin(runtime.webui_origin);
|
||||
if(origin) rows.push(['WebUI origin',origin]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function _extensionRuntimeDetails(runtime){
|
||||
const rows=_extensionRuntimeRows(runtime);
|
||||
if(!rows.length) return '';
|
||||
return rows.map(([label,value])=>`<div><span>${esc(label)}</span><code>${esc(value)}</code></div>`).join('');
|
||||
}
|
||||
|
||||
function _extensionSidecarCard(sidecars){
|
||||
const list=Array.isArray(sidecars)?sidecars:[];
|
||||
const body=list.length?`<div class="extension-sidecar-list">${list.map((sidecar,index)=>{
|
||||
|
|
@ -8122,6 +8190,7 @@ function _extensionSidecarCard(sidecars){
|
|||
<div><span>Health path</span><code>${esc(healthPath)}</code></div>
|
||||
<div><span>Health URL</span><code>${esc(healthUrl)}</code></div>
|
||||
</div>
|
||||
<div class="extension-sidecar-runtime" data-sidecar-runtime-index="${index}" hidden></div>
|
||||
</div>`;
|
||||
}).join('')}</div>`:'<div class="extension-url-empty">No loopback sidecars declared.</div>';
|
||||
return `
|
||||
|
|
@ -8143,10 +8212,24 @@ function _setExtensionSidecarHealth(index,status,label){
|
|||
if(el) el.innerHTML=_extensionSidecarHealthBadge(status,label);
|
||||
}
|
||||
|
||||
function _setExtensionSidecarRuntime(index,runtime){
|
||||
const el=document.querySelector(`[data-sidecar-runtime-index="${index}"]`);
|
||||
if(!el) return;
|
||||
const details=_extensionRuntimeDetails(runtime);
|
||||
if(!details){
|
||||
el.hidden=true;
|
||||
el.innerHTML='';
|
||||
return;
|
||||
}
|
||||
el.hidden=false;
|
||||
el.innerHTML=details;
|
||||
}
|
||||
|
||||
async function _checkExtensionSidecarHealth(sidecar,index,seq){
|
||||
const healthUrl=sidecar&&sidecar.health_url;
|
||||
if(!healthUrl){
|
||||
_setExtensionSidecarHealth(index,'blocked','unreachable / blocked');
|
||||
_setExtensionSidecarRuntime(index,null);
|
||||
return;
|
||||
}
|
||||
let controller=null;
|
||||
|
|
@ -8158,11 +8241,22 @@ async function _checkExtensionSidecarHealth(sidecar,index,seq){
|
|||
}
|
||||
const res=await fetch(healthUrl,{credentials:'omit',cache:'no-store',signal:controller?controller.signal:undefined});
|
||||
if(seq!==_extensionsSidecarMonitorSeq) return;
|
||||
if(res.ok) _setExtensionSidecarHealth(index,'healthy','healthy');
|
||||
else _setExtensionSidecarHealth(index,'unhealthy','unhealthy');
|
||||
if(res.ok){
|
||||
_setExtensionSidecarHealth(index,'healthy','healthy');
|
||||
let body=null;
|
||||
try{
|
||||
body=await res.json();
|
||||
}catch(_e){}
|
||||
if(seq!==_extensionsSidecarMonitorSeq) return;
|
||||
_setExtensionSidecarRuntime(index,body&&typeof body==='object'?body.runtime:null);
|
||||
}else{
|
||||
_setExtensionSidecarHealth(index,'unhealthy','unhealthy');
|
||||
_setExtensionSidecarRuntime(index,null);
|
||||
}
|
||||
}catch(_e){
|
||||
if(seq!==_extensionsSidecarMonitorSeq) return;
|
||||
_setExtensionSidecarHealth(index,'blocked','unreachable / blocked');
|
||||
_setExtensionSidecarRuntime(index,null);
|
||||
}finally{
|
||||
if(timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
|
|
@ -8287,18 +8381,27 @@ async function handleExtensionToggle(btn){
|
|||
}
|
||||
}
|
||||
|
||||
async function loadExtensionsPanel(){
|
||||
async function loadExtensionsPanel(opts){
|
||||
const target=$('extensionsDiagnostics');
|
||||
const copyBtn=$('extensionsCopyDiagnosticsBtn');
|
||||
if(!target) return;
|
||||
if(copyBtn) copyBtn.disabled=true;
|
||||
// Only preserve REAL rendered diagnostics across a refresh — never the
|
||||
// "Loading…" / error placeholders, or a failed refresh would leave the panel
|
||||
// stuck on "Loading extension diagnostics…" instead of rendering the error.
|
||||
const preserveExisting=!!(
|
||||
opts&&opts.preserveExisting&&target.innerHTML.trim()
|
||||
&&!target.querySelector('.extensions-loading,.extensions-error')
|
||||
);
|
||||
if(copyBtn&&!preserveExisting) copyBtn.disabled=true;
|
||||
const seq=++_extensionsSidecarMonitorSeq;
|
||||
target.innerHTML='<div class="extensions-loading">Loading extension diagnostics…</div>';
|
||||
if(!preserveExisting) target.innerHTML='<div class="extensions-loading">Loading extension diagnostics…</div>';
|
||||
try{
|
||||
const data=await api('/api/extensions/status');
|
||||
if(seq!==_extensionsSidecarMonitorSeq) return;
|
||||
_renderExtensionsPanel(data,seq);
|
||||
}catch(e){
|
||||
if(seq!==_extensionsSidecarMonitorSeq) return;
|
||||
if(preserveExisting&&target.innerHTML.trim()) return;
|
||||
_extensionsStatusData=null;
|
||||
if(copyBtn) copyBtn.disabled=true;
|
||||
target.innerHTML='<div class="extensions-error">Failed to load extension diagnostics: '+esc(e.message||String(e))+'</div>';
|
||||
|
|
@ -8314,6 +8417,7 @@ function switchExtensionsTab(tab){
|
|||
document.querySelectorAll('[data-extensions-pane]').forEach(pane=>{
|
||||
pane.hidden=pane.dataset.extensionsPane!==tab;
|
||||
});
|
||||
if(tab==='diagnostics') loadExtensionsPanel({preserveExisting:true});
|
||||
if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4736,6 +4736,11 @@ main.main > #mainPlugin{display:none;}
|
|||
.extension-sidecar-fields>div{display:flex;align-items:flex-start;gap:8px;min-width:0;font-size:12px;color:var(--muted);}
|
||||
.extension-sidecar-fields span{flex:0 0 82px;}
|
||||
.extension-sidecar-fields code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;}
|
||||
.extension-sidecar-runtime{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:6px;margin-top:9px;padding-top:9px;border-top:1px solid var(--border);}
|
||||
.extension-sidecar-runtime[hidden]{display:none!important;}
|
||||
.extension-sidecar-runtime>div{display:flex;flex-direction:column;gap:2px;min-width:0;border:1px solid var(--border);border-radius:7px;background:var(--card-bg,var(--sidebar-bg));padding:6px 8px;}
|
||||
.extension-sidecar-runtime span{font-size:10.5px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:0;}
|
||||
.extension-sidecar-runtime code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;}
|
||||
.extension-sidecar-status-badge{display:inline-flex;align-items:center;white-space:nowrap;border-radius:999px;padding:2px 7px;font-size:10.5px;font-weight:700;font-family:var(--font-mono);line-height:1.45;background:var(--code-bg);color:var(--muted);}
|
||||
.extension-sidecar-status-healthy{background:rgba(34,197,94,.14);color:#22c55e;}
|
||||
.extension-sidecar-status-unhealthy{background:rgba(245,158,11,.14);color:#f59e0b;}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,13 @@ def test_extensions_panel_fetches_status_endpoint_without_mutating_settings():
|
|||
assert "extensions-error" in load_block
|
||||
|
||||
|
||||
def test_extensions_diagnostics_tab_refreshes_runtime_status():
|
||||
tab_block = _function_block("switchExtensionsTab", extra=900)
|
||||
|
||||
assert "if(tab==='diagnostics') loadExtensionsPanel({preserveExisting:true});" in tab_block
|
||||
assert "if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();" in tab_block
|
||||
|
||||
|
||||
def test_extensions_panel_renders_sanitized_status_payload():
|
||||
render_block = _between("function _renderExtensionsPanel", "async function loadExtensionsPanel")
|
||||
warning_block = _function_block("_extensionWarningList", extra=900)
|
||||
|
|
@ -128,10 +135,13 @@ def test_extensions_panel_renders_sanitized_status_payload():
|
|||
|
||||
|
||||
def test_extensions_panel_renders_loopback_sidecar_monitor_safely():
|
||||
runtime_block = _between("function _extensionRuntimeStatusValue", "function _extensionSidecarCard")
|
||||
sidecar_block = _between("function _extensionSidecarCard", "function _setExtensionSidecarHealth")
|
||||
runtime_setter_block = _between("function _setExtensionSidecarRuntime", "async function _checkExtensionSidecarHealth")
|
||||
monitor_block = _between("async function _checkExtensionSidecarHealth", "function _renderExtensionsPanel")
|
||||
render_block = _between("function _renderExtensionsPanel", "async function loadExtensionsPanel")
|
||||
load_block = _between("async function loadExtensionsPanel", "async function copyExtensionsDiagnostics")
|
||||
load_catch_block = load_block[load_block.index("}catch(e){"):]
|
||||
|
||||
assert "Loopback sidecars" in sidecar_block
|
||||
assert "No loopback sidecars declared." in sidecar_block
|
||||
|
|
@ -140,17 +150,37 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely():
|
|||
assert "esc(origin)" in sidecar_block
|
||||
assert "esc(healthPath)" in sidecar_block
|
||||
assert "esc(healthUrl)" in sidecar_block
|
||||
assert 'data-sidecar-runtime-index="${index}"' in sidecar_block
|
||||
assert "fetch(healthUrl,{credentials:'omit',cache:'no-store'" in monitor_block
|
||||
assert "function _monitorExtensionSidecars(sidecars,seq)" in monitor_block
|
||||
assert "const seq=_extensionsSidecarMonitorSeq" not in monitor_block
|
||||
assert "_monitorExtensionSidecars(sidecars,seq)" in render_block
|
||||
assert "function _renderExtensionsPanel(data,seq)" in render_block
|
||||
assert "const seq=++_extensionsSidecarMonitorSeq" in load_block
|
||||
assert "opts&&opts.preserveExisting&&target.innerHTML.trim()" in load_block
|
||||
# A failed refresh must NOT be preserved as "existing content": the Loading/
|
||||
# error placeholders are excluded so a fetch error always renders the error
|
||||
# instead of leaving the panel stuck on "Loading extension diagnostics…".
|
||||
assert "!target.querySelector('.extensions-loading,.extensions-error')" in load_block
|
||||
assert "if(!preserveExisting) target.innerHTML" in load_block
|
||||
assert "loadExtensionsPanel({preserveExisting:true})" in load_block
|
||||
assert "if(seq!==_extensionsSidecarMonitorSeq) return;" in load_block
|
||||
assert "if(seq!==_extensionsSidecarMonitorSeq) return;" in load_catch_block
|
||||
assert "if(preserveExisting&&target.innerHTML.trim()) return;" in load_catch_block
|
||||
assert "_renderExtensionsPanel(data,seq)" in load_block
|
||||
assert "res.ok" in monitor_block
|
||||
assert "res.text" not in monitor_block
|
||||
assert "res.json" not in monitor_block
|
||||
assert "body=await res.json()" in monitor_block
|
||||
assert "_setExtensionSidecarRuntime(index,body&&typeof body==='object'?body.runtime:null)" in monitor_block
|
||||
assert "String(value??'').trim()" in runtime_block
|
||||
assert r"/^\d+(?:\.\d+)?$/.test(text)" in runtime_block
|
||||
assert "seconds>now+300" in runtime_block
|
||||
assert "runtime.sidecar" in runtime_block
|
||||
assert "runtime.native_host" in runtime_block
|
||||
assert "runtime.bridge" in runtime_block
|
||||
assert "runtime.last_seen_at" in runtime_block
|
||||
assert "runtime.webui_origin" in runtime_block
|
||||
assert "el.innerHTML=details" in runtime_setter_block
|
||||
assert "api('/api/settings'" not in monitor_block
|
||||
assert "api('/extensions/" not in monitor_block
|
||||
assert "sidecar/*" not in monitor_block
|
||||
|
|
@ -196,6 +226,7 @@ def test_extensions_styles_are_scoped_to_extensions_panel():
|
|||
assert ".extension-installed-list" in STYLE_CSS
|
||||
assert ".extension-toggle-btn" in STYLE_CSS
|
||||
assert ".extension-sidecar-list" in STYLE_CSS
|
||||
assert ".extension-sidecar-runtime" in STYLE_CSS
|
||||
assert ".extension-sidecar-status-badge" in STYLE_CSS
|
||||
|
||||
|
||||
|
|
@ -222,6 +253,8 @@ def test_extensions_docs_mentions_settings_panel_without_install_or_proxy_claims
|
|||
assert "sanitized loopback sidecars" in diagnostics_section
|
||||
assert "credentials: 'omit'" in diagnostics_section
|
||||
assert "does **not** proxy sidecar requests" in diagnostics_section
|
||||
assert "optional top-level `runtime` object" in diagnostics_section
|
||||
assert "allowlisted scalar fields" in diagnostics_section
|
||||
assert "do **not**" in diagnostics_section
|
||||
assert "return `HERMES_WEBUI_EXTENSION_DIR`" in diagnostics_section
|
||||
assert "override state-file path" in diagnostics_section
|
||||
|
|
|
|||
Loading…
Reference in a new issue