2026-10-17 23:45:14 +08:00
import { scopeAlertHealth , summaryStatus } from '/assets/js/overview-health.js' ;
2026-10-17 23:07:49 +08:00
import { state } from '/assets/js/state.js' ;
2026-10-18 23:58:37 +08:00
import { $ , esc , pluginText as text } from '/assets/js/shared.js' ;
import { get } from '/assets/js/navigation.js' ;
function managementLink ( host ) {
try {
const url = new URL ( host . connection ? . management_url || host . connection ? . url || host . url );
if ( ! [ 'http:' , 'https:' ]. includes ( url . protocol ) || url . username || url . password ) return '' ;
const label = text ( '打开 Frame Flow 管理' , 'Open Frame Flow management' );
return `<a class="docker-icon" style="margin-left: -7px" href=" ${ esc ( url . href ) } " target="_blank" rel="noopener noreferrer" title=" ${ label } " aria-label=" ${ label } "><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h6v6M20 4l-10 10M11 4H4v16h16v-7"/></svg></a>` ;
} catch { return '' ; }
}
2026-10-17 23:07:49 +08:00
const metricValue = metric => {
if ( metric . unit === 'percent' ) return ` ${ metric . value } %` ;
if ( metric . unit === 'count' ) return ` ${ metric . value } ${ text ( '个' , 'items' ) } ` ;
const values = {
service_ready : [ text ( '未就绪' , 'Not ready' ), text ( '就绪' , 'Ready' )],
device_online : [ text ( '离线' , 'Offline' ), text ( '在线' , 'Online' )],
job_enabled : [ text ( '停用' , 'Disabled' ), text ( '启用' , 'Enabled' )],
last_run_success : [ text ( '失败或待确认' , 'Failed or unconfirmed' ), text ( '成功' , 'Succeeded' )],
};
return values [ metric . key ] ? .[ metric . value ] ?? String ( metric . value );
};
export function hostCard ( entry ) {
const snapshot = entry . snapshot || {};
const resources = snapshot . resources || [];
const current = entry . dataCurrent === true ;
const label = item => item . label ? .[ state . LANG ] || item . key || item . id ;
const name = id => resources . find ( r => r . id === id ) ? . name || id ;
const alertRows = ( snapshot . alerts || []). map ( alert => {
const separator = alert . id . indexOf ( ':' );
const kind = alert . id . split ( ':' )[ 0 ];
const resource = separator < 0 ? '' : ` ${ kind . startsWith ( 'JOB_' ) ? 'job' : 'device' } : ${ alert . id . slice ( separator + 1 ) } ` ;
return `<div class="row"><span> ${ esc ( label ( alert )) } </span><span> ${ esc ( resource ? name ( resource ) : '' ) } </span></div>` ;
}). join ( '' );
2026-10-17 23:31:38 +08:00
const metrics = snapshot . metrics || [];
const value = ( resource , key ) => metrics . find ( m => m . resource_id === resource . id && m . key === key ) ? . value ;
const card = ( title , content , status = '' ) => `<div class="metric"><div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px"><div class="muted"> ${ esc ( title ) } </div> ${ status } </div><div class="value"> ${ esc ( content ) } </div></div>` ;
const status = ( semantic , title ) => `<span class="semantic-status" data-semantic=" ${ semantic } "> ${ esc ( title ) } </span>` ;
const cards = resources . flatMap ( resource => {
if ( resource . type === 'job' ) {
const enabled = value ( resource , 'job_enabled' ) === 1 ;
const result = value ( resource , 'last_run_success' );
const unknown = ( snapshot . alerts || []). some ( a => a . id === `JOB_UNKNOWN: ${ resource . id . slice ( 4 ) } ` );
const kind = ! current || unknown || result === undefined ? 'unknown' : result === 1 ? 'normal' : 'error' ;
const stateLabel = kind === 'normal' ? text ( '正常' , 'Normal' ) : kind === 'error' ? text ( '失败' , 'Failed' ) : text ( '待确认' , 'Unconfirmed' );
const title = /轮播/ . test ( resource . name || '' ) ? text ( '轮播任务' , 'Slideshow task' ) : resource . name || text ( '任务' , 'Job' );
2026-10-17 23:34:26 +08:00
return [ card ( title , ` ${ resource . name || text ( '任务' , 'Job' ) } · ${ enabled ? text ( '启用' , 'Enabled' ) : text ( '停用' , 'Disabled' ) } ` , status ( kind , stateLabel ))];
2026-10-17 23:31:38 +08:00
}
return metrics . filter ( m => m . resource_id === resource . id ). map ( metric => {
const title = label ( metric );
return card ( title , metricValue ( metric ));
});
}). join ( '' );
const frames = resources . filter ( resource => resource . type === 'device' );
2026-10-18 23:58:37 +08:00
return `<article class="panel host-panel"><div class="host-head"><div><div class="host-title"><h2> ${ esc ( entry . host . name || 'Frame Flow' ) } </h2> ${ managementLink ( entry . host ) } </div><div class="device-details"> ${ frames . map ( frame => `<span> ${ esc ( frame . name || text ( '画框' , 'Frame' )) } </span>` ). join ( '' ) } </div></div><span class="badge semantic-status" data-semantic=" ${ current ? 'online' : 'unknown' } "> ${ current ? text ( '在线' , 'Online' ) : text ( '状态未确认' , 'Unconfirmed' ) } </span></div> ${ ! current ? `<p class="muted"> ${ text ( '以下为上次采集数据' , 'Showing the last collected data' ) } </p>` : '' } <div class="host-grid"> ${ cards } </div> ${ ( snapshot . alerts || []). length || ! current || snapshot . alerts_complete !== true ? `<details class="group" data-overview-details="alerts"><summary class="smart-summary"><strong class="smart-summary-title"> ${ text ( '已读取范围内的告警' , 'Alerts within monitored scope' ) } </strong> ${ summaryStatus ( scopeAlertHealth ( snapshot . alerts , snapshot . alerts_complete , current ), state . LANG ) } </summary><div style="margin-top:12px"> ${ alertRows || ( current && snapshot . alerts_complete ? text ( '无告警' , 'No alerts' ) : text ( '状态未确认' , 'Unconfirmed' )) } </div></details>` : "" } </article>` ;
}
export function renderConfig () {
const host = state . fleetHosts . find ( h => h . id === state . currentConfigHost );
const connection = host . connection || {};
$ ( 'mainSavebar' ). classList . add ( 'hidden' );
$ ( 'configMenu' ). innerHTML = `<button data-config-section="frame-flow" class="active"> ${ text ( 'Frame Flow 接入' , 'Frame Flow connection' ) } </button>` ;
$ ( 'configRows' ). innerHTML = `<section class="config-page active" data-config-page="frame-flow"><h2> ${ text ( 'Frame Flow 接入' , 'Frame Flow connection' ) } </h2><p class="hint"> ${ text ( '配置保存在 Console;管理入口留空时,首页链接使用 Frame Flow 服务地址。API 令牌留空保留现有值。' , 'Settings are stored in Console. Leave the management URL blank to use the Frame Flow service URL on the overview. A blank API token keeps the existing value.' ) } </p><div class="fields"> ${ [
[ 'url' , text ( 'Frame Flow 地址' , 'Frame Flow URL' ), connection . url || host . url || '' ],
[ 'token' , text ( 'API 令牌(留空保留)' , 'API token (blank keeps existing)' ), '' ],
[ 'management_url' , text ( '管理入口地址(留空使用服务地址)' , 'Management URL (blank uses service URL)' ), connection . management_url || '' ],
]. map (([ key , label , value ]) => `<div class="field"><label> ${ label } <input data-frame-flow-connection=" ${ key } " type=" ${ key === 'token' ? 'password' : 'url' } " value=" ${ esc ( value ) } " autocomplete=" ${ key === 'token' ? 'new-password' : 'off' } "></label></div>` ). join ( '' ) } </div><div class="action-row"><button id="saveFrameFlowConnection"> ${ text ( '保存接入配置' , 'Save connection' ) } </button><span id="frameFlowConnectionMessage" role="status"></span></div></section>` ;
const page = $ ( 'configRows' ). querySelector ( '[data-config-page="frame-flow"]' );
$ ( 'configMenu' ). onclick = event => {
if ( ! event . target . closest ( '[data-config-section="frame-flow"]' )) return ;
$ ( 'configMenu' ). querySelectorAll ( 'button' ). forEach ( b => b . classList . toggle ( 'active' , b . dataset . configSection === 'frame-flow' ));
$ ( 'configRows' ). querySelectorAll ( '[data-config-page]' ). forEach ( p => p . classList . toggle ( 'active' , p === page ));
$ ( 'mainSavebar' ). classList . add ( 'hidden' );
};
$ ( 'saveFrameFlowConnection' ). onclick = async () => {
const button = $ ( 'saveFrameFlowConnection' ), message = $ ( 'frameFlowConnectionMessage' );
const values = Object . fromEntries ([... page . querySelectorAll ( '[data-frame-flow-connection]' )]. map ( input => [ input . dataset . frameFlowConnection , input . value . trim ()]));
for ( const [ key , value ] of Object . entries ( values )) {
if ( key === 'token' || ( key === 'management_url' && ! value )) continue ;
try {
const url = new URL ( value );
if ( ! [ 'http:' , 'https:' ]. includes ( url . protocol ) || url . username || url . password || url . pathname !== '/' || url . search || url . hash ) throw Error ();
} catch {
message . textContent = text ( '请使用不含路径或账号密码的 HTTP(S) 根地址' , 'Use an HTTP(S) origin without paths or credentials' );
message . className = 'error' ; return ;
}
}
button . disabled = true ;
try {
const result = await get ( '/api/v1/hosts' , { method : 'PUT' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ operation : 'save' , id : host . id , adapter_id : host . adapter_id , connection : values }) });
state . fleetHosts = result . hosts ;
page . querySelector ( '[data-frame-flow-connection="token"]' ). value = '' ;
message . textContent = text ( '已保存' , 'Saved' ); message . className = 'success' ;
} catch ( error ) { message . textContent = error . message ; message . className = 'error' ; }
finally { button . disabled = false ; }
};
2026-10-17 23:07:49 +08:00
}