diff --git a/server/entrypoint-server.sh b/server/entrypoint-server.sh index f14a7fd..a49b625 100644 --- a/server/entrypoint-server.sh +++ b/server/entrypoint-server.sh @@ -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" \ diff --git a/server/manage_api.py b/server/manage_api.py index edae951..2d3a9a3 100644 --- a/server/manage_api.py +++ b/server/manage_api.py @@ -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: diff --git a/web/css/app.css b/web/css/app.css index 1f95642..abaaa71 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -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} diff --git a/web/index.html b/web/index.html index eb37077..64c1fff 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@