Skip to content
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
77 changes: 77 additions & 0 deletions clients/install.sh
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 10 additions & 0 deletions web/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
7 changes: 4 additions & 3 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<meta name="description" content="云监控,ServerStatus中文版,ServerStatus,ServerStatus cppla" />
<title>云监控</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="stylesheet" href="css/app.css?v=20260812-2" />
<link rel="stylesheet" href="css/app.css?v=20260812-4" />
</head>
<body>
<header class="topbar">
Expand Down Expand Up @@ -180,7 +180,7 @@ <h2 id="configEditorTitle">新增节点</h2>
<p class="muted" id="configEditorHint">保存后会写入 config.json,并由服务热重载。</p>
</div>
</div>
<form id="configForm" class="config-form">
<form id="configForm" class="config-form" autocomplete="off">
<div id="configFields" class="config-fields"></div>
<div class="form-actions">
<button type="submit" class="primary-btn">保存配置</button>
Expand All @@ -189,6 +189,7 @@ <h2 id="configEditorTitle">新增节点</h2>
<button type="button" id="resetConfigFormBtn" class="icon-text">清空</button>
</div>
</form>
<div id="clientCmd" class="client-cmd" style="display:none;"></div>
</section>
</div>
</section>
Expand All @@ -207,6 +208,6 @@ <h3 id="detailTitle" class="modal-title">节点详情</h3>
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
</footer>

<script src="js/app.js?v=20260812-4" defer></script>
<script src="js/app.js?v=20260812-7" defer></script>
</body>
</html>
153 changes: 143 additions & 10 deletions web/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:'' },
Expand Down Expand Up @@ -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){
Expand Down Expand Up @@ -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' }
],
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -985,26 +1038,67 @@ 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 = [
'<div class="client-cmd-head">',
'<strong>客户端安装命令</strong>',
'<button type="button" class="icon-text" id="copyClientCmdBtn" title="复制到剪贴板">复制</button>',
'</div>',
'<div class="segmented client-cmd-methods" id="clientCmdMethods" role="group" aria-label="安装方式">',
'<button type="button" data-method="shell" class="active">Shell</button>',
'<button type="button" data-method="compose">Docker Compose</button>',
'<button type="button" data-method="run">Docker Run</button>',
'</div>',
'<label class="client-cmd-addr"><span>服务端地址(客户端 SERVER)</span><input id="clientCmdServer" type="text" spellcheck="false" placeholder="服务器 IP 或域名" /></label>',
'<pre class="client-cmd-text" id="clientCmdText"></pre>'
].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 `<label class="check-row"><input name="${esc(field.name)}" type="checkbox" ${value ? 'checked' : ''} /> <span>${esc(field.label)}</span></label>`;
}
const required = field.required ? ' required' : '';
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 `<label class="wide"><span>${esc(field.label)}</span><textarea name="${esc(field.name)}"${required}${placeholder}>${esc(value)}</textarea></label>`;
return `<label class="wide"><span>${esc(field.label)}</span><textarea name="${esc(field.name)}"${required}${placeholder}${autocomplete}>${esc(value)}</textarea></label>`;
}
return `<label><span>${esc(field.label)}</span><input name="${esc(field.name)}" type="${field.type || 'text'}" value="${esc(value)}"${required}${min}${max}${placeholder} /></label>`;
return `<label><span>${esc(field.label)}</span><input name="${esc(field.name)}" type="${field.type || 'text'}" value="${esc(value)}"${required}${min}${max}${placeholder}${autocomplete} /></label>`;
}
function clearConfigForm(){
S.admin.selectedIndex = -1;
Expand All @@ -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];
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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'); }
Expand Down