Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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