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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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