增加月流量重置按钮
CI / validate (push) Has been cancelled

This commit is contained in:
cppla
2026-07-06 14:02:12 +08:00
parent 8e4590699b
commit e8e90275a2
5 changed files with 171 additions and 4 deletions
+1
View File
@@ -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" \
+136
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+27
View File
@@ -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'); }