feat: compress shusub2 polling
This commit is contained in:
@@ -83,9 +83,11 @@ Environment variables override the config file:
|
|||||||
|
|
||||||
`shusub2` opens a single dashboard with Accounts, Keys, Logs, and Errors
|
`shusub2` opens a single dashboard with Accounts, Keys, Logs, and Errors
|
||||||
tables stacked vertically. Press `a`, `k`, `l`, or `e` to focus a table, `/` to
|
tables stacked vertically. Press `a`, `k`, `l`, or `e` to focus a table, `/` to
|
||||||
filter all four tables, and `r` to refresh all data. The two-line detail area
|
filter all four tables, and `r` to refresh all data immediately. Automatic Accounts,
|
||||||
follows the selected row. Accounts, Keys, Logs, and Errors use green, yellow,
|
Keys/Logs, and Errors refresh defaults to every five minutes. The client requests
|
||||||
magenta, and red section styling respectively.
|
gzip-compressed JSON and transparently decodes it when the upstream supports it.
|
||||||
|
The two-line detail area follows the selected row. Accounts, Keys, Logs, and
|
||||||
|
Errors use green, yellow, magenta, and red section styling respectively.
|
||||||
|
|
||||||
The dashboard sizes every column from the fetched content so wide terminals show
|
The dashboard sizes every column from the fetched content so wide terminals show
|
||||||
complete keys, accounts, and models. On a narrow terminal, a table scrolls
|
complete keys, accounts, and models. On a narrow terminal, a table scrolls
|
||||||
@@ -112,7 +114,7 @@ The middle dashboard table shows request logs, and `l` focuses it.
|
|||||||
`shusub2 --once --logs` prints one logs snapshot to stdout.
|
`shusub2 --once --logs` prints one logs snapshot to stdout.
|
||||||
|
|
||||||
The logs page reads the latest requests (default 100, `--logs-limit`) from the
|
The logs page reads the latest requests (default 100, `--logs-limit`) from the
|
||||||
Sub2API admin usage API and refreshes every 60 seconds by default
|
Sub2API admin usage API and refreshes every five minutes by default
|
||||||
(`--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`). Columns:
|
(`--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`). Columns:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -168,7 +170,7 @@ parallel, then merges them by `created_at`:
|
|||||||
backend, but Cloudflare often returns 1010 for non-browser clients, so the TUI
|
backend, but Cloudflare often returns 1010 for non-browser clients, so the TUI
|
||||||
defaults to the fixed server4 origin while still labeling rows as `us`.
|
defaults to the fixed server4 origin while still labeling rows as `us`.
|
||||||
|
|
||||||
The dashboard and dedicated errors page refresh every 60 seconds by default
|
The dashboard and dedicated errors page refresh every five minutes by default
|
||||||
(`--errors-refresh-seconds` / `SHUSUB2_ERRORS_REFRESH_SECONDS`). Query window
|
(`--errors-refresh-seconds` / `SHUSUB2_ERRORS_REFRESH_SECONDS`). Query window
|
||||||
defaults to `24h` (`--errors-time-range` / `SHUSUB2_ERRORS_TIME_RANGE`); each source uses
|
defaults to `24h` (`--errors-time-range` / `SHUSUB2_ERRORS_TIME_RANGE`); each source uses
|
||||||
`page=1` and `page_size` from `--errors-limit` (default 100, max 500).
|
`page=1` and `page_size` from `--errors-limit` (default 100, max 500).
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "shusub2"
|
name = "shusub2"
|
||||||
version = "0.2.10"
|
version = "0.2.11"
|
||||||
description = "Terminal UI for Sub2API account quota and daily usage"
|
description = "Terminal UI for Sub2API account quota and daily usage"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+27
-24
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import gzip
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -19,7 +20,7 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
APP_NAME = "shusub2"
|
APP_NAME = "shusub2"
|
||||||
FALLBACK_VERSION = "0.2.10"
|
FALLBACK_VERSION = "0.2.11"
|
||||||
DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts"
|
DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts"
|
||||||
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
|
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
|
||||||
DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-url"
|
DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-url"
|
||||||
@@ -33,9 +34,9 @@ DEFAULT_ERRORS_US_URL = "https://sub2api.server4.shujk.top:19857/api/v1/admin/op
|
|||||||
DEFAULT_ERRORS_CN_URL_CONFIG_FILE = "~/.config/shusub2/errors-cn-url"
|
DEFAULT_ERRORS_CN_URL_CONFIG_FILE = "~/.config/shusub2/errors-cn-url"
|
||||||
DEFAULT_ERRORS_US_URL_CONFIG_FILE = "~/.config/shusub2/errors-us-url"
|
DEFAULT_ERRORS_US_URL_CONFIG_FILE = "~/.config/shusub2/errors-us-url"
|
||||||
DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/shusub2/raw/branch/main/pyproject.toml"
|
DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/shusub2/raw/branch/main/pyproject.toml"
|
||||||
DEFAULT_REFRESH_SECONDS = 60
|
DEFAULT_REFRESH_SECONDS = 300
|
||||||
DEFAULT_LOGS_REFRESH_SECONDS = 60
|
DEFAULT_LOGS_REFRESH_SECONDS = 300
|
||||||
DEFAULT_ERRORS_REFRESH_SECONDS = 60
|
DEFAULT_ERRORS_REFRESH_SECONDS = 300
|
||||||
DEFAULT_LOGS_LIMIT = 100
|
DEFAULT_LOGS_LIMIT = 100
|
||||||
DEFAULT_ERRORS_LIMIT = 100
|
DEFAULT_ERRORS_LIMIT = 100
|
||||||
DEFAULT_ERRORS_TIME_RANGE = "24h"
|
DEFAULT_ERRORS_TIME_RANGE = "24h"
|
||||||
@@ -332,15 +333,26 @@ def add_refresh_param(api_url: str, refresh: bool) -> str:
|
|||||||
return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query)))
|
return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query)))
|
||||||
|
|
||||||
|
|
||||||
def fetch_payload(api_url: str, timeout: int, *, refresh: bool = False) -> dict[str, Any]:
|
def decode_json_response(response: Any, error_message: str) -> dict[str, Any]:
|
||||||
req = urllib.request.Request(add_refresh_param(api_url, refresh), headers={"Accept": "application/json"})
|
raw = response.read()
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
content_encoding = str(response.headers.get("Content-Encoding", "")).lower()
|
||||||
data = json.loads(response.read().decode("utf-8"))
|
if "gzip" in {value.strip() for value in content_encoding.split(",")}:
|
||||||
|
raw = gzip.decompress(raw)
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise RuntimeError("API did not return a JSON object")
|
raise RuntimeError(error_message)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_payload(api_url: str, timeout: int, *, refresh: bool = False) -> dict[str, Any]:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
add_refresh_param(api_url, refresh),
|
||||||
|
headers={"Accept": "application/json", "Accept-Encoding": "gzip"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
|
return decode_json_response(response, "API did not return a JSON object")
|
||||||
|
|
||||||
|
|
||||||
def fetch_optional_payload(url: str, timeout: int) -> tuple[dict[str, Any], str]:
|
def fetch_optional_payload(url: str, timeout: int) -> tuple[dict[str, Any], str]:
|
||||||
if not str(url or "").strip():
|
if not str(url or "").strip():
|
||||||
return {}, ""
|
return {}, ""
|
||||||
@@ -359,15 +371,12 @@ def logs_request_url(logs_url: str, limit: int) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_logs_payload(logs_url: str, token: str, timeout: int, limit: int = DEFAULT_LOGS_LIMIT) -> dict[str, Any]:
|
def fetch_logs_payload(logs_url: str, token: str, timeout: int, limit: int = DEFAULT_LOGS_LIMIT) -> dict[str, Any]:
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json", "Accept-Encoding": "gzip"}
|
||||||
if str(token or "").strip():
|
if str(token or "").strip():
|
||||||
headers["x-api-key"] = str(token).strip()
|
headers["x-api-key"] = str(token).strip()
|
||||||
req = urllib.request.Request(logs_request_url(logs_url, limit), headers=headers)
|
req = urllib.request.Request(logs_request_url(logs_url, limit), headers=headers)
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
data = json.loads(response.read().decode("utf-8"))
|
return decode_json_response(response, "logs API did not return a JSON object")
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise RuntimeError("logs API did not return a JSON object")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def logs_envelope(payload: dict[str, Any]) -> dict[str, Any]:
|
def logs_envelope(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -465,7 +474,7 @@ def admin_api_base(logs_url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_admin_json(url: str, token: str, timeout: int, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
def fetch_admin_json(url: str, token: str, timeout: int, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json", "Accept-Encoding": "gzip"}
|
||||||
if str(token or "").strip():
|
if str(token or "").strip():
|
||||||
headers["x-api-key"] = str(token).strip()
|
headers["x-api-key"] = str(token).strip()
|
||||||
data = None
|
data = None
|
||||||
@@ -474,10 +483,7 @@ def fetch_admin_json(url: str, token: str, timeout: int, body: dict[str, Any] |
|
|||||||
data = json.dumps(body).encode("utf-8")
|
data = json.dumps(body).encode("utf-8")
|
||||||
req = urllib.request.Request(url, data=data, headers=headers)
|
req = urllib.request.Request(url, data=data, headers=headers)
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
payload = json.loads(response.read().decode("utf-8"))
|
return decode_json_response(response, "admin API did not return a JSON object")
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise RuntimeError("admin API did not return a JSON object")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def payload_data(payload: dict[str, Any]) -> dict[str, Any]:
|
def payload_data(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -683,15 +689,12 @@ def fetch_errors_payload(
|
|||||||
limit: int = DEFAULT_ERRORS_LIMIT,
|
limit: int = DEFAULT_ERRORS_LIMIT,
|
||||||
time_range: str = DEFAULT_ERRORS_TIME_RANGE,
|
time_range: str = DEFAULT_ERRORS_TIME_RANGE,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json", "Accept-Encoding": "gzip"}
|
||||||
if str(token or "").strip():
|
if str(token or "").strip():
|
||||||
headers["x-api-key"] = str(token).strip()
|
headers["x-api-key"] = str(token).strip()
|
||||||
req = urllib.request.Request(errors_request_url(errors_url, limit, time_range), headers=headers)
|
req = urllib.request.Request(errors_request_url(errors_url, limit, time_range), headers=headers)
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
data = json.loads(response.read().decode("utf-8"))
|
return decode_json_response(response, "errors API did not return a JSON object")
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise RuntimeError("errors API did not return a JSON object")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def error_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
def error_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -105,6 +105,51 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
|||||||
self.assertEqual(mod.version_update_message("0.1.7", "0.1.7"), "")
|
self.assertEqual(mod.version_update_message("0.1.7", "0.1.7"), "")
|
||||||
self.assertEqual(mod.version_update_message("0.1.6", "0.1.7"), "")
|
self.assertEqual(mod.version_update_message("0.1.6", "0.1.7"), "")
|
||||||
|
|
||||||
|
def test_gzip_json_requests_decode_responses_and_request_compression(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
payload = {"ok": True}
|
||||||
|
compressed = mod.gzip.compress(mod.json.dumps(payload).encode("utf-8"))
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
headers = {"Content-Encoding": "gzip"}
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, traceback):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return compressed
|
||||||
|
|
||||||
|
old_urlopen = mod.urllib.request.urlopen
|
||||||
|
mod.urllib.request.urlopen = lambda request, timeout: (requests.append(request) or FakeResponse())
|
||||||
|
try:
|
||||||
|
self.assertEqual(mod.fetch_payload("https://example.test/accounts", 1), payload)
|
||||||
|
self.assertEqual(mod.fetch_logs_payload("https://example.test/usage", "test-token", 1), payload)
|
||||||
|
self.assertEqual(mod.fetch_admin_json("https://example.test/admin", "test-token", 1, body={"ids": [1]}), payload)
|
||||||
|
self.assertEqual(mod.fetch_errors_payload("https://example.test/errors", "test-token", 1), payload)
|
||||||
|
finally:
|
||||||
|
mod.urllib.request.urlopen = old_urlopen
|
||||||
|
|
||||||
|
class PlainResponse:
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return mod.json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(mod.decode_json_response(PlainResponse(), "plain JSON failed"), payload)
|
||||||
|
self.assertEqual(len(requests), 4)
|
||||||
|
self.assertTrue(all(request.get_header("Accept-encoding") == "gzip" for request in requests))
|
||||||
|
|
||||||
|
def test_default_refresh_intervals_are_five_minutes(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
|
||||||
|
self.assertEqual(mod.DEFAULT_REFRESH_SECONDS, 300)
|
||||||
|
self.assertEqual(mod.DEFAULT_LOGS_REFRESH_SECONDS, 300)
|
||||||
|
self.assertEqual(mod.DEFAULT_ERRORS_REFRESH_SECONDS, 300)
|
||||||
|
|
||||||
def test_write_api_url_config_uses_config_file_env(self) -> None:
|
def test_write_api_url_config_uses_config_file_env(self) -> None:
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user