mirror of
https://github.com//cppla/ServerStatus
synced 2026-08-06 08:13:56 +08:00
@@ -28,6 +28,7 @@ mkdir -p "$WEB_DIR/json"
|
||||
nginx
|
||||
|
||||
CONFIG_PATH="$CONFIG_PATH" \
|
||||
STATS_PATH="${STATS_PATH:-$WEB_DIR/json/stats.json}" \
|
||||
SERGATE_PID_FILE="$SERGATE_PID_FILE" \
|
||||
ADMIN_API_BIND="$ADMIN_API_BIND" \
|
||||
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")
|
||||
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")
|
||||
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")
|
||||
API_BIND = os.environ.get("ADMIN_API_BIND", "127.0.0.1")
|
||||
@@ -31,6 +32,36 @@ def load_config():
|
||||
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):
|
||||
missing = [field for field in required if item.get(field) is None or not str(item.get(field, "")).strip()]
|
||||
if missing:
|
||||
@@ -238,6 +269,27 @@ def signal_sergate(sig):
|
||||
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):
|
||||
servers = config.get("servers", [])
|
||||
for index, server in enumerate(servers):
|
||||
@@ -262,6 +314,81 @@ def find_collection_item(config, key, item_id):
|
||||
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():
|
||||
endpoints = []
|
||||
for key in ["monitors", "sslcerts", "watchdog"]:
|
||||
@@ -290,6 +417,7 @@ def api_schema():
|
||||
{"method": "POST", "path": "/api/servers", "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": "POST", "path": "/api/servers/{username}/reset-traffic", "auth": True},
|
||||
*collection_routes(),
|
||||
{"method": "POST", "path": "/api/reload", "auth": True},
|
||||
{"method": "POST", "path": "/api/restart", "auth": True},
|
||||
@@ -379,6 +507,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
config, pid = write_and_reload(config)
|
||||
self.send_json(201, {"ok": True, "server": server, "reloaded": True, "pid": pid, "config": config})
|
||||
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/"):
|
||||
username = unquote(path[len("/api/servers/"):])
|
||||
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:last-child{border-right:0}
|
||||
.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)}
|
||||
.primary-btn{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
.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:hover{background:var(--danger);color:#fff}
|
||||
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){
|
||||
.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}
|
||||
.section-head{flex-direction:column}
|
||||
.section-actions{width:100%;justify-content:stretch}
|
||||
|
||||
+3
-2
@@ -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=20260702-1" />
|
||||
<link rel="stylesheet" href="css/app.css?v=20260706-1" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -177,6 +177,7 @@
|
||||
<div id="configFields" class="config-fields"></div>
|
||||
<div class="form-actions">
|
||||
<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="resetConfigFormBtn" class="icon-text">清空</button>
|
||||
</div>
|
||||
@@ -199,6 +200,6 @@
|
||||
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
|
||||
</footer>
|
||||
|
||||
<script src="js/app.js?v=20260630-7" defer></script>
|
||||
<script src="js/app.js?v=20260706-1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -737,6 +737,10 @@ function renderConfigEditor(item){
|
||||
$('configEditorTitle').textContent = `${editing ? '编辑' : '新增'}${def.label}`;
|
||||
$('configEditorHint').textContent = def.hint;
|
||||
$('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;
|
||||
}
|
||||
function fieldHTML(field, item){
|
||||
@@ -810,6 +814,28 @@ async function deleteConfigItem(key, index){
|
||||
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(){
|
||||
$('adminToken').value = S.admin.token;
|
||||
$('adminTokenForm').addEventListener('submit', async e => {
|
||||
@@ -828,6 +854,7 @@ function bindAdmin(){
|
||||
});
|
||||
$('addConfigItemBtn').addEventListener('click', clearConfigForm);
|
||||
$('resetConfigFormBtn').addEventListener('click', clearConfigForm);
|
||||
$('resetTrafficBtn').addEventListener('click', () => resetServerTraffic(S.admin.selectedIndex));
|
||||
$('adminReload').addEventListener('click', async () => {
|
||||
try{ await api('/api/reload', { method:'POST' }); setAdminStatus('配置重载已触发。', 'ok'); }
|
||||
catch(err){ setAdminStatus('重载失败:' + err.message, 'err'); }
|
||||
|
||||
Reference in New Issue
Block a user