mirror of
https://github.com//cppla/ServerStatus
synced 2026-08-06 09:23:56 +08:00
@@ -28,6 +28,7 @@ mkdir -p "$WEB_DIR/json"
|
|||||||
nginx
|
nginx
|
||||||
|
|
||||||
CONFIG_PATH="$CONFIG_PATH" \
|
CONFIG_PATH="$CONFIG_PATH" \
|
||||||
|
STATS_PATH="${STATS_PATH:-$WEB_DIR/json/stats.json}" \
|
||||||
SERGATE_PID_FILE="$SERGATE_PID_FILE" \
|
SERGATE_PID_FILE="$SERGATE_PID_FILE" \
|
||||||
ADMIN_API_BIND="$ADMIN_API_BIND" \
|
ADMIN_API_BIND="$ADMIN_API_BIND" \
|
||||||
ADMIN_API_PORT="$ADMIN_API_PORT" \
|
ADMIN_API_PORT="$ADMIN_API_PORT" \
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from urllib.parse import unquote, urlparse
|
|||||||
|
|
||||||
|
|
||||||
CONFIG_PATH = os.environ.get("CONFIG_PATH", "/ServerStatus/server/config.json")
|
CONFIG_PATH = os.environ.get("CONFIG_PATH", "/ServerStatus/server/config.json")
|
||||||
|
STATS_PATH = os.environ.get("STATS_PATH", "/usr/share/nginx/html/json/stats.json")
|
||||||
SERGATE_PID_FILE = os.environ.get("SERGATE_PID_FILE", "/tmp/serverstatus-sergate.pid")
|
SERGATE_PID_FILE = os.environ.get("SERGATE_PID_FILE", "/tmp/serverstatus-sergate.pid")
|
||||||
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
|
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
|
||||||
API_BIND = os.environ.get("ADMIN_API_BIND", "127.0.0.1")
|
API_BIND = os.environ.get("ADMIN_API_BIND", "127.0.0.1")
|
||||||
@@ -31,6 +32,36 @@ def load_config():
|
|||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_json_file(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def load_stats_file():
|
||||||
|
try:
|
||||||
|
return load_json_file(STATS_PATH)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return load_json_file(f"{STATS_PATH}~")
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_file(path, data):
|
||||||
|
directory = os.path.dirname(path) or "."
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
mode = os.stat(path).st_mode if os.path.exists(path) else 0o644
|
||||||
|
payload = json.dumps(data, ensure_ascii=False, indent="\t") + "\n"
|
||||||
|
fd, tmp_path = tempfile.mkstemp(prefix=".json.", suffix=".tmp", dir=directory)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(payload)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.chmod(tmp_path, mode)
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
def normalize_required_strings(item, required, kind, index=None):
|
def normalize_required_strings(item, required, kind, index=None):
|
||||||
missing = [field for field in required if item.get(field) is None or not str(item.get(field, "")).strip()]
|
missing = [field for field in required if item.get(field) is None or not str(item.get(field, "")).strip()]
|
||||||
if missing:
|
if missing:
|
||||||
@@ -238,6 +269,27 @@ def signal_sergate(sig):
|
|||||||
return pid
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_pid_exit(pid, timeout=2.5):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
time.sleep(0.05)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_sergate_pid(previous_pid=None, timeout=4.0):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
pid = get_sergate_pid()
|
||||||
|
if pid and pid != previous_pid:
|
||||||
|
return pid
|
||||||
|
time.sleep(0.1)
|
||||||
|
return get_sergate_pid()
|
||||||
|
|
||||||
|
|
||||||
def find_server(config, username):
|
def find_server(config, username):
|
||||||
servers = config.get("servers", [])
|
servers = config.get("servers", [])
|
||||||
for index, server in enumerate(servers):
|
for index, server in enumerate(servers):
|
||||||
@@ -262,6 +314,81 @@ def find_collection_item(config, key, item_id):
|
|||||||
return -1, None
|
return -1, None
|
||||||
|
|
||||||
|
|
||||||
|
def stats_server_matches(config_server, stats_server):
|
||||||
|
return all(str(stats_server.get(field, "")) == str(config_server.get(field, "")) for field in ["name", "type", "host", "location"])
|
||||||
|
|
||||||
|
|
||||||
|
def find_stats_server(stats, config_server):
|
||||||
|
servers = stats.get("servers", [])
|
||||||
|
if not isinstance(servers, list):
|
||||||
|
raise ApiError(500, "stats.json has invalid servers data")
|
||||||
|
for index, stats_server in enumerate(servers):
|
||||||
|
if isinstance(stats_server, dict) and stats_server_matches(config_server, stats_server):
|
||||||
|
return index, stats_server
|
||||||
|
return -1, None
|
||||||
|
|
||||||
|
|
||||||
|
def as_counter(value, field):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ApiError(409, f"{field} is missing or invalid in stats.json")
|
||||||
|
|
||||||
|
|
||||||
|
def require_resettable_stats(stats, server, username):
|
||||||
|
stats_index, stats_server = find_stats_server(stats, server)
|
||||||
|
if stats_index < 0:
|
||||||
|
raise ApiError(404, "server stats were not found", {"username": username})
|
||||||
|
if "network_in" not in stats_server or "network_out" not in stats_server:
|
||||||
|
raise ApiError(409, "server has no current traffic counters; it may be offline", {"username": username})
|
||||||
|
return stats_index, stats_server
|
||||||
|
|
||||||
|
|
||||||
|
def reset_server_month_traffic(username):
|
||||||
|
config = load_config()
|
||||||
|
_, server = find_server(config, username)
|
||||||
|
if server is None:
|
||||||
|
raise ApiError(404, "server was not found", {"username": username})
|
||||||
|
|
||||||
|
require_resettable_stats(load_stats_file(), server, username)
|
||||||
|
|
||||||
|
old_pid = signal_sergate(signal.SIGTERM)
|
||||||
|
wait_for_pid_exit(old_pid)
|
||||||
|
|
||||||
|
stats = load_stats_file()
|
||||||
|
stats_index, stats_server = require_resettable_stats(stats, server, username)
|
||||||
|
|
||||||
|
network_in = as_counter(stats_server.get("network_in"), "network_in")
|
||||||
|
network_out = as_counter(stats_server.get("network_out"), "network_out")
|
||||||
|
previous_last_in = as_counter(stats_server.get("last_network_in", 0), "last_network_in")
|
||||||
|
previous_last_out = as_counter(stats_server.get("last_network_out", 0), "last_network_out")
|
||||||
|
|
||||||
|
stats["servers"][stats_index]["last_network_in"] = network_in
|
||||||
|
stats["servers"][stats_index]["last_network_out"] = network_out
|
||||||
|
stats["updated"] = str(int(time.time()))
|
||||||
|
write_json_file(STATS_PATH, stats)
|
||||||
|
|
||||||
|
new_pid = wait_for_sergate_pid(previous_pid=old_pid)
|
||||||
|
if new_pid:
|
||||||
|
os.kill(new_pid, signal.SIGHUP)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"server": server,
|
||||||
|
"stats": {
|
||||||
|
"network_in": network_in,
|
||||||
|
"network_out": network_out,
|
||||||
|
"previous_last_network_in": previous_last_in,
|
||||||
|
"previous_last_network_out": previous_last_out,
|
||||||
|
"last_network_in": network_in,
|
||||||
|
"last_network_out": network_out,
|
||||||
|
"month_in_before": max(0, network_in - previous_last_in),
|
||||||
|
"month_out_before": max(0, network_out - previous_last_out),
|
||||||
|
},
|
||||||
|
"oldPid": old_pid,
|
||||||
|
"pid": new_pid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def collection_routes():
|
def collection_routes():
|
||||||
endpoints = []
|
endpoints = []
|
||||||
for key in ["monitors", "sslcerts", "watchdog"]:
|
for key in ["monitors", "sslcerts", "watchdog"]:
|
||||||
@@ -290,6 +417,7 @@ def api_schema():
|
|||||||
{"method": "POST", "path": "/api/servers", "auth": True, "body": "server JSON"},
|
{"method": "POST", "path": "/api/servers", "auth": True, "body": "server JSON"},
|
||||||
{"method": "PUT", "path": "/api/servers/{username}", "auth": True, "body": "server JSON"},
|
{"method": "PUT", "path": "/api/servers/{username}", "auth": True, "body": "server JSON"},
|
||||||
{"method": "DELETE", "path": "/api/servers/{username}", "auth": True},
|
{"method": "DELETE", "path": "/api/servers/{username}", "auth": True},
|
||||||
|
{"method": "POST", "path": "/api/servers/{username}/reset-traffic", "auth": True},
|
||||||
*collection_routes(),
|
*collection_routes(),
|
||||||
{"method": "POST", "path": "/api/reload", "auth": True},
|
{"method": "POST", "path": "/api/reload", "auth": True},
|
||||||
{"method": "POST", "path": "/api/restart", "auth": True},
|
{"method": "POST", "path": "/api/restart", "auth": True},
|
||||||
@@ -379,6 +507,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
config, pid = write_and_reload(config)
|
config, pid = write_and_reload(config)
|
||||||
self.send_json(201, {"ok": True, "server": server, "reloaded": True, "pid": pid, "config": config})
|
self.send_json(201, {"ok": True, "server": server, "reloaded": True, "pid": pid, "config": config})
|
||||||
return
|
return
|
||||||
|
if path.startswith("/api/servers/") and path.endswith("/reset-traffic"):
|
||||||
|
username = unquote(path[len("/api/servers/"):-len("/reset-traffic")].rstrip("/"))
|
||||||
|
if not username:
|
||||||
|
raise ApiError(400, "username is required")
|
||||||
|
if method == "POST":
|
||||||
|
result = reset_server_month_traffic(username)
|
||||||
|
self.send_json(200, {"ok": True, "operation": "reset-traffic", **result})
|
||||||
|
return
|
||||||
if path.startswith("/api/servers/"):
|
if path.startswith("/api/servers/"):
|
||||||
username = unquote(path[len("/api/servers/"):])
|
username = unquote(path[len("/api/servers/"):])
|
||||||
if not username:
|
if not username:
|
||||||
|
|||||||
+4
-2
@@ -346,10 +346,12 @@ table.data tbody tr[class*="os-"]:hover{background:linear-gradient(180deg, color
|
|||||||
.segmented button{height:36px;border:0;border-right:1px solid var(--border);background:transparent;color:var(--text-dim);padding:0 .8rem;cursor:pointer}
|
.segmented button{height:36px;border:0;border-right:1px solid var(--border);background:transparent;color:var(--text-dim);padding:0 .8rem;cursor:pointer}
|
||||||
.segmented button:last-child{border-right:0}
|
.segmented button:last-child{border-right:0}
|
||||||
.segmented button.active,.segmented button:hover{background:var(--accent);color:#fff}
|
.segmented button.active,.segmented button:hover{background:var(--accent);color:#fff}
|
||||||
.icon-text,.primary-btn,.danger-btn{height:36px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);padding:0 .75rem;cursor:pointer;transition:var(--trans);font-weight:600}
|
.icon-text,.primary-btn,.warn-btn,.danger-btn{height:36px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);padding:0 .75rem;cursor:pointer;transition:var(--trans);font-weight:600}
|
||||||
.icon-text:hover{border-color:var(--accent);color:var(--accent)}
|
.icon-text:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
.primary-btn{background:var(--accent);border-color:var(--accent);color:#fff}
|
.primary-btn{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||||
.primary-btn:hover{filter:brightness(1.08)}
|
.primary-btn:hover{filter:brightness(1.08)}
|
||||||
|
.warn-btn{background:rgba(245,158,11,.14);border-color:rgba(245,158,11,.42);color:var(--warn)}
|
||||||
|
.warn-btn:hover{background:var(--warn);border-color:var(--warn);color:#111827}
|
||||||
.danger-btn{background:rgba(239,68,68,.14);border-color:rgba(239,68,68,.38);color:var(--danger)}
|
.danger-btn{background:rgba(239,68,68,.14);border-color:rgba(239,68,68,.38);color:var(--danger)}
|
||||||
.danger-btn:hover{background:var(--danger);color:#fff}
|
.danger-btn:hover{background:var(--danger);color:#fff}
|
||||||
th[data-sort]{cursor:pointer;user-select:none}
|
th[data-sort]{cursor:pointer;user-select:none}
|
||||||
@@ -434,7 +436,7 @@ th[data-sort].sorted-desc:after{border-top-color:var(--accent);opacity:1}
|
|||||||
}
|
}
|
||||||
@media (max-width:640px){
|
@media (max-width:640px){
|
||||||
.ops-toolbar{align-items:stretch}
|
.ops-toolbar{align-items:stretch}
|
||||||
.search-field,.select-field,.search-field input,.select-field select,.segmented,.icon-text,.primary-btn,.danger-btn{width:100%}
|
.search-field,.select-field,.search-field input,.select-field select,.segmented,.icon-text,.primary-btn,.warn-btn,.danger-btn{width:100%}
|
||||||
.segmented button{flex:1;padding:0 .35rem}
|
.segmented button{flex:1;padding:0 .35rem}
|
||||||
.section-head{flex-direction:column}
|
.section-head{flex-direction:column}
|
||||||
.section-actions{width:100%;justify-content:stretch}
|
.section-actions{width:100%;justify-content:stretch}
|
||||||
|
|||||||
+3
-2
@@ -6,7 +6,7 @@
|
|||||||
<meta name="description" content="云监控,ServerStatus中文版,ServerStatus,ServerStatus cppla" />
|
<meta name="description" content="云监控,ServerStatus中文版,ServerStatus,ServerStatus cppla" />
|
||||||
<title>云监控</title>
|
<title>云监控</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="stylesheet" href="css/app.css?v=20260702-1" />
|
<link rel="stylesheet" href="css/app.css?v=20260706-1" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -177,6 +177,7 @@
|
|||||||
<div id="configFields" class="config-fields"></div>
|
<div id="configFields" class="config-fields"></div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="primary-btn">保存配置</button>
|
<button type="submit" class="primary-btn">保存配置</button>
|
||||||
|
<button type="button" id="resetTrafficBtn" class="warn-btn" style="display:none;">重置月流量</button>
|
||||||
<button type="button" id="deleteConfigItemBtn" class="danger-btn">删除配置</button>
|
<button type="button" id="deleteConfigItemBtn" class="danger-btn">删除配置</button>
|
||||||
<button type="button" id="resetConfigFormBtn" class="icon-text">清空</button>
|
<button type="button" id="resetConfigFormBtn" class="icon-text">清空</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,6 +200,6 @@
|
|||||||
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
|
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="js/app.js?v=20260630-7" defer></script>
|
<script src="js/app.js?v=20260706-1" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -737,6 +737,10 @@ function renderConfigEditor(item){
|
|||||||
$('configEditorTitle').textContent = `${editing ? '编辑' : '新增'}${def.label}`;
|
$('configEditorTitle').textContent = `${editing ? '编辑' : '新增'}${def.label}`;
|
||||||
$('configEditorHint').textContent = def.hint;
|
$('configEditorHint').textContent = def.hint;
|
||||||
$('configFields').innerHTML = def.fields.map(field => fieldHTML(field, current)).join('');
|
$('configFields').innerHTML = def.fields.map(field => fieldHTML(field, current)).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;
|
$('deleteConfigItemBtn').disabled = !editing;
|
||||||
}
|
}
|
||||||
function fieldHTML(field, item){
|
function fieldHTML(field, item){
|
||||||
@@ -810,6 +814,28 @@ async function deleteConfigItem(key, index){
|
|||||||
S.admin.saving = false;
|
S.admin.saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function resetServerTraffic(index){
|
||||||
|
const server = configItems()[index];
|
||||||
|
if(!server) return setAdminStatus('请先选择要重置月流量的节点。', 'err');
|
||||||
|
const title = server.name || server.username || '未命名节点';
|
||||||
|
if(!confirm(`将节点 ${title} 的月流量重置为 0?该操作会重启采集服务以写入当前流量基准。`)) return;
|
||||||
|
S.admin.saving = true;
|
||||||
|
S.suppressStatsReloadUntil = Date.now() + 10000;
|
||||||
|
renderConfigEditor(server);
|
||||||
|
try{
|
||||||
|
const data = await api(`/api/servers/${encodeURIComponent(server.username)}/reset-traffic`, { method:'POST' });
|
||||||
|
const beforeIn = humanMinMBFromB(data.stats?.month_in_before || 0);
|
||||||
|
const beforeOut = humanMinMBFromB(data.stats?.month_out_before || 0);
|
||||||
|
setAdminStatus(`${title} 月流量已重置为 0(重置前 ↓${beforeIn} / ↑${beforeOut})。`, 'ok');
|
||||||
|
setTimeout(fetchData, 1500);
|
||||||
|
}catch(err){
|
||||||
|
setAdminStatus('重置月流量失败:' + err.message, 'err');
|
||||||
|
}finally{
|
||||||
|
S.admin.saving = false;
|
||||||
|
S.suppressStatsReloadUntil = Date.now() + 8000;
|
||||||
|
renderConfigEditor(configItems()[S.admin.selectedIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
function bindAdmin(){
|
function bindAdmin(){
|
||||||
$('adminToken').value = S.admin.token;
|
$('adminToken').value = S.admin.token;
|
||||||
$('adminTokenForm').addEventListener('submit', async e => {
|
$('adminTokenForm').addEventListener('submit', async e => {
|
||||||
@@ -828,6 +854,7 @@ function bindAdmin(){
|
|||||||
});
|
});
|
||||||
$('addConfigItemBtn').addEventListener('click', clearConfigForm);
|
$('addConfigItemBtn').addEventListener('click', clearConfigForm);
|
||||||
$('resetConfigFormBtn').addEventListener('click', clearConfigForm);
|
$('resetConfigFormBtn').addEventListener('click', clearConfigForm);
|
||||||
|
$('resetTrafficBtn').addEventListener('click', () => resetServerTraffic(S.admin.selectedIndex));
|
||||||
$('adminReload').addEventListener('click', async () => {
|
$('adminReload').addEventListener('click', async () => {
|
||||||
try{ await api('/api/reload', { method:'POST' }); setAdminStatus('配置重载已触发。', 'ok'); }
|
try{ await api('/api/reload', { method:'POST' }); setAdminStatus('配置重载已触发。', 'ok'); }
|
||||||
catch(err){ setAdminStatus('重载失败:' + err.message, 'err'); }
|
catch(err){ setAdminStatus('重载失败:' + err.message, 'err'); }
|
||||||
|
|||||||
Reference in New Issue
Block a user