fix: keep controller topology inventory lightweight

This commit is contained in:
2026-09-11 20:37:02 +03:00
parent 124429c1fb
commit 0ba42afe35
4 changed files with 7 additions and 7 deletions
@@ -1 +1 @@
4fbbc0b0ffc135353bbfb16cad45d7bcb8a0340466b04869d080720b98f2dbba index.html de6f1c6e07d74bc58555f41a22e9a0e8a3942577ffce5773f9450707cb7ca440 index.html
@@ -2,4 +2,4 @@
This artifact is served by the SHD MCP backend as `ui://shd/controller-observability/v1.html`. This artifact is served by the SHD MCP backend as `ui://shd/controller-observability/v1.html`.
It is a standalone parameterized MCP App. The same UI contract renders controller history, series comparison, expert settings, topology, telemetry health and source audit modes. It is a standalone parameterized MCP App. The same UI contract renders controller history, series comparison, expert settings, lightweight topology, telemetry health and source audit modes; source inventory is loaded separately through the bounded paginated inventory request.
@@ -19,7 +19,7 @@
</section></main> </section></main>
<script> <script>
(function(){'use strict'; (function(){'use strict';
var T={ru:{history:['История контроллера','Временные точки и происхождение данных.'],compare:['Сравнение рядов','Универсальное сравнение временных рядов на общей шкале.'],settings:['Экспертные настройки','Поля доступны для записи только при явном разрешении источника.'],topology:['Топология контроллеров','Все контроллеры проекта, source inventory и устройства.'],health:['Здоровье telemetry','Состояние нативного источника и Influx/Grafana.'],audit:['Аудит операций','Команды, подтверждения и readback по source-контроллеру.'],refresh:'Обновить',loading:'Загрузка…',empty:'Нет данных для отображения.',records:'записей',native:'Нативный источник',influx:'Influx/Grafana',available:'доступен',unavailable:'недоступен',configured:'настроен',points:'точек',source:'Источник',time:'Время',value:'Значение',name:'Название',controller:'Контроллер',device:'Устройство',room:'Комната',type:'Тип',status:'Статус',error:'Ошибка',readonly:'Только чтение',save:'Сохранить',saved:'Настройки сохранены.',noWritable:'Нет разрешённых writable-параметров.',required:'Обязательное поле',unknown:'Неизвестный формат ответа.',fail:'Не удалось загрузить данные.',id:'ID',action:'Действие',requested:'Запрошено',actual:'Фактически',revision:'Ревизия',checked:'Проверено'},en:{history:['Controller history','Time-series points and data provenance.'],compare:['Series comparison','Universal time-series comparison on one shared scale.'],settings:['Expert settings','Fields are writable only when the source explicitly allows it.'],topology:['Controller topology','All project controllers, source inventory and devices.'],health:['Telemetry health','Native-source and Influx/Grafana status.'],audit:['Operation audit','Commands, confirmations and readback for the source controller.'],refresh:'Refresh',loading:'Loading…',empty:'No data to display.',records:'records',native:'Native source',influx:'Influx/Grafana',available:'available',unavailable:'unavailable',configured:'configured',points:'points',source:'Source',time:'Time',value:'Value',name:'Name',controller:'Controller',device:'Device',room:'Room',type:'Type',status:'Status',error:'Error',readonly:'Read-only',save:'Save',saved:'Settings saved.',noWritable:'No writable parameters are allowed.',required:'Required field',unknown:'Unknown response format.',fail:'Could not load data.',id:'ID',action:'Action',requested:'Requested',actual:'Actual',revision:'Revision',checked:'Checked'}}; var T={ru:{history:['История контроллера','Временные точки и происхождение данных.'],compare:['Сравнение рядов','Универсальное сравнение временных рядов на общей шкале.'],settings:['Экспертные настройки','Поля доступны для записи только при явном разрешении источника.'],topology:['Топология контроллеров','Все контроллеры проекта; inventory устройств загружается отдельно.'],health:['Здоровье telemetry','Состояние нативного источника и Influx/Grafana.'],audit:['Аудит операций','Команды, подтверждения и readback по source-контроллеру.'],inventorySeparate:'Инвентарь устройств загружается отдельным запросом по выбранному контроллеру.',refresh:'Обновить',loading:'Загрузка…',empty:'Нет данных для отображения.',records:'записей',native:'Нативный источник',influx:'Influx/Grafana',available:'доступен',unavailable:'недоступен',configured:'настроен',points:'точек',source:'Источник',time:'Время',value:'Значение',name:'Название',controller:'Контроллер',device:'Устройство',room:'Комната',type:'Тип',status:'Статус',error:'Ошибка',readonly:'Только чтение',save:'Сохранить',saved:'Настройки сохранены.',noWritable:'Нет разрешённых writable-параметров.',required:'Обязательное поле',unknown:'Неизвестный формат ответа.',fail:'Не удалось загрузить данные.',id:'ID',action:'Действие',requested:'Запрошено',actual:'Фактически',revision:'Ревизия',checked:'Проверено'},en:{history:['Controller history','Time-series points and data provenance.'],compare:['Series comparison','Universal time-series comparison on one shared scale.'],settings:['Expert settings','Fields are writable only when the source explicitly allows it.'],topology:['Controller topology','All project controllers; device inventory is loaded separately.'],health:['Telemetry health','Native-source and Influx/Grafana status.'],audit:['Operation audit','Commands, confirmations and readback for the source controller.'],inventorySeparate:'Device inventory is loaded by a separate request for the selected controller.',refresh:'Refresh',loading:'Loading…',empty:'No data to display.',records:'records',native:'Native source',influx:'Influx/Grafana',available:'available',unavailable:'unavailable',configured:'configured',points:'points',source:'Source',time:'Time',value:'Value',name:'Name',controller:'Controller',device:'Device',room:'Room',type:'Type',status:'Status',error:'Error',readonly:'Read-only',save:'Save',saved:'Settings saved.',noWritable:'No writable parameters are allowed.',required:'Required field',unknown:'Unknown response format.',fail:'Could not load data.',id:'ID',action:'Action',requested:'Requested',actual:'Actual',revision:'Revision',checked:'Checked'}};
var state={mode:'history',config:{},data:null,busy:false,lang:(String((window.openai&&window.openai.locale)||document.documentElement.lang||'ru').toLowerCase().indexOf('en')===0?'en':'ru')};var pending=new Map(),nextId=1; var state={mode:'history',config:{},data:null,busy:false,lang:(String((window.openai&&window.openai.locale)||document.documentElement.lang||'ru').toLowerCase().indexOf('en')===0?'en':'ru')};var pending=new Map(),nextId=1;
var el={title:document.getElementById('title'),lede:document.getElementById('lede'),refresh:document.getElementById('refresh'),source:document.getElementById('source'),preset:document.getElementById('preset'),status:document.getElementById('status'),summary:document.getElementById('summary'),content:document.getElementById('content')}; var el={title:document.getElementById('title'),lede:document.getElementById('lede'),refresh:document.getElementById('refresh'),source:document.getElementById('source'),preset:document.getElementById('preset'),status:document.getElementById('status'),summary:document.getElementById('summary'),content:document.getElementById('content')};
function t(k){return(T[state.lang]&&T[state.lang][k])||k}function obj(x){return x&&typeof x==='object'&&!Array.isArray(x)}function text(x){if(x===null||x===undefined||x==='')return'—';if(typeof x==='object')try{return JSON.stringify(x)}catch(e){return'—'}return String(x)}function esc(x){return text(x).replace(/[&<>"']/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]})}function unwrap(x){var p=x&&x.structuredContent?x.structuredContent:(x&&x.toolOutput?x.toolOutput:x);if(p&&p.isError)throw Error(t('fail'));return p||{}} function t(k){return(T[state.lang]&&T[state.lang][k])||k}function obj(x){return x&&typeof x==='object'&&!Array.isArray(x)}function text(x){if(x===null||x===undefined||x==='')return'—';if(typeof x==='object')try{return JSON.stringify(x)}catch(e){return'—'}return String(x)}function esc(x){return text(x).replace(/[&<>"']/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]})}function unwrap(x){var p=x&&x.structuredContent?x.structuredContent:(x&&x.toolOutput?x.toolOutput:x);if(p&&p.isError)throw Error(t('fail'));return p||{}}
@@ -37,7 +37,7 @@
function inputFor(s){var wrap=document.createElement('div');wrap.className='setting';var label=document.createElement('label');label.textContent=text(s.name||s.key);if(s.description){var small=document.createElement('small');small.textContent=s.description;label.append(small)}var value=s.current_value;var input;if(Array.isArray(s.enum_values)&&s.enum_values.length){input=document.createElement('select');s.enum_values.forEach(function(v){var o=document.createElement('option');o.value=String(v);o.textContent=String(v);input.append(o)});input.value=value===null||value===undefined?'':String(value)}else if(String(s.value_type||'').toLowerCase()==='boolean'){input=document.createElement('input');input.type='checkbox';input.checked=value===true||value===1||value==='1'}else{input=document.createElement('input');input.type=['number','integer'].includes(String(s.value_type||'').toLowerCase())?'number':'text';if(s.min!==null&&s.min!==undefined)input.min=s.min;if(s.max!==null&&s.max!==undefined)input.max=s.max;if(s.step!==null&&s.step!==undefined)input.step=s.step;input.value=value===null||value===undefined?'':String(value)}input.dataset.key=s.key;input.disabled=!s.writable;wrap.append(label,input);if(!s.writable){var ro=document.createElement('div');ro.className='muted';ro.textContent=t('readonly');wrap.append(ro)}return wrap} function inputFor(s){var wrap=document.createElement('div');wrap.className='setting';var label=document.createElement('label');label.textContent=text(s.name||s.key);if(s.description){var small=document.createElement('small');small.textContent=s.description;label.append(small)}var value=s.current_value;var input;if(Array.isArray(s.enum_values)&&s.enum_values.length){input=document.createElement('select');s.enum_values.forEach(function(v){var o=document.createElement('option');o.value=String(v);o.textContent=String(v);input.append(o)});input.value=value===null||value===undefined?'':String(value)}else if(String(s.value_type||'').toLowerCase()==='boolean'){input=document.createElement('input');input.type='checkbox';input.checked=value===true||value===1||value==='1'}else{input=document.createElement('input');input.type=['number','integer'].includes(String(s.value_type||'').toLowerCase())?'number':'text';if(s.min!==null&&s.min!==undefined)input.min=s.min;if(s.max!==null&&s.max!==undefined)input.max=s.max;if(s.step!==null&&s.step!==undefined)input.step=s.step;input.value=value===null||value===undefined?'':String(value)}input.dataset.key=s.key;input.disabled=!s.writable;wrap.append(label,input);if(!s.writable){var ro=document.createElement('div');ro.className='muted';ro.textContent=t('readonly');wrap.append(ro)}return wrap}
function renderSettings(d){var settings=Array.isArray(d.settings)?d.settings:[];summary([{value:settings.length+' '+t('records')},{value:t('revision')+': '+text(d.source_revision)}]);var form=document.createElement('div');form.className='panel';settings.forEach(function(s){form.append(inputFor(s))});var actions=document.createElement('div');actions.className='actions';var btn=document.createElement('button');btn.type='button';btn.textContent=t('save');btn.disabled=!settings.some(function(s){return s.writable});btn.onclick=function(){saveSettings(form,settings,d)};actions.append(btn);form.append(actions);if(!settings.some(function(s){return s.writable})){var note=document.createElement('p');note.className='muted';note.textContent=t('noWritable');form.append(note)}el.content.append(form)} function renderSettings(d){var settings=Array.isArray(d.settings)?d.settings:[];summary([{value:settings.length+' '+t('records')},{value:t('revision')+': '+text(d.source_revision)}]);var form=document.createElement('div');form.className='panel';settings.forEach(function(s){form.append(inputFor(s))});var actions=document.createElement('div');actions.className='actions';var btn=document.createElement('button');btn.type='button';btn.textContent=t('save');btn.disabled=!settings.some(function(s){return s.writable});btn.onclick=function(){saveSettings(form,settings,d)};actions.append(btn);form.append(actions);if(!settings.some(function(s){return s.writable})){var note=document.createElement('p');note.className='muted';note.textContent=t('noWritable');form.append(note)}el.content.append(form)}
function saveSettings(form,settings,d){var changes={};settings.forEach(function(s){if(!s.writable)return;var input=form.querySelector('[data-key="'+CSS.escape(String(s.key))+'"]');if(!input)return;var v=input.type==='checkbox'?input.checked:input.value;if(['number','integer'].includes(String(s.value_type||'').toLowerCase())&&v!=='')v=Number(v);changes[s.key]=v});if(!Object.keys(changes).length)return;var a=merge(state.config.sourceArgs,{settings:changes,expected_revision:d.source_revision||'',idempotency_key:'mcp-widget-'+Date.now()});rpc('tools/call',{name:state.config.writeTool||'shd_update_controller_expert_settings',arguments:a}).then(function(){setStatus(t('saved'),false);refresh()}).catch(function(e){setStatus(e.message||t('fail'),true)})} function saveSettings(form,settings,d){var changes={};settings.forEach(function(s){if(!s.writable)return;var input=form.querySelector('[data-key="'+CSS.escape(String(s.key))+'"]');if(!input)return;var v=input.type==='checkbox'?input.checked:input.value;if(['number','integer'].includes(String(s.value_type||'').toLowerCase())&&v!=='')v=Number(v);changes[s.key]=v});if(!Object.keys(changes).length)return;var a=merge(state.config.sourceArgs,{settings:changes,expected_revision:d.source_revision||'',idempotency_key:'mcp-widget-'+Date.now()});rpc('tools/call',{name:state.config.writeTool||'shd_update_controller_expert_settings',arguments:a}).then(function(){setStatus(t('saved'),false);refresh()}).catch(function(e){setStatus(e.message||t('fail'),true)})}
function renderTopology(d){var controllers=Array.isArray(d.controllers)?d.controllers:[],items=Array.isArray(d.items)?d.items:[];summary([{value:controllers.length+' '+t('controller')},{value:items.length+' '+t('records')}]);el.content.append(table([t('controller'),t('type'),t('records'),t('revision')],controllers.map(function(c){var role=c.controller_role==='source'?t('source'):t('controller');return[c.controller_name||c.controller,role+' · '+(c.controller_type||'—'),(c.items||[]).length,c.source_revision]})));el.content.append(table([t('name'),t('controller'),t('room'),t('type')],items.slice(0,500).map(function(i){return[i.name||i.id,i.source_controller_id,i.room||i.room_name,i.type||i.devclass]})))} function renderTopology(d){var controllers=Array.isArray(d.controllers)?d.controllers:[];summary([{value:controllers.length+' '+t('controller')},{value:t('inventorySeparate')}]);el.content.append(table([t('controller'),t('type'),t('revision')],controllers.map(function(c){var role=c.controller_role==='source'?t('source'):t('controller');return[c.controller_name||c.controller,role+' · '+(c.controller_type||'—'),c.source_revision]})))}
function renderHealth(d){var list=Array.isArray(d.controllers)?d.controllers:[d];summary([{value:list.length+' '+t('controller')}]);var grid=document.createElement('div');grid.className='grid';list.forEach(function(c){var sources=Array.isArray(c.sources)?c.sources:[];grid.append(card(c.controller||c.source_controller_id||t('controller'),'<p>'+pill(c.status||'—',c.status==='available'?'good':'')+'</p><div class="source-list">'+sources.map(function(s){return '<div class="source-row"><span>'+esc(s.name==='native'?t('native'):t('influx'))+'</span><span>'+pill(s.available?t('available'):(s.configured?t('configured'):t('unavailable')),s.available?'good':(s.configured?'':'bad'))+'</span></div>'}).join('')+'</div>'))});el.content.append(grid)} function renderHealth(d){var list=Array.isArray(d.controllers)?d.controllers:[d];summary([{value:list.length+' '+t('controller')}]);var grid=document.createElement('div');grid.className='grid';list.forEach(function(c){var sources=Array.isArray(c.sources)?c.sources:[];grid.append(card(c.controller||c.source_controller_id||t('controller'),'<p>'+pill(c.status||'—',c.status==='available'?'good':'')+'</p><div class="source-list">'+sources.map(function(s){return '<div class="source-row"><span>'+esc(s.name==='native'?t('native'):t('influx'))+'</span><span>'+pill(s.available?t('available'):(s.configured?t('configured'):t('unavailable')),s.available?'good':(s.configured?'':'bad'))+'</span></div>'}).join('')+'</div>'))});el.content.append(grid)}
function renderAudit(d){var rows=Array.isArray(d.audit)?d.audit:(Array.isArray(d.commands)?d.commands:[]);summary([{value:rows.length+' '+t('records')}]);el.content.append(rows.length?table([t('time'),t('device'),t('action'),t('status'),t('requested'),t('actual')],rows.map(function(x){return[x.created_at,x.item_id,x.action,x.status,text(x.requested_value),text(x.actual_readback)]})):document.createTextNode(t('empty')))} function renderAudit(d){var rows=Array.isArray(d.audit)?d.audit:(Array.isArray(d.commands)?d.commands:[]);summary([{value:rows.length+' '+t('records')}]);el.content.append(rows.length?table([t('time'),t('device'),t('action'),t('status'),t('requested'),t('actual')],rows.map(function(x){return[x.created_at,x.item_id,x.action,x.status,text(x.requested_value),text(x.actual_readback)]})):document.createTextNode(t('empty')))}
function render(){clear();labels();var d=state.data||{};try{if(state.mode==='history')renderHistory(d);else if(state.mode==='compare')renderCompare(d);else if(state.mode==='settings')renderSettings(d);else if(state.mode==='topology')renderTopology(d);else if(state.mode==='health')renderHealth(d);else if(state.mode==='audit')renderAudit(d);else el.content.append(document.createTextNode(t('unknown')))}catch(e){setStatus(e.message||t('unknown'),true)}} function render(){clear();labels();var d=state.data||{};try{if(state.mode==='history')renderHistory(d);else if(state.mode==='compare')renderCompare(d);else if(state.mode==='settings')renderSettings(d);else if(state.mode==='topology')renderTopology(d);else if(state.mode==='health')renderHealth(d);else if(state.mode==='audit')renderAudit(d);else el.content.append(document.createTextNode(t('unknown')))}catch(e){setStatus(e.message||t('unknown'),true)}}
@@ -5,11 +5,11 @@
"resources": [ "resources": [
{ {
"file": "index.html", "file": "index.html",
"bytes": 22419, "bytes": 22446,
"gzipBytes": 7748, "gzipBytes": 7762,
"mediaType": "text/html;profile=mcp-app", "mediaType": "text/html;profile=mcp-app",
"uri": "ui://shd/controller-observability/v1.html", "uri": "ui://shd/controller-observability/v1.html",
"sha256": "4fbbc0b0ffc135353bbfb16cad45d7bcb8a0340466b04869d080720b98f2dbba" "sha256": "de6f1c6e07d74bc58555f41a22e9a0e8a3942577ffce5773f9450707cb7ca440"
} }
], ],
"license": "../../../../../LICENSE", "license": "../../../../../LICENSE",