diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 291d900a..1b29b025 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: python3 -m py_compile clients/client-linux.py clients/client-psutil.py python3 -m unittest clients/test_client_args.py sh -n clients/entrypoint.sh + bash -n clients/install.sh bash -n status.sh node --check web/js/app.js diff --git a/README.md b/README.md index c8aead11..d4ae4b7e 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,14 @@ docker run -d --restart=always --name=serverstatus-client \ ``` ```bash -# Shell Run -wget -qO client-linux.py --header='Accept: application/vnd.github.raw' \ - 'https://api.github.com/repos/cppla/ServerStatus/contents/clients/client-linux.py?ref=master' -nohup python3 client-linux.py SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD >/dev/null 2>&1 & +# Shell(下载安装脚本,自动配置为 systemd 服务) +wget -qO install.sh --header='Accept: application/vnd.github.raw' \ + 'https://api.github.com/repos/jumploop/ServerStatus/contents/clients/install.sh?ref=master' +bash install.sh SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD ``` +安装脚本会下载 `service/status-client.service` 与 `clients/client-linux.py`,写入客户端配置并注册为 systemd 服务 `status-client`(需 root 或 sudo)。 + `USER` 是常见的宿主机环境变量名。如果没有显式传递或传递方式错误,Compose 可能会把系统中的 `$USER` 解析成本机用户名,而不是默认的 `s01`。推荐优先级: 1. 运行命令显式传递 `USER=s01` diff --git a/clients/install.sh b/clients/install.sh new file mode 100644 index 00000000..c925f1f6 --- /dev/null +++ b/clients/install.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# ServerStatus 客户端一键安装脚本(systemd 方式) +# 自动下载 status-client.service 与 client-linux.py,注册 systemd 服务并启动。 +# 用法: bash install.sh SERVER=服务器地址 [PORT=端口] [USER=用户名] [PASSWORD=密码] +set -euo pipefail + +github_prefix="https://raw.githubusercontent.com/jumploop/ServerStatus/master" + +client_file="/usr/local/ServerStatus/clients/client-linux.py" +client_env="/usr/local/ServerStatus/clients/config.env" +client_service="/usr/lib/systemd/system/status-client.service" +client_override="/etc/systemd/system/status-client.service.d/override.conf" + +SERVER="" +PORT="35601" +USER="" +PASSWORD="" + +for arg in "$@"; do + case "${arg}" in + SERVER=*) SERVER="${arg#SERVER=}" ;; + PORT=*) PORT="${arg#PORT=}" ;; + USER=*) USER="${arg#USER=}" ;; + PASSWORD=*) PASSWORD="${arg#PASSWORD=}" ;; + *) echo "错误: 未知参数 ${arg}" >&2; exit 1 ;; + esac +done + +if [[ -z "${SERVER}" ]]; then + echo "错误: 缺少 SERVER 参数,用法: bash install.sh SERVER=服务器地址 [PORT=端口] [USER=用户名] [PASSWORD=密码]" >&2 + exit 1 +fi + +if [[ -z "${USER}" ]]; then + echo "警告: USER 为空,客户端可能无法上报,请用 USER=用户名 指定" >&2 +fi + +if [[ "$(id -u)" -ne 0 ]]; then + if command -v sudo >/dev/null 2>&1; then + exec sudo bash "$0" "$@" + fi + echo "错误: 请使用 root 权限运行(如: sudo bash install.sh ...)" >&2 + exit 1 +fi + +command -v wget >/dev/null 2>&1 || { echo "错误: 未找到 wget,请先安装 wget" >&2; exit 1; } +command -v python3 >/dev/null 2>&1 || { echo "错误: 未找到 python3,请先安装 python3" >&2; exit 1; } +command -v systemctl >/dev/null 2>&1 || { echo "错误: 未找到 systemctl,当前系统不支持 systemd" >&2; exit 1; } + +mkdir -p "$(dirname "${client_file}")" +wget -qN --no-check-certificate "${github_prefix}/clients/client-linux.py" -O "${client_file}" +wget -qN --no-check-certificate "${github_prefix}/service/status-client.service" -O "${client_service}" +chmod +x "${client_file}" + +printf 'SERVER=%s\nPORT=%s\nUSER=%s\nPASSWORD=%s\n' "${SERVER}" "${PORT}" "${USER}" "${PASSWORD}" > "${client_env}" + +mkdir -p "$(dirname "${client_override}")" +cat > "${client_override}" <<'EOF' +[Service] +EnvironmentFile=/usr/local/ServerStatus/clients/config.env +EOF + +systemctl daemon-reload +systemctl enable status-client >/dev/null 2>&1 || true +if ! systemctl restart status-client; then + echo "错误: status-client 启动失败,最近日志:" >&2 + journalctl -u status-client -n 20 --no-pager >&2 || true + exit 1 +fi + +echo "ServerStatus 客户端安装完成:" +echo " SERVER: ${SERVER}" +echo " PORT: ${PORT}" +echo " USER: ${USER}" +echo " 配置: ${client_env}" +echo " 查看状态: systemctl status status-client" +echo " 查看日志: journalctl -u status-client -f" diff --git a/web/css/app.css b/web/css/app.css index bfd7e2ad..b3138efd 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -367,6 +367,16 @@ th[data-sort].sorted-desc:after{border-top-color:var(--accent);opacity:1} .config-form input[name=password],.config-form textarea[name=rule]{font-family:ui-monospace,SFMono-Regular,Menlo,monospace} .config-form button:disabled{opacity:.5;cursor:not-allowed} .config-editor-card{scroll-margin-top:70px} +.client-cmd{display:flex;flex-direction:column;gap:.6rem;border:1px dashed var(--border);border-radius:8px;background:var(--bg);padding:.75rem} +.client-cmd-head{display:flex;align-items:center;justify-content:space-between;gap:.5rem} +.client-cmd-head strong{font-size:13px} +.client-cmd-head .icon-text{width:auto;flex:none} +.client-cmd-addr{display:flex;flex-direction:column;gap:.3rem;color:var(--text-dim);font-size:12px} +.client-cmd-addr input{height:36px;border:1px solid var(--border);background:var(--bg-alt);color:var(--text);border-radius:6px;padding:0 .65rem;outline:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace} +.client-cmd-addr input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 18%,transparent)} +.client-cmd-text{margin:0;padding:.6rem .7rem;border-radius:6px;background:var(--bg-alt);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-all;max-height:220px;overflow-y:auto} +.client-cmd-methods{width:100%} +.client-cmd-methods button{flex:1;padding:0 .4rem;white-space:nowrap} @media (min-width:981px){ .config-editor-card{position:sticky;top:70px;max-height:calc(100dvh - 86px);overflow-y:auto;scrollbar-gutter:stable} diff --git a/web/index.html b/web/index.html index fa844a5c..2a45d8bb 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ 云监控 - +
@@ -180,7 +180,7 @@

新增节点

保存后会写入 config.json,并由服务热重载。

-
+
@@ -189,6 +189,7 @@

新增节点

+ @@ -207,6 +208,6 @@ ServerStatus中文版 - + diff --git a/web/js/app.js b/web/js/app.js index b52ed9bf..f1b7b899 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -28,6 +28,9 @@ const S = { enabled: false, connected: false, config: null, + agentAddr: '', + clientServer: '', + clientCmdMethod: 'shell', selectedType: 'servers', selectedIndex: -1, queries: { servers:'', monitors:'', sslcerts:'', watchdog:'' }, @@ -814,6 +817,7 @@ async function ensureAdminChecked(){ try{ const health = await api('/api/health', { auth:false }); S.admin.enabled = !!health.enabled; + S.admin.agentAddr = health.agent?.address || ''; setAdminStatus(health.enabled ? '管理 API 已启用,输入 token 后可编辑配置。' : '管理 API 未启用:请在容器环境变量设置 ADMIN_TOKEN。', health.enabled ? '' : 'err'); if(S.admin.enabled && S.admin.token) await loadConfig(); }catch(err){ @@ -845,12 +849,12 @@ const CONFIG_TYPES = { searchFields: ['name','username','location','type','host'], hint: '客户端登录使用 username/password,保存后服务会热重载并让客户端自动重连。', fields: [ - { name:'username', label:'用户名', required:true, max:120 }, + { name:'username', label:'用户名', required:true, max:120, random:8 }, { name:'name', label:'节点名', required:true, max:120 }, { name:'type', label:'虚拟化', required:true, max:120, placeholder:'kvm / xen / vmware' }, { name:'host', label:'主机名', required:true, max:120 }, { name:'location', label:'位置', required:true, max:120, placeholder:'🇨🇳 / 上海 / hk-01' }, - { name:'password', label:'密码', required:true, max:120, keepRaw:true }, + { name:'password', label:'密码', required:true, max:120, keepRaw:true, random:12 }, { name:'monthstart', label:'月初日', type:'number', min:1, max:28, default:1 }, { name:'disabled', label:'禁用节点', type:'checkbox' } ], @@ -919,6 +923,55 @@ function normalizeAdminConfig(config){ }); return normalized; } +function agentPort(){ + const addr = S.admin.agentAddr || ''; + const idx = addr.lastIndexOf(':'); + const port = idx >= 0 ? addr.slice(idx + 1) : addr; + return /^\d+$/.test(port) ? port : '35601'; +} +function defaultClientServer(){ + return window.location.hostname || '127.0.0.1'; +} +function currentClientServer(){ + if(!S.admin.clientServer) S.admin.clientServer = defaultClientServer(); + return S.admin.clientServer; +} +function shellSafe(v){ + const s = String(v ?? ''); + if(!s) return "''"; + return /^[A-Za-z0-9_@:./-]+$/.test(s) ? s : "'" + s.replace(/'/g, "'\\''") + "'"; +} +function clientInstallCommand(user, pass){ + const server = shellSafe(currentClientServer()); + const port = shellSafe(agentPort()); + const userS = shellSafe(user); + const passS = shellSafe(pass); + const method = S.admin.clientCmdMethod || 'shell'; + if(method === 'compose'){ + return [ + `wget -qO docker-compose-client.yml --header='Accept: application/vnd.github.raw' \\`, + ` 'https://api.github.com/repos/cppla/ServerStatus/contents/docker-compose-client.yml?ref=master'`, + `SERVER=${server} PORT=${port} USER=${userS} PASSWORD=${passS} \\`, + ` docker compose -f docker-compose-client.yml up -d --force-recreate`, + ].join('\n'); + } + if(method === 'run'){ + return [ + `docker run -d --restart=always --name=serverstatus-client \\`, + ` --network=host --pid=host \\`, + ` -e SERVER=${server} \\`, + ` -e PORT=${port} \\`, + ` -e USER=${userS} \\`, + ` -e PASSWORD=${passS} \\`, + ` cppla/serverstatus:client`, + ].join('\n'); + } + return [ + `wget -qO install.sh --header='Accept: application/vnd.github.raw' \\`, + ` 'https://api.github.com/repos/jumploop/ServerStatus/contents/clients/install.sh?ref=master'`, + `bash install.sh SERVER=${server} PORT=${port} USER=${userS} PASSWORD=${passS}` + ].join('\n'); +} function activeConfigDef(){ return CONFIG_TYPES[S.admin.selectedType] || CONFIG_TYPES.servers; } @@ -985,15 +1038,55 @@ function renderConfigEditor(item){ const current = item || {}; $('configEditorTitle').textContent = `${editing ? '编辑' : '新增'}${def.label}`; $('configEditorHint').textContent = def.hint; - $('configFields').innerHTML = def.fields.map(field => fieldHTML(field, current)).join(''); + $('configFields').innerHTML = def.fields.map(field => fieldHTML(field, current, !editing)).join(''); const resetTrafficBtn = $('resetTrafficBtn'); const canResetTraffic = editing && S.admin.selectedType === 'servers'; resetTrafficBtn.style.display = canResetTraffic ? '' : 'none'; resetTrafficBtn.disabled = !canResetTraffic || S.admin.saving; $('deleteConfigItemBtn').disabled = !editing; -} -function fieldHTML(field, item){ - const value = item[field.name] ?? field.default ?? ''; + renderClientCmd(); +} +function renderClientCmd(){ + const box = $('clientCmd'); + if(!box) return; + if(S.admin.selectedType !== 'servers'){ box.style.display = 'none'; return; } + box.style.display = ''; + box.innerHTML = [ + '
', + '客户端安装命令', + '', + '
', + '
', + '', + '', + '', + '
', + '', + '
'
+  ].join('');
+  $('clientCmdServer').value = currentClientServer();
+  document.querySelectorAll('#clientCmdMethods [data-method]').forEach(btn => btn.classList.toggle('active', btn.dataset.method === (S.admin.clientCmdMethod || 'shell')));
+  refreshClientCmd();
+}
+function refreshClientCmd(){
+  const textEl = $('clientCmdText');
+  if(!textEl) return;
+  const form = $('configForm').elements;
+  const user = form.username ? form.username.value.trim() : '';
+  const pass = form.password ? form.password.value : '';
+  textEl.textContent = clientInstallCommand(user, pass);
+}
+function randomToken(len){
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  const bytes = new Uint8Array(len);
+  crypto.getRandomValues(bytes);
+  let out = '';
+  for(let i = 0; i < len; i++) out += chars[bytes[i] % chars.length];
+  return out;
+}
+function fieldHTML(field, item, showDefault){
+  let value = item[field.name];
+  if(value == null && showDefault) value = field.random ? randomToken(field.random) : (field.default ?? '');
   if(field.type === 'checkbox'){
     return ``;
   }
@@ -1001,10 +1094,11 @@ function fieldHTML(field, item){
   const min = field.min != null ? ` min="${field.min}"` : '';
   const max = field.max != null ? (field.type === 'number' ? ` max="${field.max}"` : ` maxlength="${field.max}"`) : '';
   const placeholder = field.placeholder ? ` placeholder="${esc(field.placeholder)}"` : '';
+  const autocomplete = field.type === 'password' ? ' autocomplete="new-password"' : ' autocomplete="off"';
   if(field.type === 'textarea'){
-    return ``;
+    return ``;
   }
-  return ``;
+  return ``;
 }
 function clearConfigForm(){
   S.admin.selectedIndex = -1;
@@ -1013,6 +1107,7 @@ function clearConfigForm(){
 }
 function formConfigItem(){
   const elements = $('configForm').elements;
+  const existing = S.admin.selectedIndex >= 0 ? configItems()[S.admin.selectedIndex] : null;
   const item = {};
   activeConfigDef().fields.forEach(field => {
     const el = elements[field.name];
@@ -1026,10 +1121,13 @@ function formConfigItem(){
       if(!Number.isFinite(value)) value = field.default ?? 0;
       if(field.min != null) value = Math.max(field.min, value);
       if(field.max != null) value = Math.min(field.max, value);
+      if(existing && !(field.name in existing) && el.value === '') return;
       item[field.name] = value;
       return;
     }
-    item[field.name] = field.keepRaw ? el.value : el.value.trim();
+    const value = field.keepRaw ? el.value : el.value.trim();
+    if(existing && !(field.name in existing) && value === '') return;
+    item[field.name] = value;
   });
   return item;
 }
@@ -1043,7 +1141,9 @@ async function saveConfigItem(key, index, item){
   S.admin.saving = true;
   S.suppressStatsReloadUntil = Date.now() + 8000;
   try{
-    const data = await api(configItemPath(key, index), { method: index >= 0 ? 'PUT' : 'POST', body: JSON.stringify(item) });
+    const original = index >= 0 ? configItems()[index] : null;
+    const body = original ? { ...original, ...item } : item;
+    const data = await api(configItemPath(key, index), { method: index >= 0 ? 'PUT' : 'POST', body: JSON.stringify(body) });
     if(data.config) S.admin.config = normalizeAdminConfig(data.config);
     S.suppressStatsReloadUntil = Date.now() + 8000;
     return data;
@@ -1118,6 +1218,39 @@ function bindAdmin(){
   });
   $('resetConfigFormBtn').addEventListener('click', clearConfigForm);
   $('resetTrafficBtn').addEventListener('click', () => resetServerTraffic(S.admin.selectedIndex));
+  $('clientCmd').addEventListener('input', e => {
+    if(e.target.id === 'clientCmdServer'){
+      S.admin.clientServer = e.target.value.trim();
+      refreshClientCmd();
+    }
+  });
+  $('clientCmd').addEventListener('click', async e => {
+    const methodBtn = e.target.closest('#clientCmdMethods [data-method]');
+    if(methodBtn){
+      S.admin.clientCmdMethod = methodBtn.dataset.method;
+      document.querySelectorAll('#clientCmdMethods [data-method]').forEach(b => b.classList.toggle('active', b === methodBtn));
+      refreshClientCmd();
+      return;
+    }
+    const btn = e.target.closest('#copyClientCmdBtn');
+    if(!btn) return;
+    const text = $('clientCmdText')?.textContent || '';
+    if(!text) return;
+    try{
+      await navigator.clipboard.writeText(text);
+    }catch(_err){
+      const ta = document.createElement('textarea');
+      ta.value = text;
+      document.body.appendChild(ta);
+      ta.select();
+      document.execCommand('copy');
+      ta.remove();
+    }
+    const prev = btn.textContent;
+    btn.textContent = '已复制';
+    setTimeout(() => { btn.textContent = prev; }, 1200);
+  });
+  $('configForm').addEventListener('input', () => { if(S.admin.selectedType === 'servers') refreshClientCmd(); });
   $('adminReload').addEventListener('click', async () => {
     try{ await api('/api/reload', { method:'POST' }); setAdminStatus('配置重载已触发。', 'ok'); }
     catch(err){ setAdminStatus('重载失败:' + err.message, 'err'); }