2026-10-18 00:12:50 +08:00
import { runtimeStatus } from "/assets/js/ui-semantics.js" ;
2026-10-11 14:52:40 +08:00
import { state as appState } from "/assets/js/state.js" ;
2026-10-15 23:30:11 +08:00
import { addStorageSettings } from "./storage.js" ;
2026-10-11 14:52:40 +08:00
import { renderDevice } from "./device.js" ;
import { renderHosts } from "/assets/js/devices.js" ;
import {
bulkSections ,
field ,
labels ,
options ,
sectionMeta ,
sectionOrder ,
} from "./schema.js" ;
import { tr } from "/assets/js/i18n.js" ;
import { loadPlugins , pluginText , renderPlugins } from "./hardware.js" ;
import { $ , configurableHosts , esc , hostLabel } from "/assets/js/shared.js" ;
import { get , rows } from "/assets/js/navigation.js" ;
import {
loadServices ,
renderServiceLists ,
resetMonitorForm ,
saveMonitor ,
tokenHeaders ,
} from "./services.js" ;
import {
applyBulkConfig ,
deleteHost ,
saveHost ,
} from "/assets/js/device-actions.js" ;
export function renderGeneral ( s , v ) {
if ( s === "device" ) return renderDevice ( v );
const [ t , d ] = sectionMeta [ s ] || [ s , "" ];
return `<div class="group"><div class="group-head"><div><h3> ${ tr ( t ) } </h3><div class="hint"> ${ tr ( d ) } </div></div></div><div class="fields"> ${
Object . entries ( v )
. map (([ k , x ]) => field ( s , k , x ))
. join ( "" ) || '<div class="muted">暂无配置项</div>'
} </div></div>` ;
}
export function renderServices () {
return `<div class="group"><div class="group-head"><div><h3>当前系统服务</h3><div class="hint">点击“加入监控”会自动填写下方表单;也可以手动输入尚未安装的未来服务。</div></div><button id="refreshServices">刷新列表</button></div><input class="service-search" id="serviceSearch" placeholder="搜索服务名称或说明"><div class="service-list rows" id="systemServices"><div class="muted">正在读取 systemd 服务…</div></div></div><div class="group"><h3>已监控服务</h3><div class="hint">“暂停监控”只停止状态检查,不影响服务运行;“停止服务”才会操作 systemd。</div><div class="rows" id="monitoredServices"></div></div><div class="group"><h3 id="serviceFormTitle">新建服务监控</h3><div class="service-form"><label>OLED 显示名称<input id="monitorName" maxlength="64" placeholder="例如:DOCKER"></label><label>systemd 服务名称<input id="monitorTarget" maxlength="128" placeholder="例如:docker.service"></label><label class="full">备注说明<input id="monitorNote" maxlength="120" placeholder="例如:家庭相册容器服务"></label><div class="full action-row"><button id="saveMonitor">保存监控</button> <button id="resetMonitor">清空</button> <span id="serviceMessage" class="muted"></span></div></div></div>` ;
}
export function renderProcesses () {
return `<div class="group"><h3> ${ pluginText ( "已监控进程" , "Monitored processes" ) } </h3><div class="hint"> ${ pluginText ( "用于没有 systemd 服务的程序,通过 pgrep -f 匹配完整命令行。" , "For programs without a systemd service. Matching uses pgrep -f against the full command line." ) } </div><div class="rows" id="processList"><div class="muted"> ${ pluginText ( "正在读取进程监控…" , "Loading process monitors…" ) } </div></div></div><div class="group"><h3 id="processFormTitle"> ${ pluginText ( "新建进程监控" , "Add process monitor" ) } </h3><div class="service-form"><label> ${ pluginText ( "显示名称" , "Display name" ) } <input id="processName" maxlength="64" placeholder="例如:MYAPP"></label><label> ${ pluginText ( "进程匹配关键字" , "Process match expression" ) } <input id="processTarget" maxlength="256" placeholder="例如:/opt/myapp/server"></label><div class="full action-row"><button id="saveProcess"> ${ pluginText ( "保存监控" , "Save monitor" ) } </button><button id="resetProcess"> ${ pluginText ( "清空" , "Clear" ) } </button><span id="processMessage" class="muted"></span></div></div></div>` ;
}
export async function loadProcesses () {
if ( ! $ ( "processList" )) return ;
try {
const d = await get (
`/api/v1/processes?host= ${ encodeURIComponent ( appState . currentConfigHost ) } ` ,
);
appState . processCache = d . processes ;
renderProcessList ();
} catch ( e ) {
$ ( "processList" ). innerHTML = `<div class="error"> ${ esc ( e . message ) } </div>` ;
}
}
export function renderProcessList () {
rows (
"processList" ,
appState . processCache . map (
( p ) =>
2026-10-18 00:12:50 +08:00
`<div class="row"><span><b> ${ esc ( p . name ) } </b> ${ runtimeStatus ( p . up , appState . LANG ) } <br><span class="muted"> ${ esc ( p . target ) } </span></span><span class="row-actions"><button data-edit-process=" ${ esc ( p . name ) } "> ${ tr ( "编辑" ) } </button><button class="danger" data-delete-process=" ${ esc ( p . name ) } "> ${ tr ( "删除" ) } </button></span></div>` ,
2026-10-11 14:52:40 +08:00
),
pluginText ( "尚未监控任何普通进程" , "No process monitors configured" ),
);
document
. querySelectorAll ( "[data-edit-process]" )
. forEach (( b ) => ( b . onclick = () => selectProcess ( b . dataset . editProcess )));
document
. querySelectorAll ( "[data-delete-process]" )
. forEach (( b ) => ( b . onclick = () => deleteProcess ( b . dataset . deleteProcess )));
}
export function selectProcess ( name ) {
const p = appState . processCache . find (( x ) => x . name === name );
if ( ! p ) return ;
appState . editingProcessName = p . name ;
$ ( "processName" ). value = p . name ;
$ ( "processTarget" ). value = p . target ;
$ ( "processFormTitle" ). textContent = pluginText (
"编辑进程监控" ,
"Edit process monitor" ,
);
}
export function resetProcessForm () {
appState . editingProcessName = "" ;
$ ( "processName" ). value = "" ;
$ ( "processTarget" ). value = "" ;
$ ( "processFormTitle" ). textContent = pluginText (
"新建进程监控" ,
"Add process monitor" ,
);
$ ( "processMessage" ). textContent = "" ;
}
export async function saveProcess () {
const state = $ ( "processMessage" );
try {
state . textContent = pluginText ( "正在保存…" , "Saving…" );
await get ( "/api/v1/processes" , {
method : "PUT" ,
headers : tokenHeaders (),
body : JSON . stringify ({
host : appState . currentConfigHost ,
operation : "save" ,
name : $ ( "processName" ). value . trim (),
target : $ ( "processTarget" ). value . trim (),
previous_name : appState . editingProcessName ,
}),
});
resetProcessForm ();
await loadProcesses ();
state . textContent = pluginText ( "保存成功" , "Saved" );
state . className = "success" ;
} catch ( e ) {
state . textContent = e . message ;
state . className = "error" ;
}
}
export async function deleteProcess ( name ) {
if (
! confirm (
pluginText (
`确定删除 ${ name } 的进程监控吗?` ,
`Remove process monitor ${ name } ?` ,
),
)
)
return ;
try {
await get ( "/api/v1/processes" , {
method : "PUT" ,
headers : tokenHeaders (),
body : JSON . stringify ({
host : appState . currentConfigHost ,
operation : "delete" ,
name ,
}),
});
await loadProcesses ();
} catch ( e ) {
alert ( e . message );
}
}
export function renderBulkTargets () {
return `<div class="group"><h3> ${ tr ( "目标机器" ) } </h3><div class="hint"> ${ tr ( "机器标识和机器列表不会参与同步。" ) } </div> ${
configurableHosts ()
. filter (
( h ) =>
h . id !== appState . bulkSource &&
h . config_contract ===
appState . fleetHosts . find (
( source ) => source . id === appState . bulkSource ,
) ? . config_contract ,
)
. map (
( h ) =>
`<label class="action-row"><input type="checkbox" data-bulk-target=" ${ esc ( h . id ) } "> ${ esc ( hostLabel ( h )) } </label>` ,
)
. join ( "" ) || pluginText ( "没有其他设备" , "No other devices" )
} </div>` ;
}
export function bulkValue ( section , key , value ) {
const item = options [ ` ${ section } . ${ key } ` ] ? . find (([ v ]) => v === String ( value ));
return item ? tr ( item [ 1 ]) : value ;
}
export function renderBulkSync () {
const groups = bulkSections
. filter (
( s ) =>
appState . bulkOriginal [ s ] &&
Object . keys ( appState . bulkOriginal [ s ]). length ,
)
. map (
( s ) =>
`<div class="group bulk-section"><label class="bulk-section-title"><input type="checkbox" data-bulk-section=" ${ esc ( s ) } "> ${ tr ( sectionMeta [ s ] ? .[ 0 ] || s ) } </label><div class="bulk-items"> ${ Object . entries (
appState . bulkOriginal [ s ],
)
. map (
([ k , v ]) =>
`<label class="bulk-item"><input type="checkbox" data-bulk-item=" ${ esc ( s ) } . ${ esc ( k ) } "><span> ${ esc ( tr ( labels [ ` ${ s } . ${ k } ` ] || k )) } <small class="bulk-value"> ${ esc ( bulkValue ( s , k , v )) } </small></span></label>` ,
)
. join ( "" ) } </div></div>` ,
)
. join ( "" );
return `<div class="group"><h3> ${ tr ( "来源机器" ) } </h3><div> ${ esc ( hostLabel ( appState . fleetHosts . find (( h ) => h . id === appState . bulkSource ) || { id : appState . bulkSource } ))}</div></div> ${ renderBulkTargets () } <div class="bulk-tools"><button id="bulkAll"> ${ tr ( "全部选择" ) } </button><button id="bulkNone"> ${ tr ( "全部取消" ) } </button><button id="bulkInvert"> ${ tr ( "反选" ) } </button><span class="muted" id="bulkCount"></span></div><div><h3> ${ tr ( "配置项" ) } </h3> ${ groups } </div><div class="savebar"><button id="bulkApply"> ${ tr ( "开始批量同步" ) } </button> <span id="bulkState" class="muted"></span></div>` ;
}
export function updateBulkSelection () {
document . querySelectorAll ( "[data-bulk-section]" ). forEach (( parent ) => {
const items = [
... document . querySelectorAll (
`[data-bulk-item^=" ${ parent . dataset . bulkSection } ."]` ,
),
];
parent . checked = items . length > 0 && items . every (( x ) => x . checked );
parent . indeterminate = items . some (( x ) => x . checked ) && ! parent . checked ;
});
const n = document . querySelectorAll ( "[data-bulk-item]:checked" ). length ;
if ( $ ( "bulkCount" ))
$ ( "bulkCount" ). textContent =
appState . LANG === "zh" ? `已选择 ${ n } 项` : ` ${ n } settings selected` ;
}
export function bindBulkSync () {
if ( ! $ ( "bulkApply" )) return ;
document . querySelectorAll ( "[data-bulk-section]" ). forEach (
( x ) =>
( x . onchange = () => {
document
. querySelectorAll ( `[data-bulk-item^=" ${ x . dataset . bulkSection } ."]` )
. forEach (( i ) => ( i . checked = x . checked ));
updateBulkSelection ();
}),
);
document
. querySelectorAll ( "[data-bulk-item]" )
. forEach (( x ) => ( x . onchange = updateBulkSelection ));
$ ( "bulkAll" ). onclick = () => {
document
. querySelectorAll ( "[data-bulk-item]" )
. forEach (( x ) => ( x . checked = true ));
updateBulkSelection ();
};
$ ( "bulkNone" ). onclick = () => {
document
. querySelectorAll ( "[data-bulk-item]" )
. forEach (( x ) => ( x . checked = false ));
updateBulkSelection ();
};
$ ( "bulkInvert" ). onclick = () => {
document
. querySelectorAll ( "[data-bulk-item]" )
. forEach (( x ) => ( x . checked = ! x . checked ));
updateBulkSelection ();
};
$ ( "bulkApply" ). onclick = applyBulkConfig ;
updateBulkSelection ();
}
export function updateConditional () {
const alertMode =
document . querySelector ( '[data-key="alert_mode"]' ) ? . value || "breathe" ;
document
. querySelectorAll ( ".alert-flash" )
. forEach (( e ) => e . classList . toggle ( "hidden" , alertMode !== "flash" ));
document
. querySelectorAll ( ".alert-breathe" )
. forEach (( e ) => e . classList . toggle ( "hidden" , alertMode !== "breathe" ));
const mode =
document . querySelector ( '[data-section="led"][data-key="normal_mode"]' )
? . value || "off" ;
document
. querySelectorAll ( ".normal-field" )
. forEach (( x ) =>
x . classList . toggle ( "hidden" , ! x . dataset . modes . split ( " " ). includes ( mode )),
);
updateSwatches ();
}
export function updateSwatches () {
document . querySelectorAll ( "[data-swatch]" ). forEach (( s ) => {
const p = s . dataset . swatch ,
read = ( c ) =>
Math . max (
0 ,
Math . min (
255 ,
Number (
document . querySelector (
`[data-section="led"][data-key=" ${ p } _ ${ c } "]` ,
) ? . value || 0 ,
),
),
),
hex =
"#" +
[ "r" , "g" , "b" ]
. map (( c ) => Math . round ( read ( c )). toString ( 16 ). padStart ( 2 , "0" ))
. join ( "" );
s . style . background = hex ;
const picker = document . querySelector ( `[data-color-prefix=" ${ p } "]` );
if ( picker ) picker . value = hex ;
});
}
export function renderConfig () {
const ss = sectionOrder . filter (
( s ) => appState . original [ s ] || s === "plugins" ,
);
$ ( "configMenu" ). innerHTML = ss
. map (
( s , i ) =>
`<button class=" ${ i === 0 ? "active" : "" } " data-config-section=" ${ s } "> ${ tr ( sectionMeta [ s ] ? .[ 0 ] || s ) } </button>` ,
)
. join ( "" );
$ ( "configRows" ). innerHTML = ss
. map (
( s , i ) =>
`<section class="config-page ${ i === 0 ? "active" : "" } " data-config-page=" ${ s } "><h2> ${ tr ( sectionMeta [ s ] ? .[ 0 ] || s ) } </h2><p class="muted"> ${ tr ( sectionMeta [ s ] ? .[ 1 ] || "" ) } </p> ${ s === "bulk_sync" ? renderBulkSync () : s === "plugins" ? renderPlugins () : s === "services" ? renderServices () : s === "processes" ? renderProcesses () : s === "hosts" ? renderHosts ( appState . original [ s ]) : renderGeneral ( s , appState . original [ s ]) } </section>` ,
)
. join ( "" );
$ ( "configMenu" ). onclick = ( e ) => {
const b = e . target . closest ( "button" );
if ( ! b ) return ;
document
. querySelectorAll ( "[data-config-section]" )
. forEach (( x ) => x . classList . toggle ( "active" , x === b ));
document
. querySelectorAll ( "[data-config-page]" )
. forEach (( x ) =>
x . classList . toggle (
"active" ,
x . dataset . configPage === b . dataset . configSection ,
),
);
$ ( "mainSavebar" ). classList . toggle (
"hidden" ,
2026-10-20 20:55:28 +08:00
[ "bulk_sync" , "plugins" , "access" ]. includes ( b . dataset . configSection ),
2026-10-11 14:52:40 +08:00
);
if ( b . dataset . configSection === "services" ) loadServices ();
if ( b . dataset . configSection === "processes" ) loadProcesses ();
if ( b . dataset . configSection === "plugins" ) loadPlugins ();
};
$ ( "configRows" ). oninput = ( e ) => {
if ( e . target . matches ( "[data-color-prefix]" )) {
const p = e . target . dataset . colorPrefix ,
v = e . target . value ;
[ "r" , "g" , "b" ]. forEach (( c , i ) => {
document . querySelector (
`[data-section="led"][data-key=" ${ p } _ ${ c } "]` ,
). value = parseInt ( v . slice ( 1 + i * 2 , 3 + i * 2 ), 16 );
});
}
if ( e . target . matches ( '[data-section="led"],[data-color-prefix]' ))
updateConditional ();
if ( e . target . matches ( "[data-section]" )) {
if ( e . target . dataset . section === "device" )
e . target . dataset . deviceAuto = "false" ;
markChanged ();
}
};
$ ( "refreshServices" ) ? . addEventListener ( "click" , loadServices );
$ ( "serviceSearch" ) ? . addEventListener ( "input" , renderServiceLists );
$ ( "saveMonitor" ) ? . addEventListener ( "click" , saveMonitor );
$ ( "resetMonitor" ) ? . addEventListener ( "click" , resetMonitorForm );
$ ( "saveProcess" ) ? . addEventListener ( "click" , saveProcess );
$ ( "resetProcess" ) ? . addEventListener ( "click" , resetProcessForm );
$ ( "saveHost" ) ? . addEventListener ( "click" , saveHost );
document
. querySelectorAll ( "[data-delete-host]" )
. forEach (( b ) => ( b . onclick = () => deleteHost ( b . dataset . deleteHost )));
bindBulkSync ();
updateConditional ();
2026-10-15 23:30:11 +08:00
addStorageSettings ();
2026-10-20 20:55:28 +08:00
installAccessSettings ();
2026-10-11 14:52:40 +08:00
}
export function markChanged () {
let n = 0 ;
document . querySelectorAll ( "#configRows [data-section]" ). forEach (( i ) => {
if (
( i . dataset . deviceAuto === "true" ? "auto" : i . value ) !==
appState . original [ i . dataset . section ][ i . dataset . key ]
)
n ++ ;
});
$ ( "saveState" ). textContent = n
? appState . LANG === "zh"
? `有 ${ n } 项尚未保存`
: ` ${ n } unsaved changes`
: tr ( "尚未修改" );
$ ( "saveState" ). className = n ? "" : "muted" ;
}
2026-10-20 20:55:28 +08:00
function installAccessSettings () {
const host = appState . fleetHosts . find ( h => h . id === appState . currentConfigHost );
if ( ! host ) return ;
$ ( "configMenu" ). insertAdjacentHTML ( "beforeend" , `<button data-config-section="access"> ${ pluginText ( "访问地址" , "Access URL" ) } </button>` );
2026-10-20 21:04:03 +08:00
$ ( "configRows" ). insertAdjacentHTML ( "beforeend" , `<section class="config-page" data-config-page="access"><h2> ${ pluginText ( "访问地址" , "Access URL" ) } </h2><p class="muted"> ${ pluginText ( "设置 Gateway 管理页面地址,首页显示链接按钮;留空隐藏入口。" , "Set the Gateway management URL to show a link on the overview. Leave blank to hide it." ) } </p><div class="fields"><div class="field"><label> ${ pluginText ( "访问地址" , "Access URL" ) } <input id="gatewayAccessUrl" type="url" value=" ${ esc ( host . connection ? . management_url || "" ) } " placeholder="https://gateway.example.com"></label></div></div><div class="action-row"><button id="saveGatewayAccess"> ${ pluginText ( "保存访问地址" , "Save access URL" ) } </button><span id="gatewayAccessMessage" role="status"></span></div></section>` );
2026-10-20 20:55:28 +08:00
$ ( "saveGatewayAccess" ). onclick = async () => {
const button = $ ( "saveGatewayAccess" ), message = $ ( "gatewayAccessMessage" );
const value = $ ( "gatewayAccessUrl" ). value . trim ();
if ( value ) {
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 = pluginText ( "请使用不含路径或账号密码的 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 : { management_url : value }})});
appState . fleetHosts = result . hosts ;
message . textContent = pluginText ( "已保存" , "Saved" ); message . className = "success" ;
} catch ( error ) { message . textContent = error . message ; message . className = "error" ; }
finally { button . disabled = false ; }
};
}