mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
feat(remote): list only configured services on the dashboard
/api/dashboard/services and the /status count now use serve.services, else the union of serve.users services lists; ?all=1 lists every installed service.
This commit is contained in:
@@ -43,13 +43,13 @@ All are `GET`.
|
||||
|
||||
| Path | Returns |
|
||||
|------|---------|
|
||||
| `/api/dashboard/status` | Version, `code_hash`, bind host and port, `mode` (`full`, `api_only`, `remote_only`), uptime, `requests_total`, `requests_rejected` (401s), `requests_by_key` (label → count), loaded service count, session count, `max_sessions` (`null` when the operator configured no limit), `session_ttl`, job counts by status. The service count includes services that failed to import - use `/api/dashboard/services` for the number that loaded. |
|
||||
| `/api/dashboard/status` | Version, `code_hash`, bind host and port, `mode` (`full`, `api_only`, `remote_only`), uptime, `requests_total`, `requests_rejected` (401s), `requests_by_key` (label → count), configured service count (the same services as `/api/dashboard/services`), session count, `max_sessions` (`null` when the operator configured no limit), `session_ttl`, job counts by status. The service count includes services that failed to import - use `/api/dashboard/services` for the number that loaded. |
|
||||
| `/api/dashboard/sessions` | Every live remote session: `id`, `owner` (username or masked key), `creator_ip`, `service`, `title_id`, `titles`, `tracks`, `title` (the display name of the title the client is on: the last one it asked tracks for, else the first resolved title), `auth_status`, `auth_error`, `server_account` (the server profile lent to the remote session, else `null`), `server_cdm` (`false` when the client's own device licenses the remote session), `server_cdm_max_height` (the tallest video the server CDM licenses live, else `null`), `client`, `actions`, `created_at` and `last_accessed` (ISO 8601) with `created_ts` and `last_accessed_ts` (Unix epoch, the same clock as log `ts`), `age_seconds`, `idle_seconds`, `log_seq`. `client` is whatever the remote client sent as `client` in its session create request. The CLI sends `version`, `code_hash` (the commit the client runs, `null` when its source cannot be read), `platform` and `argv`. `argv` is the command line the user ran: proxy and URL userinfo, secret query parameters and credential values become `***`, home and install paths shorten as in the logs, and the line is cut at 3000 characters. An older client sends less or nothing and the field is `{}`. `log_seq` is the last sequence number in the remote session's service log - poll `/api/dashboard/sessions/{id}/logs` when it changes. `actions` is the session's request log, newest last, capped at 500: `ts`, `method`, `action` (`titles`, `tracks`, `segments`, `license`, `prompt`, …), `query`, `status`, `ms`, `bytes_in`, `bytes_out`. |
|
||||
| `/api/dashboard/jobs` | Every download job with full detail, regardless of owner. Empty in `--remote-only` mode. |
|
||||
| `/api/dashboard/logs` | `{"seq": N, "records": [...]}`: the last 1000 log records. `?since=<seq>` returns only newer records; `?level=WARNING` sets the minimum level; `?logger=serve` keeps one logger and its children (`aiohttp.access` is the noisiest). `ts` is a Unix epoch in seconds. |
|
||||
| `/api/dashboard/sessions/{id}/logs` | One remote session's service log: `{"session_id", "last_seq", "records": [...]}`. Each record has `seq`, `ts`, `level`, `message`. `?since=<seq>` returns only newer records. 404 when the remote session is unknown. |
|
||||
| `/api/dashboard/keys` | Every configured API key: what it may do and what it has done. One row per key in `serve.users`, plus `serve.api_secret` when it is set. |
|
||||
| `/api/dashboard/services` | Every discovered service and its load state, including the ones that failed to import. Not filtered by any allowlist. |
|
||||
| `/api/dashboard/services` | Every service configured on this server and its load state, including the ones that failed to import. See [Services](#services) for what configured means. |
|
||||
| `/api/dashboard/health` | Preflight: whether this instance could finish a download. Cached 30 seconds. |
|
||||
| `/api/dashboard/events` | Server-Sent Events stream. With `?since=<seq>` it returns `{"seq", "stats", "events": [...]}` instead: every event after `seq` (the server keeps the last 20000, log records included, so a poll every few seconds is safe even under a busy access log) as one JSON burst, so a UI that polls every few seconds still sees a remote session that opened and closed in between. |
|
||||
|
||||
@@ -186,6 +186,24 @@ GET /api/dashboard/services
|
||||
"aliases": ["EXMPL"], "geofence": ["US"], "geoblock": []}]
|
||||
```
|
||||
|
||||
The list holds only the services configured on this server:
|
||||
|
||||
1. The global `serve.services` list, when it is set.
|
||||
2. Else, the union of the `services` lists of all keys in `serve.users`.
|
||||
3. Else, every installed service.
|
||||
|
||||
!!! note "The union does not show what every API key can reach"
|
||||
An API key with no `services` list can use every service, but it does not add to the union. The
|
||||
`api_secret` API key is the same. So with `k1: {services: [A]}` and a `k2` with no list, the
|
||||
list shows only `A`. To list every service, remove the `services` lists or set
|
||||
`serve.services`.
|
||||
|
||||
`GET /api/dashboard/services?all=1` lists every installed service. `/status` always counts
|
||||
only the configured services.
|
||||
|
||||
A configured tag that names no installed service does not show. Name a service by its tag: an
|
||||
alias resolves only for a service that imported.
|
||||
|
||||
`state` is `loaded`, `staged` or `failed`. A service that failed to import keeps its error
|
||||
here instead of disappearing, and `/status` still counts it in the service total - count the
|
||||
`state` values here for the number that loaded.
|
||||
|
||||
@@ -24,6 +24,7 @@ pytestmark = pytest.mark.unit
|
||||
def dashboard_cfg(monkeypatch):
|
||||
monkeypatch.setitem(config.serve, "dashboard", {"key": "dash-secret"})
|
||||
monkeypatch.setitem(config.serve, "users", {"tier-key": {"username": "tier1"}})
|
||||
monkeypatch.setitem(config.serve, "services", None)
|
||||
stats.requests_total = 0
|
||||
stats.requests_rejected = 0
|
||||
stats.keys.clear()
|
||||
@@ -266,6 +267,33 @@ async def test_dashboard_services_states(aiohttp_client, dashboard_cfg, monkeypa
|
||||
assert all(r["state"] == "loaded" for t, r in rows.items() if t not in (staged, failed))
|
||||
|
||||
|
||||
async def test_dashboard_services_lists_only_configured(aiohttp_client, dashboard_cfg, monkeypatch) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from unshackle.core import services as services_module
|
||||
|
||||
monkeypatch.setattr(services_module, "SERVICES", [Path(f"/svc/{t}/__init__.py") for t in ("AAA", "BBB", "CCC")])
|
||||
client = await aiohttp_client(make_app())
|
||||
headers = {"X-Secret-Key": "dash-secret"}
|
||||
|
||||
async def listed() -> set[str]:
|
||||
rows = await (await client.get("/api/dashboard/services", headers=headers)).json()
|
||||
count = (await (await client.get("/api/dashboard/status", headers=headers)).json())["services"]
|
||||
assert count == len(rows)
|
||||
return {r["tag"] for r in rows}
|
||||
|
||||
assert await listed() == {"AAA", "BBB", "CCC"}
|
||||
users = {"k1": {"services": ["aaa"]}, "k2": {"services": ["BBB", 1]}, "k3": {"services": "CCC"}, "k4": {}}
|
||||
monkeypatch.setitem(config.serve, "users", users)
|
||||
assert await listed() == {"AAA", "BBB"}
|
||||
monkeypatch.setitem(config.serve, "users", ["not", "a", "mapping"])
|
||||
assert await listed() == {"AAA", "BBB", "CCC"}
|
||||
monkeypatch.setitem(config.serve, "services", ["CCC"])
|
||||
assert await listed() == {"CCC"}
|
||||
everything = await (await client.get("/api/dashboard/services?all=1", headers=headers)).json()
|
||||
assert {r["tag"] for r in everything} == {"AAA", "BBB", "CCC"}
|
||||
|
||||
|
||||
async def test_status_reports_no_session_limit(aiohttp_client, dashboard_cfg, monkeypatch) -> None:
|
||||
monkeypatch.setitem(config.serve, "max_sessions", None)
|
||||
client = await aiohttp_client(make_app())
|
||||
|
||||
@@ -2229,6 +2229,24 @@ async def download_job_events_handler(job_id: str, request: web.Request) -> web.
|
||||
return response
|
||||
|
||||
|
||||
def configured_service_tags() -> List[str]:
|
||||
"""The installed services this server names in its config, for the dashboard.
|
||||
|
||||
The global ``serve.services`` list, else the union of every ``serve.users`` key's ``services``, else every
|
||||
installed service. An API key with no ``services`` list does not widen the union, and a malformed entry is skipped.
|
||||
"""
|
||||
tags = Services.get_tags()
|
||||
allowed = allowed_services_for_key(None)
|
||||
if allowed is None:
|
||||
users = config.serve.get("users")
|
||||
lists = [u.get("services") for u in users.values() if isinstance(u, dict)] if isinstance(users, dict) else []
|
||||
allowed = [Services.get_tag(s) for lst in lists if isinstance(lst, list) for s in lst if isinstance(s, str)]
|
||||
if not allowed:
|
||||
return tags
|
||||
named = set(allowed)
|
||||
return [tag for tag in tags if tag in named]
|
||||
|
||||
|
||||
async def dashboard_status_handler(request: web.Request) -> web.Response:
|
||||
"""Server-wide stats for the developer dashboard."""
|
||||
from unshackle.core.api.download_manager import get_download_manager
|
||||
@@ -2240,7 +2258,7 @@ async def dashboard_status_handler(request: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
**stats.to_dict(),
|
||||
"services": len(Services.get_tags()),
|
||||
"services": len(configured_service_tags()),
|
||||
"sessions": store.session_count,
|
||||
"max_sessions": store.max_sessions,
|
||||
"session_ttl": store.ttl,
|
||||
@@ -2361,10 +2379,7 @@ def user_grant(secret_key: str, name: str) -> Any:
|
||||
|
||||
|
||||
async def dashboard_services_handler(request: web.Request) -> web.Response:
|
||||
"""Every discovered service and its load state, including the ones that failed to import.
|
||||
|
||||
Not allowlist-filtered: an operator needs to see the services their keys cannot reach.
|
||||
"""
|
||||
"""Every service configured on this server, or every installed one with ``?all=1``, and its load state."""
|
||||
from unshackle.core import services as services_module
|
||||
from unshackle.core.api.download_manager import TERMINAL_STATUSES, get_download_manager
|
||||
from unshackle.core.api.session_store import get_session_store
|
||||
@@ -2375,8 +2390,9 @@ async def dashboard_services_handler(request: web.Request) -> web.Response:
|
||||
errors = {err.split(":", 1)[0]: err for err in services_module.LOAD_ERRORS if ":" in err}
|
||||
staged_commits: Dict[Any, Optional[str]] = {}
|
||||
|
||||
tags = Services.get_tags() if request.query.get("all") == "1" else configured_service_tags()
|
||||
rows = []
|
||||
for tag in Services.get_tags():
|
||||
for tag in tags:
|
||||
error = errors.get(tag)
|
||||
staged = tag in services_module.PENDING
|
||||
row: Dict[str, Any] = {
|
||||
|
||||
@@ -2665,19 +2665,31 @@ async def dashboard_keys(request: web.Request) -> web.Response:
|
||||
@api_handler
|
||||
async def dashboard_services(request: web.Request) -> web.Response:
|
||||
"""
|
||||
Dashboard: every discovered service and its load state.
|
||||
Dashboard: every configured service and its load state.
|
||||
---
|
||||
summary: Dashboard services
|
||||
description: >
|
||||
Unlike `/api/services` this is not filtered by any allowlist and it keeps the services
|
||||
that failed to import, with their error. `state` is `loaded`, `staged` (an update is on
|
||||
Shows only the services configured on this server: the global `serve.services`
|
||||
allowlist, else the union of every `serve.users` key's `services`, else every discovered
|
||||
service. An API key with no `services` list does not widen the union, so the list can be
|
||||
smaller than what an unrestricted API key can reach. `?all=1` lists every installed
|
||||
service instead. Unlike `/api/services` it keeps the
|
||||
services that failed to import, with their error. `state` is `loaded`, `staged` (an update is on
|
||||
disk but a busy service blocks the re-import) or `failed`.
|
||||
A staged service applies when its last job finishes; watch the `service` event on
|
||||
`/api/dashboard/events` instead of polling for it.
|
||||
tags: [Dashboard]
|
||||
parameters:
|
||||
- name: all
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: ["1"]
|
||||
description: List every installed service, not only the configured ones
|
||||
responses:
|
||||
'200':
|
||||
description: One row per discovered service
|
||||
description: One row per listed service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
Reference in New Issue
Block a user