From 48bb6c2ec265f7c8cbee86db9f74663e6b606695 Mon Sep 17 00:00:00 2001 From: retoor Date: Thu, 9 Jul 2026 02:52:54 +0200 Subject: [PATCH] Update --- CLAUDE.md | 5 +- devplacepy/awards/__init__.py | 0 devplacepy/awards/images.py | 33 + devplacepy/config.py | 15 + devplacepy/curl_transport.py | 10 +- devplacepy/database/CLAUDE.md | 2 +- devplacepy/database/__init__.py | 19 +- devplacepy/database/awards.py | 193 +++ devplacepy/database/content.py | 6 + devplacepy/database/core.py | 45 +- devplacepy/database/notifications.py | 1 + devplacepy/database/schema.py | 146 ++ devplacepy/database/soft_delete.py | 1 + devplacepy/database/usage.py | 11 + devplacepy/docs_api/groups/admin.py | 94 ++ devplacepy/docs_api/groups/gateway.py | 56 +- devplacepy/docs_api/groups/profiles.py | 69 +- devplacepy/main.py | 19 + devplacepy/models.py | 17 + devplacepy/routers/admin/__init__.py | 4 + devplacepy/routers/admin/awards.py | 49 + devplacepy/routers/admin/gateway_configs.py | 2 + devplacepy/routers/admin/statistics.py | 90 ++ devplacepy/routers/admin/trash.py | 19 +- devplacepy/routers/awards.py | 36 + devplacepy/routers/docs/pages.py | 6 + devplacepy/routers/profile/__init__.py | 2 + devplacepy/routers/profile/award.py | 106 ++ devplacepy/routers/profile/index.py | 21 + devplacepy/schemas/__init__.py | 1 + devplacepy/schemas/awards.py | 19 + devplacepy/schemas/profile.py | 6 + devplacepy/schemas/statistics.py | 52 + devplacepy/services/audit/categories.py | 1 + devplacepy/services/background.py | 10 + .../services/devii/actions/catalog/admin.py | 31 + .../services/devii/actions/catalog/gateway.py | 28 +- .../services/devii/actions/catalog/profile.py | 15 + .../devii/actions/notification_actions.py | 2 +- devplacepy/services/devii/fetch/controller.py | 97 +- devplacepy/services/jobs/award_service.py | 207 +++ devplacepy/services/openai_gateway/CLAUDE.md | 20 +- devplacepy/services/openai_gateway/config.py | 4 + devplacepy/services/openai_gateway/gateway.py | 174 ++- devplacepy/services/openai_gateway/routing.py | 291 +++- devplacepy/services/openai_gateway/service.py | 67 + devplacepy/services/openai_gateway/usage.py | 168 ++- devplacepy/services/presence_relay.py | 3 + devplacepy/services/statistics/__init__.py | 1 + devplacepy/services/statistics/build.py | 77 + devplacepy/services/statistics/common.py | 169 +++ devplacepy/services/statistics/tabs_data.py | 1288 +++++++++++++++++ devplacepy/services/statistics/tracking.py | 207 +++ devplacepy/static/css/admin.css | 24 + devplacepy/static/css/awards.css | 107 ++ devplacepy/static/css/base.css | 58 +- devplacepy/static/css/gateway.css | 23 + devplacepy/static/css/profile.css | 13 +- devplacepy/static/css/statistics.css | 229 +++ devplacepy/static/js/Application.js | 2 + devplacepy/static/js/AwardGiver.js | 66 + devplacepy/static/js/GatewayAdmin.js | 76 +- devplacepy/static/js/LocalTime.js | 7 + devplacepy/static/js/OnlineUsers.js | 8 + devplacepy/static/js/OverflowTabs.js | 115 ++ devplacepy/static/js/ProfileTabs.js | 64 - devplacepy/static/js/StatisticsCharts.js | 111 ++ devplacepy/static/js/StatisticsDashboard.js | 327 +++++ .../static/vendor/chartjs/chart.umd.min.js | 20 + devplacepy/stealth.py | 47 +- devplacepy/templates/_avatar_link.html | 1 + devplacepy/templates/_award_badge.html | 3 + devplacepy/templates/_awards_gallery.html | 31 + devplacepy/templates/admin_base.html | 3 + devplacepy/templates/admin_gateway.html | 30 +- devplacepy/templates/admin_settings.html | 6 + devplacepy/templates/admin_statistics.html | 58 + devplacepy/templates/admin_trash.html | 15 +- devplacepy/templates/base.html | 4 +- devplacepy/templates/docs/awards.html | 26 + .../templates/docs/notification-settings.html | 3 +- .../templates/docs/services-gateway.html | 22 +- devplacepy/templates/feed.html | 2 +- devplacepy/templates/messages.html | 2 +- devplacepy/templates/profile.html | 89 +- devplacepy/templating.py | 25 + tests/api/admin/gateway/models.py | 70 + tests/api/admin/trash/restore.py | 41 + tests/e2e/admin/awards.py | 69 + tests/e2e/notification/prefs.py | 26 + tests/e2e/profile/awards.py | 109 ++ tests/unit/services/openai_gateway/gateway.py | 134 ++ tests/unit/services/openai_gateway/routing.py | 140 ++ tests/unit/services/openai_gateway/usage.py | 130 ++ tests/unit/stealth.py | 31 +- 95 files changed, 6115 insertions(+), 267 deletions(-) create mode 100644 devplacepy/awards/__init__.py create mode 100644 devplacepy/awards/images.py create mode 100644 devplacepy/database/awards.py create mode 100644 devplacepy/routers/admin/awards.py create mode 100644 devplacepy/routers/admin/statistics.py create mode 100644 devplacepy/routers/awards.py create mode 100644 devplacepy/routers/profile/award.py create mode 100644 devplacepy/schemas/awards.py create mode 100644 devplacepy/schemas/statistics.py create mode 100644 devplacepy/services/jobs/award_service.py create mode 100644 devplacepy/services/statistics/__init__.py create mode 100644 devplacepy/services/statistics/build.py create mode 100644 devplacepy/services/statistics/common.py create mode 100644 devplacepy/services/statistics/tabs_data.py create mode 100644 devplacepy/services/statistics/tracking.py create mode 100644 devplacepy/static/css/awards.css create mode 100644 devplacepy/static/css/statistics.css create mode 100644 devplacepy/static/js/AwardGiver.js create mode 100644 devplacepy/static/js/OverflowTabs.js delete mode 100644 devplacepy/static/js/ProfileTabs.js create mode 100644 devplacepy/static/js/StatisticsCharts.js create mode 100644 devplacepy/static/js/StatisticsDashboard.js create mode 100644 devplacepy/static/vendor/chartjs/chart.umd.min.js create mode 100644 devplacepy/templates/_award_badge.html create mode 100644 devplacepy/templates/_awards_gallery.html create mode 100644 devplacepy/templates/admin_statistics.html create mode 100644 devplacepy/templates/docs/awards.html create mode 100644 tests/e2e/admin/awards.py create mode 100644 tests/e2e/profile/awards.py diff --git a/CLAUDE.md b/CLAUDE.md index 0be84a3a..904773af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ This file provides guidance to Claude Code when working with code in this repository. It holds only what applies regardless of which part of the codebase is being touched. Deep, subsystem-specific detail lives in nested `CLAUDE.md` files placed inside the relevant directory - Claude Code auto-loads a nested file only when a file under that directory is read or edited, so the always-loaded cost of this repository stays proportional to this file alone. See "Subsystem map" below for the full list. +It is a big project, whatever you are implementing, it is probably done before. You should look it up and match the implementation structurely and visually. For inconsistency there is zero tolerance policy. Develop dry, kiss, re-usable code, consistent with existing implementation. Literally always try to find relatable examples before making a modification. If no-example exists, explain to user what is the case and let user decide what to do and how to continue. + ## Project DevPlace is a server-rendered social network for developers. FastAPI backend serves Jinja2 templates with pure ES6 module JavaScript on the frontend. SQLite via the `dataset` library (auto-syncs schema). No JS framework, no NPM, no JWT. @@ -99,6 +101,7 @@ Tests run on port 10501 with a tempfile SQLite DB and a dedicated `DEVPLACE_DATA | `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Max avatars in the feed's live "Online now" panel. | | `DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS` | `20` | Grace margin (hysteresis) before an online user is dropped, kills dot/roster flicker at the boundary. | | `DEVPLACE_DATA_DIR` | `/data` | Single root for ALL runtime/user-generated data OUTSIDE the package and OUTSIDE `/static`. Point at a volume in prod. | +| `DEVPLACE_OUTBOUND_PROXY_URL` | unset | Fallback for the `outbound_proxy_url` site setting (below) when the DB/settings row is unavailable (early CLI contexts). Prefer configuring the setting via `/admin/settings` - it applies live with no restart. | ## Subsystem map @@ -235,7 +238,7 @@ Devii is also reachable over **Telegram** (one supervised long-poller subprocess - **No comments, no docstrings in source.** Code is self-documenting. - **Forbidden variable name patterns:** `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_`. -- **Outbound HTTP goes through the stealth client only.** Never instantiate a bare `httpx.AsyncClient(...)`/`httpx.Client(...)` for outbound traffic - build it via `devplacepy.stealth.stealth_async_client(...)`/`stealth_sync_client(...)`. The async factory routes bytes through **`curl_cffi` (curl-impersonate, BoringSSL)** behind an `httpx` transport adapter, so TLS JA3/JA4 and the HTTP/2 fingerprint match Chrome 146 byte-for-byte; falls back to a pure-httpx Chrome-aligned transport if `curl_cffi` is missing. Applies to news, gateway upstream, Devii/DeepSearch/SEO fetch, gitea, push, attachments. SSRF-guarded fetches via `net_guard.guarded_async_client(...)` get the fingerprint automatically. Sole exception: `routers/proxy.py` relays the user's own headers verbatim. **Load-bearing gotcha:** cleartext `http://` is forced to HTTP/1.1 (`curl_transport.http_version_for`) - the Chrome-impersonation profile defaults to HTTP/2, but a plaintext origin has no TLS/ALPN to negotiate h2 and uvicorn (HTTP/1.1 only) returns `400 "Invalid HTTP request received."` once a cleartext HTTP/2 body crosses ~128KB. Every internal AI consumer calls `INTERNAL_GATEWAY_URL` = `http://localhost:/openai/v1/...`, so without this override a large Devii turn (big system prompt + tool list) fails on every request. `https://` keeps the HTTP/2 default. `stealth_sync_client` stays pure-httpx Chrome-aligned (no curl_cffi sync adapter). +- **Outbound HTTP goes through the stealth client only.** Never instantiate a bare `httpx.AsyncClient(...)`/`httpx.Client(...)` for outbound traffic - build it via `devplacepy.stealth.stealth_async_client(...)`/`stealth_sync_client(...)`. The async factory routes bytes through **`curl_cffi` (curl-impersonate, BoringSSL)** behind an `httpx` transport adapter, so TLS JA3/JA4 and the HTTP/2 fingerprint match Chrome 146 byte-for-byte; falls back to a pure-httpx Chrome-aligned transport if `curl_cffi` is missing. Applies to news, gateway upstream, Devii/DeepSearch/SEO fetch, gitea, push, attachments. SSRF-guarded fetches via `net_guard.guarded_async_client(...)` get the fingerprint automatically. Sole exception: `routers/proxy.py` relays the user's own headers verbatim. **Load-bearing gotcha:** cleartext `http://` is forced to HTTP/1.1 (`curl_transport.http_version_for`) - the Chrome-impersonation profile defaults to HTTP/2, but a plaintext origin has no TLS/ALPN to negotiate h2 and uvicorn (HTTP/1.1 only) returns `400 "Invalid HTTP request received."` once a cleartext HTTP/2 body crosses ~128KB. Every internal AI consumer calls `INTERNAL_GATEWAY_URL` = `http://localhost:/openai/v1/...`, so without this override a large Devii turn (big system prompt + tool list) fails on every request. `https://` keeps the HTTP/2 default. `stealth_sync_client` stays pure-httpx Chrome-aligned (no curl_cffi sync adapter). **Never bolt a hand-written browser-header dict (UA/`sec-ch-ua`/`Sec-Fetch-*`) onto a `stealth_async_client`/`stealth_sync_client` call** - curl_cffi's impersonation already emits an internally-consistent Chrome 146 identity across TLS, UA, and Client Hints; a caller-supplied header set claiming a different Chrome version or platform creates a UA-vs-TLS-vs-Client-Hints mismatch that is a textbook anti-bot tripwire (this was a real bug in Devii's `fetch_url`/`http_request` tool, fixed by deleting its hardcoded Chrome-131 header block). `stealth.detect_consent_gate(html)` recognizes the common JS-redirect cookie-consent wall pattern (DPG Media's `nu.nl` and sibling sites, and generically any page gated behind a `decodeURIComponent(...)` callback referencing a "privacy gate"/`consent.js`) - reuse it wherever a fetched page's real content sits behind that gate rather than special-casing a domain. `stealth_transport`/`stealth_async_client`/`stealth_sync_client` all accept a `proxy=` override, defaulting to `stealth.configured_proxy_url()` (the admin-editable `outbound_proxy_url` site setting, falling back to `DEVPLACE_OUTBOUND_PROXY_URL` when the setting is unset) - this is the only real fix for a destination that blocks by IP/ASN reputation (e.g. reddit.com returns an identical "blocked by network security" page across every TLS/header fingerprint tried, confirmed by testing curl_cffi's full impersonation list - it is blocking the datacenter ASN outright, not fingerprinting the client). - **Form validation is Pydantic-native.** Routers declare `data: Annotated[SomeForm, Form()]` (models in `models.py`); the global `RequestValidationError` handler re-renders auth pages with messages (400) or redirects other routes. Read raw `await request.form()` only when also reading an uploaded file alongside a model. - **`RedirectResponse(url=..., status_code=302)`** - always pass `status_code` as an integer literal, never `status.HTTP_302_FOUND`. - **Ownership checks:** compare `resource["user_uid"] == user["uid"]` before edit/delete (use `content.is_owner`). **Deletes additionally allow any admin:** `is_owner(...) or is_admin(user)` on every content delete endpoint, so an administrator may delete any member's content while members are limited to their own. Edits stay owner-only. Deletes are **soft** and cascade under one shared stamp - see `devplacepy/database/CLAUDE.md`. diff --git a/devplacepy/awards/__init__.py b/devplacepy/awards/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/devplacepy/awards/images.py b/devplacepy/awards/images.py new file mode 100644 index 00000000..bce5069f --- /dev/null +++ b/devplacepy/awards/images.py @@ -0,0 +1,33 @@ +# retoor + +from io import BytesIO + +from PIL import Image + + +def enforce_rgba_png(file_bytes: bytes) -> bytes: + img = Image.open(BytesIO(file_bytes)).convert("RGBA") + width, height = img.size + if width > 1 and height > 1: + corner = img.getpixel((0, 0)) + if len(corner) == 4 and corner[3] == 255: + bg = corner[:3] + data = img.getdata() + cleaned = [] + for pixel in data: + if pixel[:3] == bg: + cleaned.append((pixel[0], pixel[1], pixel[2], 0)) + else: + cleaned.append(pixel) + img.putdata(cleaned) + buf = BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +def resize_award_png(source: bytes, size: int) -> bytes: + img = Image.open(BytesIO(source)).convert("RGBA") + img = img.resize((size, size), Image.LANCZOS) + buf = BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() \ No newline at end of file diff --git a/devplacepy/config.py b/devplacepy/config.py index 651f54e0..57d68293 100644 --- a/devplacepy/config.py +++ b/devplacepy/config.py @@ -68,6 +68,21 @@ INTERNAL_GATEWAY_URL = f"{INTERNAL_BASE_URL}/openai/v1/chat/completions" INTERNAL_EMBED_URL = f"{INTERNAL_BASE_URL}/openai/v1/embeddings" INTERNAL_MODEL = "molodetz" INTERNAL_EMBED_MODEL = "molodetz~embed" +INTERNAL_IMAGE_MODEL = "molodetz-img-small" + +AWARD_GIVE_COOLDOWN_HOURS_DEFAULT = 24 +AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT = 24 +AWARD_DISPLAY_HOURS_DEFAULT = 24 +AWARD_DESCRIPTION_MAX = 125 +AWARD_IMAGE_MODEL_DEFAULT = "molodetz-img-small" +AWARD_IMAGE_SIZE_DEFAULT = "512x512" +AWARD_GENERATION_TIMEOUT_SECONDS = 120.0 +AWARD_IMAGE_PROMPT_DEFAULT = ( + "Generate a single decorative developer award emblem/badge as a PNG with a fully " + "transparent background (alpha channel). No rectangular backdrop, no drop shadow " + "plate, no text labels rendered in the image. Center one stylized trophy/medal " + "icon that visually matches this message:" +) DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing" DEFAULT_MODIFIER_PROMPT = ( "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`" diff --git a/devplacepy/curl_transport.py b/devplacepy/curl_transport.py index 92f2cb83..0168cf72 100644 --- a/devplacepy/curl_transport.py +++ b/devplacepy/curl_transport.py @@ -73,10 +73,17 @@ class CurlResponseStream(httpx.AsyncByteStream): class CurlTransport(httpx.AsyncBaseTransport): - def __init__(self, *, impersonate: str = IMPERSONATE_TARGET, verify: bool = True) -> None: + def __init__( + self, + *, + impersonate: str = IMPERSONATE_TARGET, + verify: bool = True, + proxy: str | None = None, + ) -> None: self._session = AsyncSession() self._impersonate = impersonate self._verify = verify + self._proxy = proxy async def handle_async_request(self, request: httpx.Request) -> httpx.Response: headers = { @@ -97,6 +104,7 @@ class CurlTransport(httpx.AsyncBaseTransport): data=body or None, impersonate=self._impersonate, verify=self._verify, + proxy=self._proxy, stream=True, allow_redirects=False, timeout=resolve_timeout(request), diff --git a/devplacepy/database/CLAUDE.md b/devplacepy/database/CLAUDE.md index 9b5153b7..c85eba1b 100644 --- a/devplacepy/database/CLAUDE.md +++ b/devplacepy/database/CLAUDE.md @@ -180,7 +180,7 @@ Site settings are seeded on startup (`site_settings` table): | `customization_js_enabled` | `"1"` | When `"0"`, custom CSS still serves but custom JS is suppressed | | `extra_head` | `""` | Raw HTML emitted verbatim into every page `` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized | -Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values). +Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values). The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed. diff --git a/devplacepy/database/__init__.py b/devplacepy/database/__init__.py index 056c9c1e..6b0aaefc 100644 --- a/devplacepy/database/__init__.py +++ b/devplacepy/database/__init__.py @@ -8,7 +8,24 @@ from .relations import _relations_cache, get_user_relations, get_blocked_uids, g from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, build_pagination from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post -from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage +from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage +from .awards import ( + AWARDS_PER_PAGE, + award_display_hours, + award_give_cooldown_hours, + award_receive_cooldown_hours, + award_is_prominent, + can_give_award, + can_receive_award, + count_published_awards, + enrich_award, + get_prominent_award, + get_user_awards, + has_giver_cooldown, + has_receiver_cooldown, + recompute_user_award_stats, + revoke_award, +) from .seo_meta import SEO_META_TYPES, get_seo_metadata, get_seo_metadata_batch, has_fresh_seo_metadata, upsert_seo_metadata, mark_seo_metadata_stale from .activity import record_activity, record_unique_activity, get_user_activity, _activity_cache, _ACTIVITY_TABLES, get_activity_calendar, _activity_level, get_first_activity_date, HEATMAP_WEEKS, get_activity_heatmap, get_activity_months, get_streaks from .customization import CUSTOMIZATION_GLOBAL_SCOPE, CUSTOMIZATION_LANGS, _customizations_cache, _customization_key, CUSTOMIZATION_PREF_COLUMNS, get_customization_prefs, set_customization_pref, get_custom_overrides, get_custom_override, list_custom_overrides, set_custom_override, delete_custom_override diff --git a/devplacepy/database/awards.py b/devplacepy/database/awards.py new file mode 100644 index 00000000..c0936fc9 --- /dev/null +++ b/devplacepy/database/awards.py @@ -0,0 +1,193 @@ +# retoor + +from datetime import datetime, timedelta, timezone + +from devplacepy.config import ( + AWARD_DISPLAY_HOURS_DEFAULT, + AWARD_GIVE_COOLDOWN_HOURS_DEFAULT, + AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT, +) +from .core import db +from .pagination import build_pagination +from .settings import get_int_setting +from .core import get_table, _now_iso +from .users import get_users_by_uids +from .content import resolve_by_slug +from .soft_delete import soft_delete, soft_delete_in + +AWARDS_PER_PAGE = 12 + + +def _awards_table(): + return get_table("awards") + + +def award_give_cooldown_hours() -> int: + return max(1, get_int_setting("award_give_cooldown_hours", AWARD_GIVE_COOLDOWN_HOURS_DEFAULT)) + + +def award_receive_cooldown_hours() -> int: + return max( + 1, get_int_setting("award_receive_cooldown_hours", AWARD_RECEIVE_COOLDOWN_HOURS_DEFAULT) + ) + + +def award_display_hours() -> int: + return max(1, get_int_setting("award_display_hours", AWARD_DISPLAY_HOURS_DEFAULT)) + + +def _cooldown_cutoff(hours: int) -> str: + return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() + + +def has_giver_cooldown(giver_uid: str) -> bool: + if not giver_uid or "awards" not in db.tables: + return False + cutoff = _cooldown_cutoff(award_give_cooldown_hours()) + row = _awards_table().find_one( + giver_uid=giver_uid, deleted_at=None, created_at={">=": cutoff} + ) + return row is not None + + +def has_receiver_cooldown(receiver_uid: str) -> bool: + if not receiver_uid or "awards" not in db.tables: + return False + cutoff = _cooldown_cutoff(award_receive_cooldown_hours()) + row = _awards_table().find_one( + receiver_uid=receiver_uid, deleted_at=None, created_at={">=": cutoff} + ) + return row is not None + + +def can_receive_award(receiver_uid: str) -> bool: + return not has_receiver_cooldown(receiver_uid) + + +def can_give_award(giver_uid: str, receiver_uid: str) -> bool: + if not giver_uid or not receiver_uid or giver_uid == receiver_uid: + return False + return not has_giver_cooldown(giver_uid) and not has_receiver_cooldown(receiver_uid) + + +def _published_filter(): + return {"deleted_at": None, "generated_at": {">": ""}} + + +def count_published_awards(receiver_uid: str) -> int: + if not receiver_uid or "awards" not in db.tables: + return 0 + return _awards_table().count(receiver_uid=receiver_uid, **_published_filter()) + + +def _latest_published(receiver_uid: str): + if not receiver_uid or "awards" not in db.tables: + return None + rows = list( + _awards_table().find( + receiver_uid=receiver_uid, + deleted_at=None, + generated_at={">": ""}, + order_by=["-generated_at"], + _limit=1, + ) + ) + return rows[0] if rows else None + + +def recompute_user_award_stats(receiver_uid: str) -> None: + if not receiver_uid or "users" not in db.tables: + return + count = count_published_awards(receiver_uid) + latest = _latest_published(receiver_uid) + users = get_table("users") + payload = { + "uid": receiver_uid, + "award_count": count, + "last_award_at": latest.get("generated_at") if latest else None, + "last_award_slug": latest.get("slug") if latest else None, + "last_award_uid": latest.get("uid") if latest else None, + } + users.update(payload, ["uid"]) + + +def award_is_prominent(user: dict | None) -> bool: + if not user or not user.get("last_award_at") or not user.get("last_award_uid"): + return False + award = resolve_by_slug(_awards_table(), user["last_award_uid"]) + if not award or not award.get("generated_at"): + return False + try: + published = datetime.fromisoformat(award["generated_at"]) + if published.tzinfo is None: + published = published.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + return False + window = timedelta(hours=award_display_hours()) + return datetime.now(timezone.utc) - published <= window + + +def enrich_award(row: dict, givers: dict | None = None) -> dict: + item = dict(row) + giver_uid = row.get("giver_uid", "") + giver = (givers or {}).get(giver_uid) or get_users_by_uids([giver_uid]).get(giver_uid) + item["giver"] = giver + item["image_url"] = f"/awards/{row.get('slug', '')}/256" + item["thumb_url"] = f"/awards/{row.get('slug', '')}/64" + return item + + +def get_user_awards(receiver_uid: str, page: int = 1, per_page: int = AWARDS_PER_PAGE): + if not receiver_uid or "awards" not in db.tables: + return [], build_pagination(page, 0, per_page) + table = _awards_table() + total = table.count(receiver_uid=receiver_uid, **_published_filter()) + offset = max(0, (page - 1) * per_page) + rows = list( + table.find( + receiver_uid=receiver_uid, + deleted_at=None, + generated_at={">": ""}, + order_by=["-generated_at"], + _limit=per_page, + _offset=offset, + ) + ) + giver_uids = [row.get("giver_uid") for row in rows if row.get("giver_uid")] + givers = get_users_by_uids(giver_uids) + items = [enrich_award(row, givers) for row in rows] + return items, build_pagination(page, total, per_page) + + +def get_prominent_award(profile_user: dict) -> dict | None: + if not award_is_prominent(profile_user): + return None + award = resolve_by_slug(_awards_table(), profile_user.get("last_award_uid", "")) + if not award: + return None + return enrich_award(award) + + +def revoke_award(award_uid: str, admin_uid: str) -> dict | None: + table = _awards_table() + row = table.find_one(uid=award_uid) + if not row or row.get("deleted_at"): + return None + stamp = _now_iso() + attachment_uids = [ + uid + for uid in ( + row.get("attachment_uid_512"), + row.get("attachment_uid_256"), + row.get("attachment_uid_64"), + ) + if uid + ] + soft_delete("awards", admin_uid, stamp=stamp, uid=award_uid) + from devplacepy.attachments import soft_delete_attachments_for + + soft_delete_attachments_for("award", [award_uid], admin_uid) + if attachment_uids: + soft_delete_in("attachments", "uid", attachment_uids, admin_uid, stamp=stamp) + recompute_user_award_stats(row.get("receiver_uid", "")) + return row \ No newline at end of file diff --git a/devplacepy/database/content.py b/devplacepy/database/content.py index 667d5b03..8428e321 100644 --- a/devplacepy/database/content.py +++ b/devplacepy/database/content.py @@ -40,6 +40,12 @@ def resolve_object_url(target_type: str, target_uid: str) -> str: comment.get("target_uid") or comment.get("post_uid", ""), ) return f"{parent_url}#comment-{target_uid}" + if target_type == "award": + award = resolve_by_slug(get_table("awards"), target_uid) + if award: + receiver = get_table("users").find_one(uid=award.get("receiver_uid", "")) + if receiver: + return f"/profile/{receiver['username']}?tab=awards#award-{award.get('slug', '')}" return "/feed" diff --git a/devplacepy/database/core.py b/devplacepy/database/core.py index 5810e124..47029ed9 100644 --- a/devplacepy/database/core.py +++ b/devplacepy/database/core.py @@ -61,10 +61,11 @@ def _ensure_cache_state() -> None: global _cache_state_ready if _cache_state_ready: return - db.query( - "CREATE TABLE IF NOT EXISTS cache_state " - "(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)" - ) + with db: + db.query( + "CREATE TABLE IF NOT EXISTS cache_state " + "(name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)" + ) _cache_state_ready = True @@ -74,14 +75,15 @@ def get_cache_version(name: str) -> int: return cached try: _ensure_cache_state() - row = next( - iter( - db.query( - "SELECT version FROM cache_state WHERE name = :name", name=name - ) - ), - None, - ) + with db: + row = next( + iter( + db.query( + "SELECT version FROM cache_state WHERE name = :name", name=name + ) + ), + None, + ) version = int(row["version"]) if row else 0 except Exception as e: logger.warning(f"Could not read cache version {name}: {e}") @@ -93,16 +95,15 @@ def get_cache_version(name: str) -> int: def bump_cache_version(name: str) -> None: try: _ensure_cache_state() - db.query( - "INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)", - name=name, - ) - db.query( - "UPDATE cache_state SET version = version + 1 WHERE name = :name", name=name - ) - connection = db.executable - if connection.in_transaction(): - connection.commit() + with db: + db.query( + "INSERT OR IGNORE INTO cache_state (name, version) VALUES (:name, 0)", + name=name, + ) + db.query( + "UPDATE cache_state SET version = version + 1 WHERE name = :name", + name=name, + ) _cache_version_cache.pop(name) except Exception as e: logger.warning(f"Could not bump cache version {name}: {e}") diff --git a/devplacepy/database/notifications.py b/devplacepy/database/notifications.py index 5f52b531..e7af2a00 100644 --- a/devplacepy/database/notifications.py +++ b/devplacepy/database/notifications.py @@ -17,6 +17,7 @@ NOTIFICATION_TYPES = [ {"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"}, {"key": "reminder", "label": "Reminders", "description": "A reminder or scheduled task you asked Devii to run fires"}, {"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"}, + {"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"}, ] diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index 66bc0ac1..e6208010 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -658,6 +658,67 @@ def init_db(): except Exception as e: logger.warning(f"Could not create unique index on seo_usage: {e}") + award_usage = get_table("award_usage") + for column, example in ( + ("user_uid", ""), + ("calls", 0), + ("prompt_tokens", 0), + ("completion_tokens", 0), + ("total_tokens", 0), + ("cost_usd", 0.0), + ("upstream_latency_ms", 0.0), + ("total_latency_ms", 0.0), + ("updated_at", ""), + ): + if not award_usage.has_column(column): + award_usage.create_column_by_example(column, example) + try: + if "award_usage" in db.tables: + db.query( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_award_usage_user " + "ON award_usage (user_uid)" + ) + except Exception as e: + logger.warning(f"Could not create unique index on award_usage: {e}") + + awards = get_table("awards") + for column, example in ( + ("uid", ""), + ("slug", ""), + ("description", ""), + ("giver_uid", ""), + ("receiver_uid", ""), + ("attachment_uid_512", ""), + ("attachment_uid_256", ""), + ("attachment_uid_64", ""), + ("generated_at", ""), + ("created_at", ""), + ("job_uid", ""), + ("deleted_at", ""), + ("deleted_by", ""), + ): + if not awards.has_column(column): + awards.create_column_by_example(column, example) + try: + if "awards" in db.tables: + db.query( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_awards_slug ON awards (slug)" + ) + db.query( + "CREATE INDEX IF NOT EXISTS idx_awards_receiver_created " + "ON awards (receiver_uid, created_at)" + ) + db.query( + "CREATE INDEX IF NOT EXISTS idx_awards_giver_created " + "ON awards (giver_uid, created_at)" + ) + db.query( + "CREATE INDEX IF NOT EXISTS idx_awards_generated " + "ON awards (receiver_uid, generated_at)" + ) + except Exception as e: + logger.warning(f"Could not create awards indexes: {e}") + seo_metadata = get_table("seo_metadata") for column, example in ( ("uid", ""), @@ -1155,7 +1216,9 @@ def init_db(): "customization_enabled": "1", "customization_js_enabled": "1", "audit_log_retention_days": "90", + "statistics_tracking_enabled": "1", "docs_search_mode": "agent", + "outbound_proxy_url": "", } for key, value in operational_defaults.items(): existing = db["site_settings"].find_one(key=key) @@ -1164,6 +1227,72 @@ def init_db(): {"uid": f"default_{key}", "key": key, "value": value} ) + with db: + db.query( + "CREATE TABLE IF NOT EXISTS visit_stats_hourly (" + "bucket_start TEXT NOT NULL, " + "page_group TEXT NOT NULL, " + "referrer_group TEXT NOT NULL, " + "views INTEGER NOT NULL DEFAULT 0, " + "member_views INTEGER NOT NULL DEFAULT 0, " + "guest_views INTEGER NOT NULL DEFAULT 0)" + ) + db.query( + "CREATE TABLE IF NOT EXISTS visit_unique_slots (" + "bucket_start TEXT NOT NULL, " + "visitor_hash TEXT NOT NULL, " + "page_group TEXT NOT NULL, " + "user_uid TEXT)" + ) + _index(db, "visit_stats_hourly", "idx_visit_hourly_bucket", ["bucket_start"]) + _index( + db, + "visit_stats_hourly", + "idx_visit_hourly_page_time", + ["page_group", "bucket_start"], + ) + _index( + db, + "visit_stats_hourly", + "idx_visit_hourly_ref_time", + ["referrer_group", "bucket_start"], + ) + _index( + db, + "visit_stats_hourly", + "idx_visit_hourly_unique_row", + ["bucket_start", "page_group", "referrer_group"], + unique=True, + ) + _index(db, "visit_unique_slots", "idx_visit_unique_bucket", ["bucket_start"]) + _index( + db, + "visit_unique_slots", + "idx_visit_unique_hash", + ["bucket_start", "visitor_hash"], + ) + _index( + db, + "visit_unique_slots", + "idx_visit_unique_row", + ["bucket_start", "visitor_hash", "page_group"], + unique=True, + ) + _index(db, "issue_tickets", "idx_issue_tickets_created", ["created_at"]) + _index(db, "devii_turns", "idx_devii_turns_started", ["started_at"]) + _index(db, "instance_events", "idx_instance_events_created", ["created_at"]) + _index(db, "game_steals", "idx_game_steals_stolen_at", ["stolen_at"]) + _index(db, "messages", "idx_messages_created_at", ["created_at"]) + _index(db, "notifications", "idx_notifications_created", ["created_at"]) + _index(db, "follows", "idx_follows_created_at", ["created_at"]) + _index(db, "reactions", "idx_reactions_created_at", ["created_at"]) + _index(db, "votes", "idx_votes_created_at", ["created_at"]) + _index(db, "bookmarks", "idx_bookmarks_created_at", ["created_at"]) + _index(db, "badges", "idx_badges_created_at", ["created_at"]) + _index(db, "audit_log", "idx_audit_result_created", ["result", "created_at"]) + _index(db, "jobs", "idx_jobs_created_at", ["created_at"]) + _index(db, "attachments", "idx_attachments_created_at", ["created_at"]) + _backfill_gamification() backfill_api_keys() migrate_ai_gateway_settings() @@ -1210,6 +1339,15 @@ def migrate_ai_gateway_settings() -> None: if get_setting("bot_model", "") == "deepseek-chat": set_setting("bot_model", "molodetz") logger.info("Migrated bot_model to molodetz") + from devplacepy.services.openai_gateway.routing import ( + migrate_retired_image_gateway, + seed_default_deepseek_routes, + seed_default_image_routes, + ) + + seed_default_deepseek_routes() + seed_default_image_routes() + migrate_retired_image_gateway() def backfill_api_keys() -> int: @@ -1240,6 +1378,14 @@ def backfill_api_keys() -> int: users.create_column_by_example("avatar_seed", "") if not users.has_column("last_seen"): users.create_column_by_example("last_seen", "") + if not users.has_column("award_count"): + users.create_column_by_example("award_count", 0) + if not users.has_column("last_award_at"): + users.create_column_by_example("last_award_at", "") + if not users.has_column("last_award_slug"): + users.create_column_by_example("last_award_slug", "") + if not users.has_column("last_award_uid"): + users.create_column_by_example("last_award_uid", "") with db: db.query( "UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL" diff --git a/devplacepy/database/soft_delete.py b/devplacepy/database/soft_delete.py index 2d1d621b..b74923bb 100644 --- a/devplacepy/database/soft_delete.py +++ b/devplacepy/database/soft_delete.py @@ -41,6 +41,7 @@ SOFT_DELETE_TABLES = [ "email_accounts", "user_relations", "seo_metadata", + "awards", ] diff --git a/devplacepy/database/usage.py b/devplacepy/database/usage.py index 4b82f3f3..09d5d8cf 100644 --- a/devplacepy/database/usage.py +++ b/devplacepy/database/usage.py @@ -126,3 +126,14 @@ def add_seo_usage(totals: dict) -> None: def get_seo_usage() -> dict: return _get_usage("seo_usage", SEO_USAGE_KEY) + + +AWARD_USAGE_KEY = "award" + + +def add_award_usage(totals: dict) -> None: + _add_usage("award_usage", AWARD_USAGE_KEY, totals) + + +def get_award_usage() -> dict: + return _get_usage("award_usage", AWARD_USAGE_KEY) diff --git a/devplacepy/docs_api/groups/admin.py b/devplacepy/docs_api/groups/admin.py index 2d3b34b0..df707d9c 100644 --- a/devplacepy/docs_api/groups/admin.py +++ b/devplacepy/docs_api/groups/admin.py @@ -63,6 +63,22 @@ four ways to sign requests. ], sample_response={"ok": True, "redirect": "/admin/media"}, ), + endpoint( + id="admin-revoke-award", + method="POST", + path="/admin/awards/{uid}/revoke", + title="Revoke award", + summary=( + "Soft-delete a published award and its linked attachments, then recompute " + "receiver stats. Restorable from admin trash." + ), + auth="admin", + destructive=True, + params=[ + field("uid", "path", "string", True, "", "Award uid to revoke."), + ], + sample_response={"ok": True, "redirect": "/profile/receiver?tab=awards"}, + ), endpoint( id="admin-media-purge", method="POST", @@ -171,6 +187,84 @@ four ways to sign requests. "This is the endpoint the Devii assistant calls as `site_analytics`; see [Devii internals](/docs/devii-internals.html).", ], ), + endpoint( + id="admin-statistics", + method="GET", + path="/admin/statistics/data", + title="Platform statistics", + summary=( + "Tabbed platform statistics with KPI cards, period-over-period deltas, " + "time-series data for charts, and breakdown tables. Covers visitors, members, " + "content, engagement, social, AI, Devii, services, containers, game, awards, " + "moderation, tools, and storage." + ), + auth="admin", + interactive=True, + params=[ + field( + "tab", + "query", + "string", + False, + "overview", + "Tab key (overview, visitors, members, content, ...).", + ), + field( + "hours", + "query", + "int", + False, + "168", + "Lookback window in hours (24, 168, 720, 2160, or 0 for all time).", + ), + field( + "compare", + "query", + "int", + False, + "1", + "Include previous-period comparison (1 or 0).", + ), + field( + "top_n", + "query", + "int", + False, + "10", + "Rows in breakdown tables (1-50).", + ), + ], + notes=[ + "The HTML dashboard lives at `/admin/statistics`. Visitor metrics require the statistics tracking middleware (hourly aggregation, 90-day retention).", + ], + ), + endpoint( + id="admin-statistics-page", + method="GET", + path="/admin/statistics", + title="Statistics dashboard", + summary="Admin HTML dashboard for platform statistics with charts and tabs.", + auth="admin", + interactive=False, + params=[ + field( + "tab", + "query", + "string", + False, + "overview", + "Initial tab to render.", + ), + field( + "hours", + "query", + "int", + False, + "168", + "Initial time window in hours.", + ), + ], + ), endpoint( id="admin-ai-usage", method="GET", diff --git a/devplacepy/docs_api/groups/gateway.py b/devplacepy/docs_api/groups/gateway.py index 89b070e9..6446d1d0 100644 --- a/devplacepy/docs_api/groups/gateway.py +++ b/devplacepy/docs_api/groups/gateway.py @@ -32,6 +32,11 @@ The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`. generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision. +The gateway also serves **image generation** at `/openai/v1/images/generations`. Clients request the +generic model `molodetz-img-small`, which the gateway maps to the configured image model (OpenRouter's +Flux 1.1 Pro by default). Cost is tracked per call with a flat per-image price when the upstream +returns no native cost. + ## Model routing and providers On top of the single default upstream above, an administrator can register additional named @@ -60,7 +65,7 @@ and dollar cost directly from the response with no extra request: | Header | Meaning | |--------|---------| | `X-Gateway-Model` | Upstream model actually used for the call | -| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, or passthrough | +| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, `image`, or passthrough | | `X-Gateway-Prompt-Tokens` | Input (prompt) tokens | | `X-Gateway-Completion-Tokens` | Output (completion) tokens | | `X-Gateway-Total-Tokens` | Total tokens (prompt + completion) | @@ -83,7 +88,7 @@ and dollar cost directly from the response with no extra request: Dollar costs use the upstream's native `cost` field when it returns one (`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million prices of the matched model route, falling back to the prices configured on the `openai` service when no route matches. The -one denied path that makes no upstream call (embeddings disabled) returns no usage headers. +denied paths that make no upstream call (embeddings or image generation disabled) return no usage headers. Administrators enable and configure this gateway under [Background Services](/docs/services.html) (the `openai` service). @@ -172,6 +177,53 @@ for signing DevPlace's own requests. "Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).", ], ), + endpoint( + id="gateway-images", + method="POST", + path="/openai/v1/images/generations", + title="Image generation", + summary="OpenAI-compatible image generation. Request model molodetz-img-small.", + auth="user", + encoding="json", + params=[ + field( + "model", + "json", + "string", + False, + "molodetz-img-small", + "Image model id; the gateway maps molodetz-img-small to the configured model, or to a matching image model route's provider and target model.", + ), + field( + "prompt", + "json", + "string", + True, + '"a decorative developer award emblem"', + "Text prompt describing the image to generate.", + ), + field( + "size", + "json", + "string", + False, + "512x512", + "Output dimensions (provider-dependent).", + ), + field( + "response_format", + "json", + "string", + False, + "b64_json", + "Return format: url or b64_json.", + ), + ], + notes=[ + "Returns `503` when the gateway service is not running or image generation is disabled.", + "Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).", + ], + ), endpoint( id="gateway-passthrough", method="POST", diff --git a/devplacepy/docs_api/groups/profiles.py b/devplacepy/docs_api/groups/profiles.py index 3090f3e7..545c83e0 100644 --- a/devplacepy/docs_api/groups/profiles.py +++ b/devplacepy/docs_api/groups/profiles.py @@ -32,7 +32,7 @@ four ways to sign requests. False, "posts", "Profile tab.", - ["posts", "activity", "followers", "following", "media"], + ["posts", "activity", "followers", "following", "media", "awards"], ), ], ), @@ -60,7 +60,7 @@ four ways to sign requests. False, "posts", "Profile tab.", - ["posts", "activity", "followers", "following", "media"], + ["posts", "activity", "followers", "following", "media", "awards"], ), ], ), @@ -254,6 +254,40 @@ four ways to sign requests. ], sample_response={"api_key": "NEW_UUID"}, ), + endpoint( + id="profile-give-award", + method="POST", + path="/profile/{username}/award", + title="Give a member an award", + summary="Create a pending award on another member's profile and enqueue image generation.", + auth="user", + encoding="json", + params=[ + field( + "username", + "path", + "string", + True, + "{{ username }}", + "Receiver username.", + ), + field( + "description", + "body", + "string", + True, + "Great work on the release!", + "Award message (1-125 characters).", + ), + ], + sample_response={ + "ok": True, + "data": { + "award_uid": "AWARD_UID", + "award_slug": "abc123-great-work", + }, + }, + ), endpoint( id="profile-regenerate-avatar", method="POST", @@ -650,6 +684,37 @@ four ways to sign requests. auth="public", interactive=True, ), + endpoint( + id="award-image", + method="GET", + path="/awards/{slug_or_uid}/{size}", + title="Award image redirect", + summary="Redirect to the stored PNG attachment for a published award.", + auth="public", + params=[ + field( + "slug_or_uid", + "path", + "string", + True, + "abc123-great-work", + "Award slug or bare uid.", + ), + field( + "size", + "path", + "enum", + True, + "256", + "Image size.", + ["512", "256", "64"], + ), + ], + notes=[ + "> Pending or revoked awards return 404.", + "> Response includes long-lived cache headers.", + ], + ), endpoint( id="avatar", method="GET", diff --git a/devplacepy/main.py b/devplacepy/main.py index ed1049f6..63a817d0 100644 --- a/devplacepy/main.py +++ b/devplacepy/main.py @@ -56,6 +56,7 @@ from devplacepy.routers import ( notifications, votes, avatar, + awards, follow, relations, admin, @@ -95,6 +96,7 @@ from devplacepy.services.jobs.issue_create_service import IssueCreateService from devplacepy.services.jobs.planning_service import PlanningReportService from devplacepy.services.jobs.seo.service import SeoService from devplacepy.services.jobs.seo_meta_service import SeoMetaService +from devplacepy.services.jobs.award_service import AwardService from devplacepy.services.backup import BackupService from devplacepy.services.dbapi.service import DbApiJobService from devplacepy.services.pubsub import PubSubService @@ -255,6 +257,7 @@ async def lifespan(app: FastAPI): service_manager.register(ForkService()) service_manager.register(SeoService()) service_manager.register(SeoMetaService()) + service_manager.register(AwardService()) service_manager.register(BackupService()) service_manager.register(DbApiJobService()) service_manager.register(PubSubService()) @@ -281,9 +284,15 @@ async def lifespan(app: FastAPI): logger.info( f"Worker pid {os.getpid()} declined service lock; another worker owns background services" ) + from devplacepy.services.statistics.tracking import start_visit_flusher + + start_visit_flusher() logger.info(f"DevPlace started on port {PORT}") yield logger.info("Shutting down services...") + from devplacepy.services.statistics.tracking import flush_visits + + flush_visits() await service_manager.shutdown_all() await background.stop() @@ -428,6 +437,7 @@ app.include_router(reactions.router, prefix="/reactions") app.include_router(bookmarks.router, prefix="/bookmarks") app.include_router(polls.router, prefix="/polls") app.include_router(avatar.router, prefix="/avatar") +app.include_router(awards.router, prefix="/awards") app.include_router(follow.router, prefix="/follow") app.include_router(relations.router) app.include_router(leaderboard.router, prefix="/leaderboard") @@ -580,6 +590,15 @@ async def track_presence(request: Request, call_next): return await call_next(request) +@app.middleware("http") +async def visit_statistics(request: Request, call_next): + from devplacepy.services.statistics.tracking import track_visit + + response = await call_next(request) + track_visit(request, response.status_code) + return response + + @app.middleware("http") async def response_timing(request: Request, call_next): start = time.perf_counter() diff --git a/devplacepy/models.py b/devplacepy/models.py index 05732547..68749e1d 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -4,6 +4,7 @@ import re from datetime import datetime from typing import Literal, Optional +from urllib.parse import urlsplit from pydantic import BaseModel, Field, field_validator, model_validator from devplacepy.constants import TOPICS, REACTION_EMOJI from devplacepy.config import DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT @@ -238,6 +239,10 @@ class CustomizationToggleForm(BaseModel): value: bool = False +class AwardGiveForm(BaseModel): + description: str = Field(min_length=1, max_length=125) + + class NotificationPrefForm(BaseModel): notification_type: str = Field(min_length=1, max_length=40) channel: Literal["in_app", "push", "telegram"] @@ -551,8 +556,20 @@ class AdminSettingsForm(BaseModel): maintenance_mode: str = Field(default="", max_length=1) maintenance_message: str = Field(default="", max_length=300) docs_search_mode: str = Field(default="", max_length=20) + outbound_proxy_url: str = Field(default="", max_length=500) extra_head: str = Field(default="", max_length=50000) + @field_validator("outbound_proxy_url") + @classmethod + def validate_outbound_proxy_url(cls, value): + text = value.strip() + if not text: + return text + parsed = urlsplit(text) + if parsed.scheme not in ("http", "https", "socks5", "socks5h") or not parsed.hostname: + raise ValueError("Proxy URL must be http(s):// or socks5(h):// with a host, e.g. http://user:pass@host:port") + return text + class GamePlantForm(BaseModel): slot: int = Field(ge=0, le=64) diff --git a/devplacepy/routers/admin/__init__.py b/devplacepy/routers/admin/__init__.py index d7d50465..da07ba55 100644 --- a/devplacepy/routers/admin/__init__.py +++ b/devplacepy/routers/admin/__init__.py @@ -1,6 +1,7 @@ # retoor from devplacepy.routers.admin import ( + awards, aiquota, aiusage, auditlog, @@ -14,13 +15,16 @@ from devplacepy.routers.admin import ( notifications, services, settings, + statistics, trash, users, ) from devplacepy.routers.admin.index import router +router.include_router(awards.router) router.include_router(users.router) router.include_router(aiusage.router) +router.include_router(statistics.router) router.include_router(aiquota.router) router.include_router(media.router) router.include_router(trash.router) diff --git a/devplacepy/routers/admin/awards.py b/devplacepy/routers/admin/awards.py new file mode 100644 index 00000000..7d5ed8c5 --- /dev/null +++ b/devplacepy/routers/admin/awards.py @@ -0,0 +1,49 @@ +# retoor + +import logging + +from fastapi import APIRouter, Request +from devplacepy.database import get_table +from devplacepy.database.awards import revoke_award +from devplacepy.responses import action_result, json_error, wants_json +from devplacepy.services.audit import record as audit +from devplacepy.utils import not_found, require_admin, safe_next + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def _redirect_back(request: Request, award: dict) -> str: + referer = request.headers.get("referer", "") + if referer and safe_next(referer, "") == referer: + return referer + receiver = get_table("users").find_one(uid=award.get("receiver_uid", "")) + if receiver: + return f"/profile/{receiver['username']}?tab=awards" + return "/admin" + + +@router.post("/awards/{uid}/revoke") +async def admin_revoke_award(request: Request, uid: str): + admin = require_admin(request) + row = revoke_award(uid, admin["uid"]) + if not row: + if wants_json(request): + return json_error(404, "Award not found") + raise not_found("Award not found") + receiver = get_table("users").find_one(uid=row.get("receiver_uid", "")) + logger.info("Admin %s revoked award %s", admin["username"], uid) + audit.record( + request, + "award.revoke", + user=admin, + target_type="award", + target_uid=uid, + target_label=row.get("slug", uid), + summary=f"admin {admin['username']} revoked award {row.get('slug', uid)}", + links=[ + audit.target("award", uid, row.get("slug")), + audit.target("user", row.get("receiver_uid"), receiver.get("username") if receiver else None), + ], + ) + return action_result(request, _redirect_back(request, row)) \ No newline at end of file diff --git a/devplacepy/routers/admin/gateway_configs.py b/devplacepy/routers/admin/gateway_configs.py index e3bf8cbc..6de4c256 100644 --- a/devplacepy/routers/admin/gateway_configs.py +++ b/devplacepy/routers/admin/gateway_configs.py @@ -25,6 +25,8 @@ def _default_provider_summary() -> dict: "model": cfg.get("gateway_model", ""), "embed_url": cfg.get("gateway_embed_url", ""), "embed_model": cfg.get("gateway_embed_model", ""), + "image_url": cfg.get("gateway_image_url", ""), + "image_model": cfg.get("gateway_image_model", ""), "vision_url": cfg.get("gateway_vision_url", ""), "vision_model": cfg.get("gateway_vision_model", ""), } diff --git a/devplacepy/routers/admin/statistics.py b/devplacepy/routers/admin/statistics.py new file mode 100644 index 00000000..7080fafe --- /dev/null +++ b/devplacepy/routers/admin/statistics.py @@ -0,0 +1,90 @@ +# retoor + +import logging +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, JSONResponse +from devplacepy.responses import respond +from devplacepy.schemas.statistics import StatisticsOut +from devplacepy.seo import base_seo_context, site_url, website_schema +from devplacepy.services.statistics.build import build_statistics_tab +from devplacepy.services.statistics.common import VALID_TABS +from devplacepy.utils import require_admin + +logger = logging.getLogger(__name__) +router = APIRouter() + +TAB_LABELS = ( + ("overview", "Overview", "\U0001f4ca"), + ("visitors", "Visitors", "\U0001f441\ufe0f"), + ("members", "Members", "\U0001f465"), + ("content", "Content", "\U0001f4dd"), + ("engagement", "Engagement", "\U0001f525"), + ("social", "Social", "\U0001f91d"), + ("ai", "AI", "\U0001f916"), + ("devii", "Devii", "\u2728"), + ("services", "Services", "\u2699\ufe0f"), + ("containers", "Containers", "\U0001f4e6"), + ("game", "Game", "\U0001f3ae"), + ("awards", "Awards", "\U0001f3c6"), + ("moderation", "Moderation", "\U0001f6e1\ufe0f"), + ("tools", "Tools", "\U0001f527"), + ("storage", "Storage", "\U0001f4be"), +) + + +@router.get("/statistics", response_class=HTMLResponse) +async def admin_statistics(request: Request, tab: str = "overview", hours: int = 168): + admin = require_admin(request) + active = tab if tab in VALID_TABS else "overview" + base = site_url(request) + seo_ctx = base_seo_context( + request, + title="Statistics - Admin", + description="Platform statistics with trends, visitors, content, engagement, and operations.", + robots="noindex,nofollow", + breadcrumbs=[ + {"name": "Home", "url": "/feed"}, + {"name": "Admin", "url": "/admin"}, + {"name": "Statistics", "url": "/admin/statistics"}, + ], + schemas=[website_schema(base)], + ) + initial = build_statistics_tab(active, hours, compare=True, top_n=10) + tabs = [ + {"key": key, "label": label, "icon": icon, "active": key == active} + for key, label, icon in TAB_LABELS + ] + return respond( + request, + "admin_statistics.html", + { + **seo_ctx, + "request": request, + "user": admin, + "admin_section": "statistics", + "tabs": tabs, + "active_tab": active, + "window_hours": hours, + "initial": initial, + }, + model=StatisticsOut, + ) + + +@router.get("/statistics/data") +async def admin_statistics_data( + request: Request, + tab: str = "overview", + hours: int = 168, + compare: int = 1, + top_n: int = 10, +): + require_admin(request) + return JSONResponse( + build_statistics_tab( + tab, + hours, + compare=bool(compare), + top_n=top_n, + ) + ) \ No newline at end of file diff --git a/devplacepy/routers/admin/trash.py b/devplacepy/routers/admin/trash.py index 00743593..b8c7764b 100644 --- a/devplacepy/routers/admin/trash.py +++ b/devplacepy/routers/admin/trash.py @@ -24,13 +24,14 @@ logger = logging.getLogger(__name__) router = APIRouter() TRASH_TABLES = [ - {"key": "posts", "label": "Posts", "type": "post"}, - {"key": "comments", "label": "Comments", "type": "comment"}, - {"key": "gists", "label": "Gists", "type": "gist"}, - {"key": "projects", "label": "Projects", "type": "project"}, - {"key": "news", "label": "News", "type": "news"}, - {"key": "project_files", "label": "Project files", "type": None}, - {"key": "attachments", "label": "Attachments", "type": None}, + {"key": "posts", "label": "Posts", "icon": "\U0001f4dd", "type": "post"}, + {"key": "comments", "label": "Comments", "icon": "\U0001f4ac", "type": "comment"}, + {"key": "gists", "label": "Gists", "icon": "\U0001f4cb", "type": "gist"}, + {"key": "projects", "label": "Projects", "icon": "\U0001f680", "type": "project"}, + {"key": "news", "label": "News", "icon": "\U0001f4f0", "type": "news"}, + {"key": "awards", "label": "Awards", "icon": "\U0001f3c6", "type": "award"}, + {"key": "project_files", "label": "Project files", "icon": "\U0001f4c1", "type": None}, + {"key": "attachments", "label": "Attachments", "icon": "\U0001f4ce", "type": None}, ] _TRASH_KEYS = {entry["key"] for entry in TRASH_TABLES} _TRASH_TYPE = {entry["key"]: entry["type"] for entry in TRASH_TABLES} @@ -113,6 +114,10 @@ async def admin_trash_restore(request: Request, table: str, uid: str): row = get_table(table).find_one(uid=uid) if row and row.get("deleted_at"): restored = restore_event(row["deleted_at"]) + if table == "awards": + from devplacepy.database.awards import recompute_user_award_stats + + recompute_user_award_stats(row.get("receiver_uid", "")) logger.info( f"Admin {admin['username']} restored {table} {uid} ({restored} rows)" ) diff --git a/devplacepy/routers/awards.py b/devplacepy/routers/awards.py new file mode 100644 index 00000000..2b2dc202 --- /dev/null +++ b/devplacepy/routers/awards.py @@ -0,0 +1,36 @@ +# retoor + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from devplacepy.attachments import _row_to_attachment +from devplacepy.database import get_table, resolve_by_slug +from devplacepy.utils import not_found + +router = APIRouter() + +_VALID_SIZES = {"512", "256", "64"} +_CACHE_CONTROL = "public, max-age=86400, immutable" + + +@router.get("/{slug_or_uid}/{size}") +async def award_image(request: Request, slug_or_uid: str, size: str): + if size not in _VALID_SIZES: + raise not_found("Award image not found") + award = resolve_by_slug(get_table("awards"), slug_or_uid) + if not award or not award.get("generated_at"): + raise not_found("Award image not found") + attachment_uid = award.get(f"attachment_uid_{size}") or "" + if not attachment_uid: + raise not_found("Award image not found") + row = get_table("attachments").find_one(uid=attachment_uid, deleted_at=None) + attachment = _row_to_attachment(row) if row else None + if not attachment: + raise not_found("Award image not found") + url = attachment.get("url") or "" + if not url: + raise not_found("Award image not found") + headers = { + "Cache-Control": _CACHE_CONTROL, + "ETag": f'"{attachment_uid}"', + } + return RedirectResponse(url=url, status_code=302, headers=headers) \ No newline at end of file diff --git a/devplacepy/routers/docs/pages.py b/devplacepy/routers/docs/pages.py index fed44c5e..2adc6e16 100644 --- a/devplacepy/routers/docs/pages.py +++ b/devplacepy/routers/docs/pages.py @@ -123,6 +123,12 @@ DOCS_PAGES = [ "kind": "prose", "section": SECTION_GENERAL, }, + { + "slug": "awards", + "title": "Profile awards", + "kind": "prose", + "section": SECTION_GENERAL, + }, { "slug": "ai-correction", "title": "AI content correction", diff --git a/devplacepy/routers/profile/__init__.py b/devplacepy/routers/profile/__init__.py index 33666d18..a05a4ca7 100644 --- a/devplacepy/routers/profile/__init__.py +++ b/devplacepy/routers/profile/__init__.py @@ -4,6 +4,7 @@ from devplacepy.routers.profile import ( ai_correction, ai_modifier, avatar, + award, customization, notifications, telegram, @@ -11,6 +12,7 @@ from devplacepy.routers.profile import ( from devplacepy.routers.profile.index import router from devplacepy.routers.profile.usage import _ai_quota +router.include_router(award.router) router.include_router(customization.router) router.include_router(notifications.router) router.include_router(ai_correction.router) diff --git a/devplacepy/routers/profile/award.py b/devplacepy/routers/profile/award.py new file mode 100644 index 00000000..6fb9c8d9 --- /dev/null +++ b/devplacepy/routers/profile/award.py @@ -0,0 +1,106 @@ +# retoor + +import logging +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import APIRouter, Request +from devplacepy.database import get_blocked_uids, get_table +from devplacepy.database.awards import can_give_award, has_giver_cooldown, has_receiver_cooldown +from devplacepy.dependencies import json_or_form +from devplacepy.models import AwardGiveForm +from devplacepy.responses import action_result, json_error, wants_json +from devplacepy.services.audit import record as audit +from devplacepy.services.jobs import queue +from devplacepy.utils import generate_uid, make_combined_slug, require_user + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/{username}/award") +async def give_award( + request: Request, + username: str, + data: Annotated[AwardGiveForm, json_or_form(AwardGiveForm)], +): + giver = require_user(request) + target = get_table("users").find_one(username=username) + redirect = f"/profile/{username}" + + def deny(message: str, status: int = 400): + if wants_json(request): + return json_error(status, message) + return action_result(request, redirect, status_code=302) + + if not target: + return deny("User not found", 404) + if target["uid"] == giver["uid"]: + return deny("You cannot give yourself an award") + blocked = get_blocked_uids(giver["uid"]) + if target["uid"] in blocked: + return deny("You cannot give an award to a blocked user") + reverse_blocked = get_blocked_uids(target["uid"]) + if giver["uid"] in reverse_blocked: + return deny("You cannot give an award to this user") + if has_giver_cooldown(giver["uid"]): + return deny("You can give another award later") + if has_receiver_cooldown(target["uid"]): + return deny("This user received an award recently") + if not (giver.get("api_key") or "").strip(): + return deny("Your account has no API key for award generation") + + description = data.description.strip() + uid = generate_uid() + slug = make_combined_slug(description, uid) + now = datetime.now(timezone.utc).isoformat() + get_table("awards").insert( + { + "uid": uid, + "slug": slug, + "description": description, + "giver_uid": giver["uid"], + "receiver_uid": target["uid"], + "attachment_uid_512": "", + "attachment_uid_256": "", + "attachment_uid_64": "", + "generated_at": None, + "created_at": now, + "job_uid": "", + "deleted_at": None, + "deleted_by": None, + } + ) + job_uid = queue.enqueue( + "award", + { + "award_uid": uid, + "giver_uid": giver["uid"], + "receiver_uid": target["uid"], + "description": description, + "api_key": giver.get("api_key", ""), + }, + "user", + giver["uid"], + ) + get_table("awards").update({"uid": uid, "job_uid": job_uid}, ["uid"]) + logger.info("%s gave award %s to %s", giver["username"], uid, username) + audit.record( + request, + "award.give", + user=giver, + target_type="user", + target_uid=target["uid"], + target_label=username, + summary=f"{giver['username']} gave award to {username}", + links=[ + audit.target("user", target["uid"], username), + audit.target("award", uid, slug), + audit.job(job_uid), + ], + ) + return action_result( + request, + redirect, + data={"ok": True, "award_uid": uid, "award_slug": slug}, + ) \ No newline at end of file diff --git a/devplacepy/routers/profile/index.py b/devplacepy/routers/profile/index.py index 044ffa68..f5146744 100644 --- a/devplacepy/routers/profile/index.py +++ b/devplacepy/routers/profile/index.py @@ -28,6 +28,11 @@ from devplacepy.database import ( mark_notifications_read_by_target, resolve_object_url, ) +from devplacepy.database.awards import ( + can_give_award, + get_prominent_award, + get_user_awards, +) from devplacepy.content import can_view_project, enrich_items from devplacepy.utils import ( get_current_user, @@ -147,6 +152,17 @@ async def profile_page( if tab == "media": media, media_pagination = get_user_media(profile_user["uid"], page) + awards, awards_pagination = [], None + if tab == "awards": + awards, awards_pagination = get_user_awards(profile_user["uid"], page) + prominent_award = get_prominent_award(profile_user) + awards_count = int(profile_user.get("award_count") or 0) + can_give = bool( + current_user + and current_user["uid"] != profile_user["uid"] + and can_give_award(current_user["uid"], profile_user["uid"]) + ) + posts = [] if tab == "posts": posts_table = get_table("posts") @@ -404,6 +420,11 @@ async def profile_page( "follow_pagination": follow_pagination, "followers_count": follow_counts["followers"], "following_count": follow_counts["following"], + "awards": awards, + "awards_pagination": awards_pagination, + "awards_count": awards_count, + "prominent_award": prominent_award, + "can_give_award": can_give, }, model=ProfileOut, ) diff --git a/devplacepy/schemas/__init__.py b/devplacepy/schemas/__init__.py index 8c8172a5..f710f887 100644 --- a/devplacepy/schemas/__init__.py +++ b/devplacepy/schemas/__init__.py @@ -114,6 +114,7 @@ from devplacepy.schemas.gateway import ( GatewayUsageOut, UserAiUsageOut, ) +from devplacepy.schemas.statistics import StatisticsOut from devplacepy.schemas.auth import ( AuthPageOut, DeviiPageOut, diff --git a/devplacepy/schemas/awards.py b/devplacepy/schemas/awards.py new file mode 100644 index 00000000..c76b3471 --- /dev/null +++ b/devplacepy/schemas/awards.py @@ -0,0 +1,19 @@ +# retoor + +from typing import Optional + +from devplacepy.schemas.base import _Out +from devplacepy.schemas.content import UserOut + + +class AwardOut(_Out): + uid: str = "" + slug: str = "" + description: str = "" + giver_uid: str = "" + receiver_uid: str = "" + generated_at: Optional[str] = None + created_at: Optional[str] = None + image_url: Optional[str] = None + thumb_url: Optional[str] = None + giver: Optional[UserOut] = None \ No newline at end of file diff --git a/devplacepy/schemas/profile.py b/devplacepy/schemas/profile.py index b71a55b8..1c5b7415 100644 --- a/devplacepy/schemas/profile.py +++ b/devplacepy/schemas/profile.py @@ -6,6 +6,7 @@ from typing import Any, Optional from devplacepy.schemas.base import _Out from devplacepy.schemas.content import BadgeOut, ProjectOut, UserOut +from devplacepy.schemas.awards import AwardOut from devplacepy.schemas.listings import FeedItemOut, GistItemOut @@ -70,6 +71,11 @@ class ProfileOut(_Out): media: list[MediaItemOut] = [] media_pagination: Optional[Any] = None notification_prefs: list[Any] = [] + awards: list[AwardOut] = [] + awards_pagination: Optional[Any] = None + awards_count: int = 0 + prominent_award: Optional[AwardOut] = None + can_give_award: bool = False class TelegramPairOut(_Out): diff --git a/devplacepy/schemas/statistics.py b/devplacepy/schemas/statistics.py new file mode 100644 index 00000000..bec2d3ee --- /dev/null +++ b/devplacepy/schemas/statistics.py @@ -0,0 +1,52 @@ +# retoor + +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel + + +class StatisticsMetricOut(BaseModel): + key: str + label: str + value: Any + format: str = "int" + delta: Optional[float] = None + direction: Optional[str] = None + + +class StatisticsPointOut(BaseModel): + t: str + v: float + + +class StatisticsSeriesOut(BaseModel): + key: str + label: str + points: list[StatisticsPointOut] + + +class StatisticsTableOut(BaseModel): + key: str + title: str + columns: list[str] + rows: list[list[Any]] + + +class StatisticsHighlightOut(BaseModel): + label: str + value: Any + + +class StatisticsOut(BaseModel): + tab: str + window_hours: int + granularity: str + generated_at: str + compare: bool = True + cards: list[StatisticsMetricOut] = [] + series: list[StatisticsSeriesOut] = [] + tables: list[StatisticsTableOut] = [] + highlights: list[StatisticsHighlightOut] = [] + notes: dict[str, Any] = {} \ No newline at end of file diff --git a/devplacepy/services/audit/categories.py b/devplacepy/services/audit/categories.py index 6e828fd4..4ca0fd70 100644 --- a/devplacepy/services/audit/categories.py +++ b/devplacepy/services/audit/categories.py @@ -4,6 +4,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = { "auth": "auth", "profile": "account", "follow": "social", + "award": "social", "relation": "social", "push": "push", "notification": "notification", diff --git a/devplacepy/services/background.py b/devplacepy/services/background.py index b83c3c41..ed965d5a 100644 --- a/devplacepy/services/background.py +++ b/devplacepy/services/background.py @@ -64,6 +64,16 @@ class BackgroundQueue: if inline: self._inline += 1 logger.warning("background task %s failed: %s", getattr(fn, "__name__", fn), exc) + finally: + self._release_db_lock() + + def _release_db_lock(self) -> None: + try: + from devplacepy.database import refresh_snapshot + + refresh_snapshot() + except Exception as exc: + logger.warning("background queue could not release db lock: %s", exc) async def start(self) -> None: if self.running: diff --git a/devplacepy/services/devii/actions/catalog/admin.py b/devplacepy/services/devii/actions/catalog/admin.py index 9f6378a2..9c1ca4fe 100644 --- a/devplacepy/services/devii/actions/catalog/admin.py +++ b/devplacepy/services/devii/actions/catalog/admin.py @@ -31,6 +31,25 @@ ADMIN_ACTIONS: tuple[Action, ...] = ( params=(query("top_n", "How many top authors to include (1-50)."),), requires_admin=True, ), + Action( + name="admin_statistics", + method="GET", + path="/admin/statistics/data", + summary="Platform statistics tab data with trends (admin only)", + description=( + "Returns JSON for one statistics tab: KPI cards with period-over-period deltas, " + "time-series points for line charts, breakdown tables, and highlight metrics. " + "Tabs: overview, visitors, members, content, engagement, social, ai, devii, " + "services, containers, game, awards, moderation, tools, storage." + ), + params=( + query("tab", "Tab key (default overview)."), + query("hours", "Lookback window in hours (24, 168, 720, 2160, or 0 for all time)."), + query("compare", "Include previous-period comparison (1 or 0, default 1)."), + query("top_n", "Rows in breakdown tables (default 10)."), + ), + requires_admin=True, + ), Action( name="ai_usage", method="GET", @@ -305,6 +324,18 @@ ADMIN_ACTIONS: tuple[Action, ...] = ( params=(path("uid", "Attachment uid to purge."), confirm()), requires_admin=True, ), + Action( + name="admin_revoke_award", + method="POST", + path="/admin/awards/{uid}/revoke", + summary="Revoke a published award (admin only)", + description=( + "Soft-deletes the award and linked attachments, then recomputes receiver stats. " + "Confirmation is required in the UI; Devii should confirm before calling." + ), + params=(path("uid", "Award uid to revoke."), confirm()), + requires_admin=True, + ), Action( name="admin_reset_guest_ai_quota", method="POST", diff --git a/devplacepy/services/devii/actions/catalog/gateway.py b/devplacepy/services/devii/actions/catalog/gateway.py index b0cdc7a3..ae644b42 100644 --- a/devplacepy/services/devii/actions/catalog/gateway.py +++ b/devplacepy/services/devii/actions/catalog/gateway.py @@ -27,7 +27,7 @@ GATEWAY_ACTIONS: tuple[Action, ...] = ( summary="Create or update a gateway provider (admin only)", description=( "Adds or updates a named upstream provider. base_url is the OpenAI-compatible " - "chat-completions endpoint; the embeddings endpoint is derived from it." + "chat-completions endpoint; the embeddings and images endpoints are derived from it." ), handler="http", requires_admin=True, @@ -60,7 +60,8 @@ GATEWAY_ACTIONS: tuple[Action, ...] = ( summary="List OpenAI gateway model routes (admin only)", description=( "Returns JSON: every source-model route with its provider, target model, kind " - "(chat/embed), optional vision model, context window, and per-model pricing economy." + "(chat/embed/image), optional vision model, context window, and per-model pricing economy " + "(including any tiered/off-peak pricing configured on it)." ), handler="http", requires_admin=True, @@ -73,9 +74,14 @@ GATEWAY_ACTIONS: tuple[Action, ...] = ( summary="Create or update a gateway model route (admin only)", description=( "Maps a requested source_model onto a provider + target_model, each with its own " - "pricing. kind is 'chat' or 'embed'. A vision_model adds image-to-text augmentation " - "for chat routes. Prices are USD per 1,000,000 tokens; chat uses cache-hit/cache-miss/" - "output, embeddings use input, vision uses input/output." + "pricing. kind is 'chat', 'embed', or 'image'. A vision_model adds image-to-text augmentation " + "for chat routes. Prices are USD per 1,000,000 tokens for chat/embed/vision; image routes " + "use price_input_per_m as a flat USD per generated image. Optionally, a route can also " + "charge a different (tier-2) rate once the request's input tokens exceed " + "context_tier_threshold_tokens, and/or apply a percentage discount during a fixed " + "UTC off-peak window - leave the tier2/off-peak fields unset to keep the flat rates " + "above at all times. off_peak_start_minute and off_peak_end_minute must be set " + "together (both or neither) or the call is rejected." ), handler="http", requires_admin=True, @@ -83,14 +89,22 @@ GATEWAY_ACTIONS: tuple[Action, ...] = ( body("source_model", "Model name clients request.", required=True), body("provider", "Provider name, or blank for the default upstream."), body("target_model", "Model name sent upstream.", required=True), - body("kind", "Route kind: 'chat' or 'embed'."), + body("kind", "Route kind: 'chat', 'embed', or 'image'."), body("vision_provider", "Provider for image description, or blank for the route provider."), body("vision_model", "Vision model name (blank disables the merge)."), Param(name="context_window", location="body", description="Max context tokens (0 = unknown).", required=False, type="integer"), Param(name="price_cache_hit_per_m", location="body", description="USD per 1M cache-hit input tokens.", required=False, type="number"), Param(name="price_cache_miss_per_m", location="body", description="USD per 1M cache-miss input tokens.", required=False, type="number"), Param(name="price_output_per_m", location="body", description="USD per 1M output tokens.", required=False, type="number"), - Param(name="price_input_per_m", location="body", description="USD per 1M input tokens (embed/vision).", required=False, type="number"), + Param(name="price_input_per_m", location="body", description="USD per 1M input tokens (embed/vision), or flat USD per image for image routes.", required=False, type="number"), + Param(name="context_tier_threshold_tokens", location="body", description="Input tokens above which tier-2 rates apply (0 disables tiering).", required=False, type="integer"), + Param(name="price_cache_hit_per_m_tier2", location="body", description="Tier-2 USD per 1M cache-hit input tokens (unset = keep tier-1 rate above threshold).", required=False, type="number"), + Param(name="price_cache_miss_per_m_tier2", location="body", description="Tier-2 USD per 1M cache-miss input tokens (unset = keep tier-1 rate above threshold).", required=False, type="number"), + Param(name="price_output_per_m_tier2", location="body", description="Tier-2 USD per 1M output tokens (unset = keep tier-1 rate above threshold).", required=False, type="number"), + Param(name="price_input_per_m_tier2", location="body", description="Tier-2 USD per 1M input tokens, embed/vision (unset = keep tier-1 rate above threshold).", required=False, type="number"), + Param(name="off_peak_start_minute", location="body", description="Off-peak window start, UTC minutes since midnight (0-1439). Must be set together with off_peak_end_minute.", required=False, type="integer"), + Param(name="off_peak_end_minute", location="body", description="Off-peak window end, UTC minutes since midnight (0-1439). A value less than the start wraps past midnight.", required=False, type="integer"), + Param(name="off_peak_discount_pct", location="body", description="Percentage discount (0-100) applied to the active tier's rates during the off-peak window.", required=False, type="number"), Param(name="is_active", location="body", description="Whether the route is active ('1' or '0').", required=False, type="boolean"), ), ), diff --git a/devplacepy/services/devii/actions/catalog/profile.py b/devplacepy/services/devii/actions/catalog/profile.py index dd547ccf..c9c14d94 100644 --- a/devplacepy/services/devii/actions/catalog/profile.py +++ b/devplacepy/services/devii/actions/catalog/profile.py @@ -61,6 +61,21 @@ PROFILE_ACTIONS: tuple[Action, ...] = ( ), ), ), + Action( + name="give_award", + method="POST", + path="/profile/{username}/award", + summary="Give another member an award on their profile", + description=( + "Creates a pending award row and enqueues image generation billed to the giver's " + "API key. The receiver is notified when generation completes. Cannot award yourself; " + "blocked pairs are rejected; cooldowns apply." + ), + params=( + path("username", "Receiver username."), + body("description", "Short award message (1-125 characters).", required=True), + ), + ), Action( name="regenerate_avatar", method="POST", diff --git a/devplacepy/services/devii/actions/notification_actions.py b/devplacepy/services/devii/actions/notification_actions.py index a3dad7e2..b09ec999 100644 --- a/devplacepy/services/devii/actions/notification_actions.py +++ b/devplacepy/services/devii/actions/notification_actions.py @@ -18,7 +18,7 @@ def arg( TYPES = ( - "One of: comment, reply, mention, vote, follow, message, badge, level, issue." + "One of: comment, reply, mention, vote, follow, message, badge, level, issue, award." ) NOTIFICATION_ACTIONS: tuple[Action, ...] = ( diff --git a/devplacepy/services/devii/fetch/controller.py b/devplacepy/services/devii/fetch/controller.py index a9d1d14f..a48cb8eb 100644 --- a/devplacepy/services/devii/fetch/controller.py +++ b/devplacepy/services/devii/fetch/controller.py @@ -21,32 +21,6 @@ from ..text import html_to_text logger = logging.getLogger("devii.fetch") -CHROME_VERSION = "131" -CHROME_VERSION_FULL = "131.0.0.0" -STEALTH_HEADERS = { - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " - f"(KHTML, like Gecko) Chrome/{CHROME_VERSION_FULL} Safari/537.36" - ), - "Accept": ( - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp," - "image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" - ), - "Accept-Language": "en-US,en;q=0.9", - "sec-ch-ua": ( - f'"Google Chrome";v="{CHROME_VERSION}", "Chromium";v="{CHROME_VERSION}", ' - '"Not_A Brand";v="24"' - ), - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Linux"', - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", - "Cache-Control": "max-age=0", - "DNT": "1", -} TITLE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) MIN_FETCH_CHARS = 1000 ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") @@ -230,9 +204,7 @@ class FetchController: raise_5xx: bool = False, ) -> tuple[str, str, str, int, dict[str, str]]: limit = self._settings.fetch_max_bytes - merged_headers = dict(STEALTH_HEADERS) - if headers: - merged_headers.update({str(k): str(v) for k, v in headers.items()}) + merged_headers = {str(k): str(v) for k, v in headers.items()} if headers else {} request_kwargs: dict[str, Any] = {} if json_body is not None: request_kwargs["json"] = json_body @@ -240,43 +212,48 @@ class FetchController: request_kwargs["data"] = form elif content is not None: request_kwargs["content"] = content + + async def _once(client: httpx.AsyncClient, target: str) -> tuple[str, str, str, int, dict[str, str]]: + async with client.stream(method, target, **request_kwargs) as response: + if raise_5xx and response.status_code >= 500: + raise UpstreamError( + f"Server returned {response.status_code}.", + status=response.status_code, + url=target, + ) + chunks: list[bytes] = [] + total = 0 + async for chunk in response.aiter_bytes(): + chunks.append(chunk) + total += len(chunk) + if total >= limit: + break + raw = b"".join(chunks)[:limit] + encoding = response.encoding or "utf-8" + try: + body = raw.decode(encoding, errors="replace") + except LookupError: + body = raw.decode("utf-8", errors="replace") + content_type = response.headers.get("content-type", "").lower() + response_headers = {key: value for key, value in response.headers.items()} + return body, str(response.url), content_type, response.status_code, response_headers + try: async with stealth.stealth_async_client( headers=merged_headers, follow_redirects=True, timeout=self._settings.fetch_timeout_seconds, ) as client: - async with client.stream(method, url, **request_kwargs) as response: - if raise_5xx and response.status_code >= 500: - raise UpstreamError( - f"Server returned {response.status_code}.", - status=response.status_code, - url=url, - ) - chunks: list[bytes] = [] - total = 0 - async for chunk in response.aiter_bytes(): - chunks.append(chunk) - total += len(chunk) - if total >= limit: - break - raw = b"".join(chunks)[:limit] - encoding = response.encoding or "utf-8" - try: - body = raw.decode(encoding, errors="replace") - except LookupError: - body = raw.decode("utf-8", errors="replace") - content_type = response.headers.get("content-type", "").lower() - response_headers = { - key: value for key, value in response.headers.items() - } - return ( - body, - str(response.url), - content_type, - response.status_code, - response_headers, - ) + result = await _once(client, url) + if method == "GET" and "html" in result[2]: + gate_url = stealth.detect_consent_gate(result[0]) + if gate_url: + try: + await client.get(gate_url) + result = await _once(client, url) + except httpx.HTTPError as exc: + logger.debug("consent gate follow-up failed for %s: %s", url, exc) + return result except httpx.TimeoutException as exc: raise NetworkError(f"Request timed out fetching {url}", url=url) from exc except httpx.HTTPError as exc: diff --git a/devplacepy/services/jobs/award_service.py b/devplacepy/services/jobs/award_service.py new file mode 100644 index 00000000..12111ce0 --- /dev/null +++ b/devplacepy/services/jobs/award_service.py @@ -0,0 +1,207 @@ +# retoor + +import asyncio +import base64 +import json +import logging +from datetime import datetime, timezone +from io import BytesIO + +from devplacepy import stealth +from devplacepy.attachments import link_attachments, store_attachment +from devplacepy.awards.images import enforce_rgba_png, resize_award_png +from devplacepy.config import ( + AWARD_GENERATION_TIMEOUT_SECONDS, + AWARD_IMAGE_MODEL_DEFAULT, + AWARD_IMAGE_PROMPT_DEFAULT, + AWARD_IMAGE_SIZE_DEFAULT, + INTERNAL_BASE_URL, +) +from devplacepy.database import add_award_usage, get_award_usage, get_setting, get_table +from devplacepy.database.awards import recompute_user_award_stats +from devplacepy.services.base import ConfigField +from devplacepy.services.correction import new_usage_totals +from devplacepy.services.jobs.base import JobService +from devplacepy.services.openai_gateway.usage import accumulate_usage, usage_metric_cards +from devplacepy.utils import create_notification + +logger = logging.getLogger(__name__) + + +class AwardService(JobService): + kind = "award" + title = "Awards" + description = ( + "Generates award images off the request path, stores PNG attachments, and " + "notifies receivers when ready." + ) + + def __init__(self): + super().__init__(name="award", interval_seconds=2) + self.config_fields = list(self.config_fields) + [ + ConfigField( + "award_give_cooldown_hours", + "Give cooldown (hours)", + type="int", + default=24, + minimum=1, + maximum=168, + group="Awards", + ), + ConfigField( + "award_receive_cooldown_hours", + "Receive cooldown (hours)", + type="int", + default=24, + minimum=1, + maximum=168, + group="Awards", + ), + ConfigField( + "award_display_hours", + "Prominent display (hours)", + type="int", + default=24, + minimum=1, + maximum=168, + group="Awards", + ), + ConfigField( + "award_image_model", + "Image model", + type="str", + default=AWARD_IMAGE_MODEL_DEFAULT, + group="Awards", + ), + ConfigField( + "award_image_prompt", + "Image prompt", + type="text", + default=AWARD_IMAGE_PROMPT_DEFAULT, + group="Awards", + ), + ] + + async def process(self, job: dict) -> dict: + totals = new_usage_totals() + try: + result = await asyncio.to_thread(self._generate, job, totals) + except Exception as exc: + self._audit_failed(job, str(exc)) + raise + if totals["calls"]: + add_award_usage(totals) + return result + + def _generate(self, job: dict, totals: dict) -> dict: + from devplacepy.services.audit import record as audit + + payload = job.get("payload") or {} + award_uid = str(payload.get("award_uid", "")) + giver_uid = str(payload.get("giver_uid", "")) + receiver_uid = str(payload.get("receiver_uid", "")) + description = str(payload.get("description", "")) + api_key = str(payload.get("api_key", "")) + awards = get_table("awards") + row = awards.find_one(uid=award_uid) + if not row or row.get("deleted_at") or row.get("generated_at"): + return {"award_uid": award_uid, "skipped": True} + source_png = self._fetch_image(api_key, description, totals) + source_png = enforce_rgba_png(source_png) + uid512 = store_attachment( + resize_award_png(source_png, 512), "award-512.png", receiver_uid + )["uid"] + uid256 = store_attachment( + resize_award_png(source_png, 256), "award-256.png", giver_uid + )["uid"] + uid64 = store_attachment( + resize_award_png(source_png, 64), "award-64.png", giver_uid + )["uid"] + link_attachments([uid512, uid256, uid64], "award", award_uid) + now = datetime.now(timezone.utc).isoformat() + awards.update( + { + "uid": award_uid, + "attachment_uid_512": uid512, + "attachment_uid_256": uid256, + "attachment_uid_64": uid64, + "generated_at": now, + }, + ["uid"], + ) + recompute_user_award_stats(receiver_uid) + users = get_table("users") + giver = users.find_one(uid=giver_uid) or {} + receiver = users.find_one(uid=receiver_uid) or {} + slug = row.get("slug", "") + create_notification( + receiver_uid, + "award", + f"@{giver.get('username', 'someone')} gave you an award", + giver_uid, + f"/profile/{receiver.get('username', '')}?tab=awards#award-{slug}", + ) + audit.record_system( + "award.complete", + actor_kind="service", + target_type="award", + target_uid=award_uid, + target_label=description[:80], + summary=f"Award ready for {receiver.get('username', receiver_uid)}", + links=[ + audit.target("award", award_uid, slug), + audit.target("user", receiver_uid, receiver.get("username")), + audit.target("user", giver_uid, giver.get("username")), + audit.job(job.get("uid", "")), + ], + ) + return {"award_uid": award_uid, "slug": slug} + + def _fetch_image(self, api_key: str, description: str, totals: dict) -> bytes: + prompt = get_setting("award_image_prompt", AWARD_IMAGE_PROMPT_DEFAULT) + model = get_setting("award_image_model", AWARD_IMAGE_MODEL_DEFAULT) + size = AWARD_IMAGE_SIZE_DEFAULT + body = { + "model": model, + "prompt": f"{prompt}\n\n{description}".strip(), + "size": size, + "background": "transparent", + "output_format": "png", + } + url = f"{INTERNAL_BASE_URL}/openai/v1/images/generations" + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + with stealth.stealth_sync_client(timeout=AWARD_GENERATION_TIMEOUT_SECONDS) as client: + response = client.post(url, json=body, headers=headers) + accumulate_usage(totals, response) + response.raise_for_status() + data = response.json() + items = data.get("data") or [] + if not items: + raise RuntimeError("image API returned no data") + encoded = items[0].get("b64_json") or "" + if not encoded: + raise RuntimeError("image API returned no b64_json") + return base64.b64decode(encoded) + + def _audit_failed(self, job: dict, error: str) -> None: + from devplacepy.services.audit import record as audit + + payload = job.get("payload") or {} + award_uid = str(payload.get("award_uid", "")) + audit.record_system( + "award.failed", + actor_kind="service", + target_type="award", + target_uid=award_uid, + summary=f"Award generation failed: {error[:80]}", + metadata={"error": error[:500], "award_uid": award_uid}, + result="failure", + links=[audit.job(job.get("uid", "")), audit.target("award", award_uid)], + ) + + def collect_metrics(self) -> dict: + base = super().collect_metrics() + base["stats"] = base.get("stats", []) + usage_metric_cards(get_award_usage()) + return base \ No newline at end of file diff --git a/devplacepy/services/openai_gateway/CLAUDE.md b/devplacepy/services/openai_gateway/CLAUDE.md index 1bd3455f..db7bf307 100644 --- a/devplacepy/services/openai_gateway/CLAUDE.md +++ b/devplacepy/services/openai_gateway/CLAUDE.md @@ -52,6 +52,10 @@ The gateway records one row per upstream call (chat, vision, passthrough) and su **Financial data is admin-only everywhere.** Any monetary figure (USD cost, pricing, spend, limit) is restricted to administrators; members and guests see only the percentage of quota used - this rule is enforced consistently across the profile card, `ai_correction`/`ai_modifier` usage displays, and the Devii cost tools. +## Image generation + +`POST /openai/v1/images/generations` exposes an OpenAI-compatible image-generation endpoint. Clients send the generic model `molodetz-img-small` (`config.INTERNAL_IMAGE_MODEL`), which `handle_images` remaps to `gateway_image_model` exactly like chat remaps `molodetz` -> `gateway_model` (also remapped when `gateway_force_model` is on or the model is empty or `molodetz-img`). It defaults to OpenRouter's `black-forest-labs/flux-1.1-pro` at `https://openrouter.ai/api/v1/images/generations` (`config.IMAGE_*_DEFAULT`, $0.04 per image fallback). `handle_images` mirrors `handle_embeddings`: build the payload, forward via `_send`, and record one ledger row. The config fields are the **Images** group (`gateway_image_enabled` default on, `gateway_image_url`, `gateway_image_model`, `gateway_image_key`) plus the Pricing-group `gateway_image_price_per_call`. `effective_config()` falls the image key back to `gateway_api_key` then `OPENROUTER_API_KEY`. Usage is recorded with **`backend="image"`**; `usage.compute_cost` adds an `image` branch (flat per-call, native OpenRouter `cost` still preferred via `extract_image_usage`). `routing.image_overlay` resolves per-route provider/url/key and uses `price_input_per_m` as the per-image price. `routing.seed_default_image_routes()` (from `migrate_ai_gateway_settings`) idempotently seeds `molodetz-img-small` -> Flux on the `openrouter` provider when `OPENROUTER_API_KEY` is set. When `gateway_image_enabled` is off the endpoint returns 503 with no ledger row. + ## Embeddings `POST /openai/v1/embeddings` exposes an OpenAI-compatible text-embeddings model. Clients send the generic model `molodetz~embed` (`config.INTERNAL_EMBED_MODEL`), which `handle_embeddings` remaps to `gateway_embed_model` exactly like chat remaps `molodetz` -> `gateway_model` (also remapped when `gateway_force_model` is on or the model is empty). It defaults to OpenRouter's `qwen/qwen3-embedding-8b` at `https://openrouter.ai/api/v1/embeddings` (`config.EMBED_*_DEFAULT`, $0.01 per 1M input tokens). `handle_embeddings` mirrors `handle_chat` but is simpler: no vision augmentation and no streaming - build the payload, forward via `_send`, and record one ledger row through the same `finalize(...)` closure. The config fields are the **Embeddings** group (`gateway_embed_enabled` default on, `gateway_embed_url`, `gateway_embed_model`, `gateway_embed_key`) plus the Pricing-group `gateway_embed_price_input_per_m`. `effective_config()` falls the embed key back to `gateway_vision_key` then `OPENROUTER_API_KEY` (NOT `gateway_api_key`: that is the DeepSeek chat upstream key, whereas embeddings target OpenRouter like vision does). Usage is recorded with **`backend="embed"`**; `usage.compute_cost` adds an `embed` branch (input-only, completion always 0, native OpenRouter `cost` still preferred) and `Pricing` gained `embed_input_per_m`. `analytics.py` groups by `backend` generically, so embed rows roll up automatically; `caching_savings` counts only **non-native** chat rows (native-priced rows did not use the configured cache-hit/miss rates, so folding them in would report a fictional saving). When `gateway_embed_enabled` is off the endpoint returns 503 with no ledger row. @@ -75,11 +79,11 @@ Layered ON TOP of the single-provider service config above, which stays THE impl **Storage.** Two dataset tables, ensured in `init_db` via `routing.ensure_tables`, cross-worker cache-invalidated under the `"gateway_routing"` cache-version name (module-level `provider_store`/`model_store` over a shared `_ROUTING_CACHE`; writes `bump_cache_version` and clear the cache). They are admin config, NOT in `SOFT_DELETE_TABLES` - hard CRUD, mirroring `site_settings`: - `gateway_providers` - named upstreams: `name` + `base_url` (chat-completions URL) + `api_key` + `is_active`; the embeddings URL is derived by swapping `/chat/completions` -> `/embeddings`. -- `gateway_models` - source->target routes: `source_model` (unique, what clients request) -> `provider` (blank = default) + `target_model` + `kind` (chat|embed) + optional `vision_provider`/`vision_model` for the **text+vision merge** (when set, image content is described by that vision model before forwarding) + `context_window` + its **own economy** (`price_cache_hit_per_m`/`price_cache_miss_per_m`/`price_output_per_m`/`price_input_per_m`, USD per 1M tokens) + `is_active`. +- `gateway_models` - source->target routes: `source_model` (unique, what clients request) -> `provider` (blank = default) + `target_model` + `kind` (chat|embed|image) + optional `vision_provider`/`vision_model` for the **text+vision merge** (when set, image content is described by that vision model before forwarding) + `context_window` + its **own economy** (`price_cache_hit_per_m`/`price_cache_miss_per_m`/`price_output_per_m`/`price_input_per_m`, USD per 1M tokens for chat/embed/vision; for image routes `price_input_per_m` is a flat USD per image, plus the tiered/off-peak fields below) + `is_active`. -**Resolution is a per-request overlay, not a fork.** At request time `routing.chat_overlay(requested, cfg)` / `routing.embed_overlay(requested, cfg)` resolve an active route by the requested model name and return a per-request OVERLAY dict of `gateway_*` cfg keys (`gateway_force_model`+`gateway_model`=target, `gateway_upstream_url`/`gateway_api_key` from the provider, the price keys, an augmented `gateway_model_context_map`, and vision overlay keys); `handle_chat`/`handle_embeddings` merge it onto the base cfg (`cfg = {**cfg, **overlay}`) BEFORE everything else, so the existing model-selection / `pricing_from_cfg` / `parse_context_map` / vision / url+key paths transparently use the route's provider, target model, pricing, vision model and context window. `_ensure` (the httpx pool / semaphore / breaker / vision cache) reads only the non-overlaid pool keys, so the connection pool is never churned per request. +**Resolution is a per-request overlay, not a fork.** At request time `routing.chat_overlay(requested, cfg)` / `routing.embed_overlay(requested, cfg)` / `routing.image_overlay(requested, cfg)` resolve an active route by the requested model name and return a per-request OVERLAY dict of `gateway_*` cfg keys (`gateway_force_model`+`gateway_model`=target, `gateway_upstream_url`/`gateway_api_key` from the provider, the price keys, an augmented `gateway_model_context_map`, and vision overlay keys); `handle_chat`/`handle_embeddings` merge it onto the base cfg (`cfg = {**cfg, **overlay}`) BEFORE everything else, so the existing model-selection / `pricing_from_cfg` / `parse_context_map` / vision / url+key paths transparently use the route's provider, target model, pricing, vision model and context window. `_ensure` (the httpx pool / semaphore / breaker / vision cache) reads only the non-overlaid pool keys, so the connection pool is never churned per request. -**No matching route = `None` overlay = byte-identical legacy behavior** - this is the "nobody feels the transformation" guarantee: `molodetz`/`molodetz~embed` and every existing caller are byte-identical when no route matches. A route with a blank provider overlays only model+pricing(+vision), keeping the default upstream url/key. +**No matching route = `None` overlay = byte-identical legacy behavior** - this is the "nobody feels the transformation" guarantee: `molodetz`/`molodetz~embed`/`molodetz-img-small` and every existing caller are byte-identical when no route matches. A route with a blank provider overlays only model+pricing(+vision), keeping the default upstream url/key. **CRUD.** Admin JSON at `/admin/gateway/{providers,models}` (`routers/admin/gateway_configs.py`, `require_admin`, Pydantic `ProviderIn`/`ModelRouteIn` validation, accepts both JSON from `static/js/GatewayAdmin.js` and form from Devii), audited under `gateway.provider.*`/`gateway.model.*` (category `ai`), included in the admin package with the page at `/admin/gateway` (`templates/admin_gateway.html`, sidebar link, `admin_section="gateway"`). @@ -88,3 +92,13 @@ Layered ON TOP of the single-provider service config above, which stays THE impl When adding a routed value, overlay it as the matching `gateway_*` cfg key so the runtime needs no new branch. **Tests:** `tests/unit/services/openai_gateway/routing.py` (overlay/economy/kind isolation), `tests/unit/services/openai_gateway/gateway.py::test_model_route_overrides_upstream` (end-to-end through `handle_chat`), and `tests/api/admin/gateway/` (admin CRUD + validation + role gating). + +## Tiered (context-length) and off-peak pricing (model-agnostic variable pricing) + +Real providers sometimes charge more than a flat per-1M rate for one component: a rate that jumps once a request crosses a context-length threshold, or a fixed time-of-day discount window. This is layered on top of the cache-hit/cache-miss/output shape (which is already exactly DeepSeek's real billing model - see below), on the SAME `gateway_models` route row, so it stays fully provider-agnostic and opt-in per route. + +- **Per-route fields** (each optional/zero by default = feature off, so an unconfigured route is byte-identical to before): `context_tier_threshold_tokens` (0 disables tiering; when the request's input token count exceeds it, `price_*_per_m_tier2` rates apply instead of the tier-1 rates above) and the four nullable `price_cache_hit_per_m_tier2`/`price_cache_miss_per_m_tier2`/`price_output_per_m_tier2`/`price_input_per_m_tier2` (a component left `None` keeps its tier-1 rate even above threshold - so a provider that only re-prices input above a size threshold, keeping output flat, needs just one tier2 field set), plus `off_peak_start_minute`/`off_peak_end_minute` (nullable, UTC minutes-since-midnight, both-or-neither enforced by `ModelRouteIn`'s model validator; a window where start > end wraps past midnight, e.g. DeepSeek's old V3/R1-era 16:30-00:30 UTC window) and `off_peak_discount_pct` (0-100, multiplies whichever tier rate is active). The same fields double for vision (`price_input/output_per_m_tier2`) and embed (`price_input_per_m_tier2`) exactly like their tier-1 counterparts already do. +- **Overlay + computation.** `chat_overlay`/`embed_overlay` propagate all of these into the per-request cfg dict under `gateway_*` keys exactly like the existing price fields (section above); `usage.pricing_from_cfg` reads them generically (defaulting to `None`/`0`/disabled when absent), so **Layer A (the single global flat Pricing config fields) never gains these dimensions** - only a `gateway_models` route can enable them, preserving the "no matching route = byte-identical legacy behavior" guarantee. `usage.compute_cost` selects tier1 vs tier2 per rate component (`_tiered_rate`) based on whether `norm["prompt"]` (billable input tokens) exceeds the threshold, then applies the off-peak discount (`_effective_rate`/`_off_peak_active`, UTC wraparound-aware) to whichever rate was selected. **The response header format and `compute_cost`'s return shape (`total, input_cost, output_cost, native`) are unchanged** - this is purely an internal rate-selection step before the existing input/output split math runs; a native upstream `cost` (OpenRouter) still overrides the modeled total exactly as before. +- **Migration.** New `gateway_models` columns are added via `has_column`/`create_column_by_example` in `routing.ensure_tables()` (the `CREATE TABLE IF NOT EXISTS` DDL string alone would never reach a pre-existing table - see the `database/CLAUDE.md` column-ensure idiom). +- **Admin UI.** `/admin/gateway`'s model-route form has a "Tiered / off-peak pricing (optional)" subsection; off-peak start/end render as `` (converted to/from UTC minutes-of-day by `GatewayAdmin.js`), and the routes table shows `tiered`/`off-peak` badges when a route has either dimension configured. +- **DeepSeek's real pricing is already the tier-1 shape, not a new dimension.** DeepSeek's actual API (verified against `api-docs.deepseek.com/quick_start/pricing`) bills three flat per-1M rates - cache-hit input, cache-miss input, output - with no current context-length tier or off-peak window for the V4 models; that shape was already fully modeled by the pre-existing `chat_cache_hit_per_m`/`chat_cache_miss_per_m`/`chat_output_per_m` fields before this section's tier2/off-peak fields existed. `routing.seed_default_deepseek_routes()` (called once from `database.migrate_ai_gateway_settings()` at the end of `init_db()`) idempotently inserts two ready-made routes - `deepseek-v4-flash` (`$0.0028`/`$0.14`/`$0.28` per 1M, 1M context) and `deepseek-v4-pro` (`$0.003625`/`$0.435`/`$0.87` per 1M, 1M context) - only when that `source_model` row does not already exist, so a caller or Devii can request either name explicitly and get correctly-priced, decoupled from whatever the single global `gateway_model` default happens to be set to (switching that global setting between the two real models does NOT retroactively fix the flat Pricing config fields - the seeded routes are the model-agnostic, always-correct way to reference a specific priced model). Neither seeded route sets the tier2/off-peak fields (DeepSeek does not use them today); an admin can add them later on the same row if DeepSeek (or any other provider routed here) introduces such pricing. diff --git a/devplacepy/services/openai_gateway/config.py b/devplacepy/services/openai_gateway/config.py index 361fc524..9318accf 100644 --- a/devplacepy/services/openai_gateway/config.py +++ b/devplacepy/services/openai_gateway/config.py @@ -15,6 +15,10 @@ VISION_CACHE_SIZE_DEFAULT = 256 EMBED_URL_DEFAULT = "https://openrouter.ai/api/v1/embeddings" EMBED_MODEL_DEFAULT = "qwen/qwen3-embedding-8b" +IMAGE_URL_DEFAULT = "https://openrouter.ai/api/v1/images" +IMAGE_MODEL_DEFAULT = "black-forest-labs/flux.2-pro" +IMAGE_PRICE_PER_CALL_DEFAULT = 0.04 + VISION_INSTRUCTION = ( "Describe this image in detail. Note objects, people, scene, any visible " "text, layout, colors, and anything else that could be relevant for " diff --git a/devplacepy/services/openai_gateway/gateway.py b/devplacepy/services/openai_gateway/gateway.py index 230d726b..0b716be8 100644 --- a/devplacepy/services/openai_gateway/gateway.py +++ b/devplacepy/services/openai_gateway/gateway.py @@ -13,11 +13,16 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse from devplacepy import stealth from devplacepy.services.openai_gateway import config from devplacepy.services.openai_gateway.reliability import CircuitBreaker, retry_send -from devplacepy.services.openai_gateway.routing import chat_overlay, embed_overlay +from devplacepy.services.openai_gateway.routing import ( + chat_overlay, + embed_overlay, + image_overlay, +) from devplacepy.services.openai_gateway.system_message import apply_system_directives from devplacepy.services.openai_gateway.usage import ( GatewayUsageLedger, classify_error, + extract_image_usage, extract_params, parse_context_map, pricing_from_cfg, @@ -106,6 +111,7 @@ class GatewayRuntime: self.peak_in_flight = 0 self.vision_calls = 0 self.embed_calls = 0 + self.image_calls = 0 self.last_status = 0 self.last_latency_ms = 0 @@ -537,6 +543,171 @@ class GatewayRuntime: log(f"embed POST -> 200 ({timing['upstream_latency_ms']:.0f}ms)") return JSONResponse(content=data, headers=resp_headers) + async def handle_images( + self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None + ): + log = log or (lambda message: None) + overlay = image_overlay(body.get("model"), cfg) + if overlay: + cfg = {**cfg, **overlay} + log( + f"routed image model {body.get('model')!r} -> {cfg['gateway_image_model']!r}" + ) + if not cfg["gateway_image_enabled"]: + from devplacepy.services.audit import record as audit + from devplacepy.services.openai_gateway.usage import audit_actor_for + + actor_kind, actor_uid, actor_role = audit_actor_for(owner[0], owner[1]) + audit.record_system( + "ai.gateway.call", + actor_kind=actor_kind, + actor_uid=actor_uid, + actor_role=actor_role, + origin="api", + result="denied", + summary="image generation disabled", + metadata={ + "backend": "image", + "endpoint": "images/generations", + "owner_kind": owner[0], + "owner_id": owner[1], + }, + ) + return JSONResponse( + status_code=503, + content={ + "error": { + "message": "Image generation is disabled", + "type": "images_disabled", + } + }, + ) + client, sem = self._ensure(cfg) + pricing = pricing_from_cfg(cfg) + context_map = parse_context_map(cfg.get("gateway_model_context_map")) + params = extract_params(body) + handle_start = time.monotonic() + + requested = body.get("model") + if ( + cfg["gateway_force_model"] + or not requested + or requested in ("molodetz-img-small", "molodetz-img") + ): + model = cfg["gateway_image_model"] + else: + model = requested + + payload = dict(body) + payload["model"] = model + + headers = {"Content-Type": "application/json"} + if cfg["gateway_image_key"]: + headers["Authorization"] = f"Bearer {cfg['gateway_image_key']}" + else: + log( + "No upstream image API key configured (gateway_image_key / gateway_api_key / OPENROUTER_API_KEY); upstream will likely reject the request" + ) + + resp, exc, timing = await self._send( + client, + sem, + "POST", + cfg["gateway_image_url"], + headers, + cfg, + log, + json_body=payload, + ) + + base = { + "owner_kind": owner[0], + "owner_id": owner[1], + "backend": "image", + "endpoint": "images/generations", + "model": model, + "user_agent": user_agent, + **params, + **timing, + } + + def finalize(status_code, success, category, usage=None): + base["total_latency_ms"] = round( + (time.monotonic() - handle_start) * 1000, 3 + ) + base["gateway_overhead_ms"] = round( + max( + base["total_latency_ms"] + - timing["upstream_latency_ms"] + - timing["queue_wait_ms"], + 0.0, + ), + 3, + ) + base["status_code"] = status_code + base["success"] = success + base["error_category"] = category + base["usage"] = usage + row = self._ledger.record(base, pricing, context_map) + return usage_response_headers(row) + + if timing["circuit_open"]: + resp_headers = finalize(503, False, "circuit_open") + return JSONResponse( + status_code=503, + content={ + "error": { + "message": "Upstream temporarily unavailable", + "type": "circuit_open", + } + }, + headers=resp_headers, + ) + if exc is not None: + resp_headers = finalize(502, False, classify_error(0, exc)) + return JSONResponse( + status_code=502, + content={ + "error": { + "message": f"Upstream connection failed: {exc}", + "type": "upstream_error", + } + }, + headers=resp_headers, + ) + if resp.status_code != 200: + resp_headers = finalize( + resp.status_code, + False, + classify_error(resp.status_code, None, resp.text), + ) + log(f"image upstream POST -> {resp.status_code}: {resp.text[:300]}") + return JSONResponse( + status_code=resp.status_code, + content={"error": {"message": resp.text, "type": "upstream_error"}}, + headers=resp_headers, + ) + try: + data = resp.json() + except ValueError: + self.errors += 1 + resp_headers = finalize(502, False, "gateway") + log("image upstream returned 200 but body was not valid JSON") + return JSONResponse( + status_code=502, + content={ + "error": { + "message": "invalid upstream response", + "type": "upstream_error", + } + }, + headers=resp_headers, + ) + self.image_calls += 1 + resp_headers = finalize(200, True, None, extract_image_usage(data)) + log(f"image POST -> 200 ({timing['upstream_latency_ms']:.0f}ms)") + return JSONResponse(content=data, headers=resp_headers) + async def handle_passthrough( self, method: str, @@ -661,6 +832,7 @@ class GatewayRuntime: "peak_in_flight": self.peak_in_flight, "vision_calls": self.vision_calls, "embed_calls": self.embed_calls, + "image_calls": self.image_calls, "last_status": self.last_status, "last_latency_ms": self.last_latency_ms, "pool": self._instances, diff --git a/devplacepy/services/openai_gateway/routing.py b/devplacepy/services/openai_gateway/routing.py index baebb3c4..8a8cb1ab 100644 --- a/devplacepy/services/openai_gateway/routing.py +++ b/devplacepy/services/openai_gateway/routing.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from typing import Optional -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from devplacepy.database import ( bump_cache_version, @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) PROVIDERS_TABLE = "gateway_providers" MODELS_TABLE = "gateway_models" CACHE_NAME = "gateway_routing" -KINDS = ("chat", "embed") +KINDS = ("chat", "embed", "image") _ROUTING_CACHE: dict = {} @@ -47,6 +47,27 @@ def _embed_url_from_base(base_url: str) -> str: return base_url +def _image_url_from_base(base_url: str) -> str: + base_url = (base_url or "").strip() + if not base_url: + return "" + if base_url.endswith("/chat/completions"): + return base_url[: -len("/chat/completions")] + "/images" + return base_url + + +MODEL_TIER2_COLUMNS = ( + "context_tier_threshold_tokens", + "price_cache_hit_per_m_tier2", + "price_cache_miss_per_m_tier2", + "price_output_per_m_tier2", + "price_input_per_m_tier2", + "off_peak_start_minute", + "off_peak_end_minute", + "off_peak_discount_pct", +) + + def ensure_tables() -> None: db.query( "CREATE TABLE IF NOT EXISTS " @@ -62,8 +83,17 @@ def ensure_tables() -> None: "vision_model TEXT, context_window INTEGER DEFAULT 0, " "price_cache_hit_per_m REAL DEFAULT 0, price_cache_miss_per_m REAL DEFAULT 0, " "price_output_per_m REAL DEFAULT 0, price_input_per_m REAL DEFAULT 0, " + "context_tier_threshold_tokens INTEGER DEFAULT 0, " + "price_cache_hit_per_m_tier2 REAL, price_cache_miss_per_m_tier2 REAL, " + "price_output_per_m_tier2 REAL, price_input_per_m_tier2 REAL, " + "off_peak_start_minute INTEGER, off_peak_end_minute INTEGER, " + "off_peak_discount_pct REAL DEFAULT 0, " "is_active INTEGER DEFAULT 1, created_at TEXT, updated_at TEXT)" ) + models_table = get_table(MODELS_TABLE) + for column in MODEL_TIER2_COLUMNS: + if not models_table.has_column(column): + models_table.create_column_by_example(column, 0.0) try: db.query( "CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_providers_name ON " @@ -121,6 +151,14 @@ class ModelRouteIn(BaseModel): price_cache_miss_per_m: float = Field(default=0.0, ge=0) price_output_per_m: float = Field(default=0.0, ge=0) price_input_per_m: float = Field(default=0.0, ge=0) + context_tier_threshold_tokens: int = Field(default=0, ge=0, le=100_000_000) + price_cache_hit_per_m_tier2: Optional[float] = Field(default=None, ge=0) + price_cache_miss_per_m_tier2: Optional[float] = Field(default=None, ge=0) + price_output_per_m_tier2: Optional[float] = Field(default=None, ge=0) + price_input_per_m_tier2: Optional[float] = Field(default=None, ge=0) + off_peak_start_minute: Optional[int] = Field(default=None, ge=0, le=1439) + off_peak_end_minute: Optional[int] = Field(default=None, ge=0, le=1439) + off_peak_discount_pct: float = Field(default=0.0, ge=0, le=100) is_active: bool = True @field_validator("source_model", "target_model") @@ -141,9 +179,19 @@ class ModelRouteIn(BaseModel): def _clean_kind(cls, value: str) -> str: value = (value or "chat").strip().lower() if value not in KINDS: - raise ValueError("Kind must be 'chat' or 'embed'") + raise ValueError("Kind must be 'chat', 'embed', or 'image'") return value + @model_validator(mode="after") + def _check_off_peak_window(self) -> "ModelRouteIn": + has_start = self.off_peak_start_minute is not None + has_end = self.off_peak_end_minute is not None + if has_start != has_end: + raise ValueError( + "Off-peak start and end minute must both be set, or both left blank" + ) + return self + @dataclass(frozen=True) class ModelRoute: @@ -158,9 +206,27 @@ class ModelRoute: price_cache_miss_per_m: float price_output_per_m: float price_input_per_m: float + context_tier_threshold_tokens: int + price_cache_hit_per_m_tier2: Optional[float] + price_cache_miss_per_m_tier2: Optional[float] + price_output_per_m_tier2: Optional[float] + price_input_per_m_tier2: Optional[float] + off_peak_start_minute: Optional[int] + off_peak_end_minute: Optional[int] + off_peak_discount_pct: float is_active: bool +def _opt_float(row: dict, key: str) -> Optional[float]: + value = row.get(key) + return float(value) if value is not None else None + + +def _opt_int(row: dict, key: str) -> Optional[int]: + value = row.get(key) + return int(value) if value is not None else None + + def _route_from_row(row: dict) -> ModelRoute: return ModelRoute( source_model=str(row.get("source_model") or ""), @@ -174,6 +240,16 @@ def _route_from_row(row: dict) -> ModelRoute: price_cache_miss_per_m=float(row.get("price_cache_miss_per_m") or 0.0), price_output_per_m=float(row.get("price_output_per_m") or 0.0), price_input_per_m=float(row.get("price_input_per_m") or 0.0), + context_tier_threshold_tokens=int( + row.get("context_tier_threshold_tokens") or 0 + ), + price_cache_hit_per_m_tier2=_opt_float(row, "price_cache_hit_per_m_tier2"), + price_cache_miss_per_m_tier2=_opt_float(row, "price_cache_miss_per_m_tier2"), + price_output_per_m_tier2=_opt_float(row, "price_output_per_m_tier2"), + price_input_per_m_tier2=_opt_float(row, "price_input_per_m_tier2"), + off_peak_start_minute=_opt_int(row, "off_peak_start_minute"), + off_peak_end_minute=_opt_int(row, "off_peak_end_minute"), + off_peak_discount_pct=float(row.get("off_peak_discount_pct") or 0.0), is_active=_as_bool(row.get("is_active", 1)), ) @@ -291,6 +367,14 @@ class ModelStore: "price_cache_miss_per_m": payload.price_cache_miss_per_m, "price_output_per_m": payload.price_output_per_m, "price_input_per_m": payload.price_input_per_m, + "context_tier_threshold_tokens": payload.context_tier_threshold_tokens, + "price_cache_hit_per_m_tier2": payload.price_cache_hit_per_m_tier2, + "price_cache_miss_per_m_tier2": payload.price_cache_miss_per_m_tier2, + "price_output_per_m_tier2": payload.price_output_per_m_tier2, + "price_input_per_m_tier2": payload.price_input_per_m_tier2, + "off_peak_start_minute": payload.off_peak_start_minute, + "off_peak_end_minute": payload.off_peak_end_minute, + "off_peak_discount_pct": payload.off_peak_discount_pct, "is_active": 1 if payload.is_active else 0, "updated_at": _now(), } @@ -318,6 +402,63 @@ class ModelStore: provider_store = ProviderStore() model_store = ModelStore() +DEEPSEEK_DEFAULT_ROUTES = ( + { + "source_model": "deepseek-v4-flash", + "target_model": "deepseek-v4-flash", + "context_window": 1_048_576, + "price_cache_hit_per_m": 0.0028, + "price_cache_miss_per_m": 0.14, + "price_output_per_m": 0.28, + }, + { + "source_model": "deepseek-v4-pro", + "target_model": "deepseek-v4-pro", + "context_window": 1_048_576, + "price_cache_hit_per_m": 0.003625, + "price_cache_miss_per_m": 0.435, + "price_output_per_m": 0.87, + }, +) + + +def seed_default_deepseek_routes() -> None: + ensure_tables() + table = get_table(MODELS_TABLE) + seeded = False + for defaults in DEEPSEEK_DEFAULT_ROUTES: + if table.find_one(source_model=defaults["source_model"]): + continue + record = { + "source_model": defaults["source_model"], + "provider": "", + "target_model": defaults["target_model"], + "kind": "chat", + "vision_provider": "", + "vision_model": "", + "context_window": defaults["context_window"], + "price_cache_hit_per_m": defaults["price_cache_hit_per_m"], + "price_cache_miss_per_m": defaults["price_cache_miss_per_m"], + "price_output_per_m": defaults["price_output_per_m"], + "price_input_per_m": 0.0, + "context_tier_threshold_tokens": 0, + "price_cache_hit_per_m_tier2": None, + "price_cache_miss_per_m_tier2": None, + "price_output_per_m_tier2": None, + "price_input_per_m_tier2": None, + "off_peak_start_minute": None, + "off_peak_end_minute": None, + "off_peak_discount_pct": 0.0, + "is_active": 1, + "created_at": _now(), + "updated_at": _now(), + } + table.insert(record) + seeded = True + if seeded: + bump_cache_version(CACHE_NAME) + _ROUTING_CACHE.clear() + def _provider_overlay(name: str, base_key: str, url_key: str, overlay: dict) -> None: provider = provider_store.get(name) @@ -339,6 +480,13 @@ def chat_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[dic "gateway_price_cache_hit_per_m": route.price_cache_hit_per_m, "gateway_price_cache_miss_per_m": route.price_cache_miss_per_m, "gateway_price_output_per_m": route.price_output_per_m, + "gateway_price_cache_hit_per_m_tier2": route.price_cache_hit_per_m_tier2, + "gateway_price_cache_miss_per_m_tier2": route.price_cache_miss_per_m_tier2, + "gateway_price_output_per_m_tier2": route.price_output_per_m_tier2, + "gateway_context_tier_threshold_tokens": route.context_tier_threshold_tokens, + "gateway_off_peak_start_minute": route.off_peak_start_minute, + "gateway_off_peak_end_minute": route.off_peak_end_minute, + "gateway_off_peak_discount_pct": route.off_peak_discount_pct, } if route.provider: _provider_overlay( @@ -355,6 +503,12 @@ def chat_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[dic overlay["gateway_vision_model"] = route.vision_model overlay["gateway_vision_price_input_per_m"] = route.price_input_per_m overlay["gateway_vision_price_output_per_m"] = route.price_output_per_m + overlay["gateway_vision_price_input_per_m_tier2"] = ( + route.price_input_per_m_tier2 + ) + overlay["gateway_vision_price_output_per_m_tier2"] = ( + route.price_output_per_m_tier2 + ) vision_provider = route.vision_provider or route.provider if vision_provider: _provider_overlay( @@ -371,6 +525,11 @@ def embed_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[di "gateway_force_model": True, "gateway_embed_model": route.target_model, "gateway_embed_price_input_per_m": route.price_input_per_m, + "gateway_embed_price_input_per_m_tier2": route.price_input_per_m_tier2, + "gateway_context_tier_threshold_tokens": route.context_tier_threshold_tokens, + "gateway_off_peak_start_minute": route.off_peak_start_minute, + "gateway_off_peak_end_minute": route.off_peak_end_minute, + "gateway_off_peak_discount_pct": route.off_peak_discount_pct, } provider = provider_store.get(route.provider) if route.provider else None if provider: @@ -379,3 +538,129 @@ def embed_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[di if provider.get("api_key"): overlay["gateway_embed_key"] = provider["api_key"] return overlay + + +def image_overlay(requested_model: Optional[str], base_cfg: dict) -> Optional[dict]: + route = model_store.resolve(requested_model, "image") + if route is None: + return None + overlay: dict = { + "gateway_force_model": True, + "gateway_image_model": route.target_model, + "gateway_image_price_per_call": route.price_input_per_m, + "gateway_off_peak_start_minute": route.off_peak_start_minute, + "gateway_off_peak_end_minute": route.off_peak_end_minute, + "gateway_off_peak_discount_pct": route.off_peak_discount_pct, + } + provider = provider_store.get(route.provider) if route.provider else None + if provider: + if provider.get("base_url"): + overlay["gateway_image_url"] = _image_url_from_base(provider["base_url"]) + if provider.get("api_key"): + overlay["gateway_image_key"] = provider["api_key"] + return overlay + + +OPENROUTER_PROVIDER_NAME = "openrouter" +FLUX_TARGET_MODEL = "black-forest-labs/flux.2-pro" +IMAGE_SOURCE_MODEL = "molodetz-img-small" +RETIRED_IMAGE_MODELS = { + "black-forest-labs/flux-1.1-pro": FLUX_TARGET_MODEL, + "black-forest-labs/flux-schnell": "black-forest-labs/flux.2-klein-4b", +} +LEGACY_IMAGE_URL = "https://openrouter.ai/api/v1/images/generations" + + +def seed_default_image_routes() -> None: + import os + + from devplacepy.services.openai_gateway import config as gw_config + + ensure_tables() + openrouter_key = os.environ.get("OPENROUTER_API_KEY", "") + if openrouter_key: + existing_provider = provider_store.get(OPENROUTER_PROVIDER_NAME) + if existing_provider is None: + provider_store.set( + ProviderIn( + name=OPENROUTER_PROVIDER_NAME, + base_url="https://openrouter.ai/api/v1/chat/completions", + api_key=openrouter_key, + is_active=True, + ) + ) + models_table = get_table(MODELS_TABLE) + if models_table.find_one(source_model=IMAGE_SOURCE_MODEL): + return + record = { + "source_model": IMAGE_SOURCE_MODEL, + "provider": OPENROUTER_PROVIDER_NAME if openrouter_key else "", + "target_model": FLUX_TARGET_MODEL, + "kind": "image", + "vision_provider": "", + "vision_model": "", + "context_window": 0, + "price_cache_hit_per_m": 0.0, + "price_cache_miss_per_m": 0.0, + "price_output_per_m": 0.0, + "price_input_per_m": gw_config.IMAGE_PRICE_PER_CALL_DEFAULT, + "context_tier_threshold_tokens": 0, + "price_cache_hit_per_m_tier2": None, + "price_cache_miss_per_m_tier2": None, + "price_output_per_m_tier2": None, + "price_input_per_m_tier2": None, + "off_peak_start_minute": None, + "off_peak_end_minute": None, + "off_peak_discount_pct": 0.0, + "is_active": 1, + "created_at": _now(), + "updated_at": _now(), + } + models_table.insert(record) + bump_cache_version(CACHE_NAME) + _ROUTING_CACHE.clear() + + +def migrate_retired_image_gateway() -> None: + from devplacepy.database import get_setting, set_setting + from devplacepy.services.openai_gateway import config as gw_config + + ensure_tables() + models_table = get_table(MODELS_TABLE) + now = _now() + changed = False + for row in models_table.find(kind="image"): + target = str(row.get("target_model") or "") + replacement = RETIRED_IMAGE_MODELS.get(target) + if not replacement: + continue + models_table.update( + { + "source_model": row["source_model"], + "target_model": replacement, + "updated_at": now, + }, + ["source_model"], + ) + changed = True + image_url = get_setting("gateway_image_url", "") + if image_url in (LEGACY_IMAGE_URL, ""): + set_setting("gateway_image_url", gw_config.IMAGE_URL_DEFAULT) + changed = True + elif image_url.endswith("/images/generations"): + set_setting( + "gateway_image_url", + image_url[: -len("/generations")], + ) + changed = True + image_model = get_setting("gateway_image_model", "") + replacement = RETIRED_IMAGE_MODELS.get(image_model) + if replacement: + set_setting("gateway_image_model", replacement) + changed = True + elif not image_model: + set_setting("gateway_image_model", gw_config.IMAGE_MODEL_DEFAULT) + changed = True + if changed: + bump_cache_version(CACHE_NAME) + _ROUTING_CACHE.clear() diff --git a/devplacepy/services/openai_gateway/service.py b/devplacepy/services/openai_gateway/service.py index ff703343..55cfd59c 100644 --- a/devplacepy/services/openai_gateway/service.py +++ b/devplacepy/services/openai_gateway/service.py @@ -172,6 +172,38 @@ class GatewayService(BaseService): help="The key currently in use; falls back to the vision/OPENROUTER key on boot (embeddings default to OpenRouter). Editable.", group="Embeddings", ), + ConfigField( + "gateway_image_enabled", + "Image generation", + type="bool", + default=True, + help="Expose image generation at /openai/v1/images/generations.", + group="Images", + ), + ConfigField( + "gateway_image_url", + "Images URL", + type="url", + default=config.IMAGE_URL_DEFAULT, + help="OpenAI-compatible images/generations endpoint requests are forwarded to.", + group="Images", + ), + ConfigField( + "gateway_image_model", + "Images model", + type="str", + default=config.IMAGE_MODEL_DEFAULT, + help="Image model sent upstream. Clients request it as molodetz-img-small.", + group="Images", + ), + ConfigField( + "gateway_image_key", + "Images API key", + type="str", + default="", + help="The key currently in use; falls back to gateway_api_key / OPENROUTER_API_KEY on boot. Editable.", + group="Images", + ), ConfigField( "gateway_require_auth", "Require authentication", @@ -267,6 +299,15 @@ class GatewayService(BaseService): help="Fallback only; used when the embeddings upstream returns no native cost.", group="Pricing", ), + ConfigField( + "gateway_image_price_per_call", + "Images price / call ($)", + type="float", + default=config.IMAGE_PRICE_PER_CALL_DEFAULT, + minimum=0, + help="Fallback only; used when the image upstream returns no native cost.", + group="Pricing", + ), ConfigField( "gateway_rsearch_cost_per_call", "rsearch cost / call ($)", @@ -355,6 +396,17 @@ class GatewayService(BaseService): or cfg["gateway_vision_key"] or os.environ.get("OPENROUTER_API_KEY", "") ) + cfg["gateway_image_key"] = ( + cfg["gateway_image_key"] + or cfg["gateway_api_key"] + or os.environ.get("OPENROUTER_API_KEY", "") + ) + if not cfg.get("gateway_image_url"): + upstream = cfg.get("gateway_upstream_url", "") + if upstream.endswith("/chat/completions"): + cfg["gateway_image_url"] = ( + upstream[: -len("/chat/completions")] + "/images" + ) return cfg def authorize(self, request: Request) -> bool: @@ -429,6 +481,18 @@ class GatewayService(BaseService): return await runtime.handle_embeddings( body, cfg, owner, user_agent, self.log ) + if subpath == "images/generations" and request.method == "POST": + try: + body = await request.json() + except Exception: + self.log("Rejected images request: invalid JSON body") + raise HTTPException(status_code=400, detail="Invalid JSON body") + if not isinstance(body, dict): + self.log("Rejected images request: JSON body was not an object") + raise HTTPException(status_code=400, detail="Invalid JSON body") + return await runtime.handle_images( + body, cfg, owner, user_agent, self.log + ) body = await request.body() content_type = request.headers.get("content-type", "") return await runtime.handle_passthrough( @@ -473,6 +537,7 @@ class GatewayService(BaseService): "peak_in_flight": 0, "vision_calls": 0, "embed_calls": 0, + "image_calls": 0, "last_status": 0, "last_latency_ms": 0, "pool": 0, @@ -485,12 +550,14 @@ class GatewayService(BaseService): {"label": "In flight", "value": m["in_flight"]}, {"label": "Vision calls", "value": m["vision_calls"]}, {"label": "Embed calls", "value": m["embed_calls"]}, + {"label": "Image calls", "value": m["image_calls"]}, {"label": "Last status", "value": m["last_status"] or "-"}, {"label": "Last latency", "value": f"{m['last_latency_ms']} ms"}, {"label": "Pool size", "value": m["pool"]}, {"label": "Circuit", "value": "open" if m["circuit_open"] else "closed"}, {"label": "Model", "value": cfg["gateway_model"]}, {"label": "Embed model", "value": cfg["gateway_embed_model"]}, + {"label": "Image model", "value": cfg["gateway_image_model"]}, {"label": "Requests 24h", "value": s["requests"]}, {"label": "Success 24h", "value": f"{s['success_pct']}%"}, {"label": "Error rate 24h", "value": f"{s['error_pct']}%"}, diff --git a/devplacepy/services/openai_gateway/usage.py b/devplacepy/services/openai_gateway/usage.py index 40d2b44d..b890b1d2 100644 --- a/devplacepy/services/openai_gateway/usage.py +++ b/devplacepy/services/openai_gateway/usage.py @@ -36,6 +36,31 @@ class Pricing: vision_input_per_m: float vision_output_per_m: float embed_input_per_m: float + image_per_call: float = 0.0 + chat_cache_hit_per_m_tier2: Optional[float] = None + chat_cache_miss_per_m_tier2: Optional[float] = None + chat_output_per_m_tier2: Optional[float] = None + vision_input_per_m_tier2: Optional[float] = None + vision_output_per_m_tier2: Optional[float] = None + embed_input_per_m_tier2: Optional[float] = None + context_tier_threshold_tokens: int = 0 + off_peak_start_minute: Optional[int] = None + off_peak_end_minute: Optional[int] = None + off_peak_discount_pct: float = 0.0 + + +def _cfg_float(cfg: dict, key: str) -> Optional[float]: + value = cfg.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + return None + + +def _cfg_int(cfg: dict, key: str) -> Optional[int]: + value = cfg.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return int(value) + return None def pricing_from_cfg(cfg: dict) -> Pricing: @@ -71,6 +96,34 @@ def pricing_from_cfg(cfg: dict) -> Pricing: config.EMBED_PRICE_INPUT_PER_M_DEFAULT, ) ), + image_per_call=float( + cfg.get( + "gateway_image_price_per_call", + config.IMAGE_PRICE_PER_CALL_DEFAULT, + ) + ), + chat_cache_hit_per_m_tier2=_cfg_float( + cfg, "gateway_price_cache_hit_per_m_tier2" + ), + chat_cache_miss_per_m_tier2=_cfg_float( + cfg, "gateway_price_cache_miss_per_m_tier2" + ), + chat_output_per_m_tier2=_cfg_float(cfg, "gateway_price_output_per_m_tier2"), + vision_input_per_m_tier2=_cfg_float( + cfg, "gateway_vision_price_input_per_m_tier2" + ), + vision_output_per_m_tier2=_cfg_float( + cfg, "gateway_vision_price_output_per_m_tier2" + ), + embed_input_per_m_tier2=_cfg_float( + cfg, "gateway_embed_price_input_per_m_tier2" + ), + context_tier_threshold_tokens=int( + cfg.get("gateway_context_tier_threshold_tokens", 0) or 0 + ), + off_peak_start_minute=_cfg_int(cfg, "gateway_off_peak_start_minute"), + off_peak_end_minute=_cfg_int(cfg, "gateway_off_peak_end_minute"), + off_peak_discount_pct=float(cfg.get("gateway_off_peak_discount_pct", 0.0) or 0.0), ) @@ -88,6 +141,18 @@ def parse_context_map(raw: Any) -> dict[str, int]: return dict(config.MODEL_CONTEXT_MAP_DEFAULT) +def extract_image_usage(data: Optional[dict]) -> dict: + data = data or {} + usage = data.get("usage") if isinstance(data.get("usage"), dict) else {} + cost = usage.get("cost") + if cost is None: + cost = data.get("cost") + result: dict = {} + if isinstance(cost, (int, float)) and not isinstance(cost, bool): + result["cost"] = float(cost) + return result + + def normalize_usage(usage: Optional[dict]) -> dict: usage = usage or {} prompt = int(usage.get("prompt_tokens", 0) or 0) @@ -117,21 +182,108 @@ def normalize_usage(usage: Optional[dict]) -> dict: } +def _off_peak_active(pricing: Pricing, now: Optional[datetime] = None) -> bool: + if pricing.off_peak_start_minute is None or pricing.off_peak_end_minute is None: + return False + moment = now or _now() + minute_of_day = moment.hour * 60 + moment.minute + start, end = pricing.off_peak_start_minute, pricing.off_peak_end_minute + if start == end: + return True + if start < end: + return start <= minute_of_day < end + return minute_of_day >= start or minute_of_day < end + + +def _tiered_rate(base: float, tier2: Optional[float], use_tier2: bool) -> float: + if use_tier2 and tier2 is not None: + return tier2 + return base + + +def _effective_rate( + base: float, + tier2: Optional[float], + use_tier2: bool, + pricing: Pricing, + off_peak: bool, +) -> float: + rate = _tiered_rate(base, tier2, use_tier2) + if off_peak and pricing.off_peak_discount_pct > 0: + rate = rate * (1 - min(pricing.off_peak_discount_pct, 100.0) / 100.0) + return rate + + def compute_cost( - usage: dict, norm: dict, pricing: Pricing, backend: str + usage: dict, + norm: dict, + pricing: Pricing, + backend: str, + now: Optional[datetime] = None, ) -> tuple[float, float, float, bool]: + threshold = pricing.context_tier_threshold_tokens + use_tier2 = bool(threshold > 0 and norm["prompt"] > threshold) + off_peak = _off_peak_active(pricing, now) if backend == "vision": - input_cost = norm["prompt"] / PER_MILLION * pricing.vision_input_per_m - output_cost = norm["completion"] / PER_MILLION * pricing.vision_output_per_m + input_rate = _effective_rate( + pricing.vision_input_per_m, + pricing.vision_input_per_m_tier2, + use_tier2, + pricing, + off_peak, + ) + output_rate = _effective_rate( + pricing.vision_output_per_m, + pricing.vision_output_per_m_tier2, + use_tier2, + pricing, + off_peak, + ) + input_cost = norm["prompt"] / PER_MILLION * input_rate + output_cost = norm["completion"] / PER_MILLION * output_rate elif backend == "embed": - input_cost = norm["prompt"] / PER_MILLION * pricing.embed_input_per_m + input_rate = _effective_rate( + pricing.embed_input_per_m, + pricing.embed_input_per_m_tier2, + use_tier2, + pricing, + off_peak, + ) + input_cost = norm["prompt"] / PER_MILLION * input_rate + output_cost = 0.0 + elif backend == "image": + rate = pricing.image_per_call + if off_peak and pricing.off_peak_discount_pct > 0: + rate = rate * (1 - min(pricing.off_peak_discount_pct, 100.0) / 100.0) + input_cost = rate output_cost = 0.0 else: - input_cost = ( - norm["cache_hit"] / PER_MILLION * pricing.chat_cache_hit_per_m - + norm["cache_miss"] / PER_MILLION * pricing.chat_cache_miss_per_m + hit_rate = _effective_rate( + pricing.chat_cache_hit_per_m, + pricing.chat_cache_hit_per_m_tier2, + use_tier2, + pricing, + off_peak, ) - output_cost = norm["completion"] / PER_MILLION * pricing.chat_output_per_m + miss_rate = _effective_rate( + pricing.chat_cache_miss_per_m, + pricing.chat_cache_miss_per_m_tier2, + use_tier2, + pricing, + off_peak, + ) + output_rate = _effective_rate( + pricing.chat_output_per_m, + pricing.chat_output_per_m_tier2, + use_tier2, + pricing, + off_peak, + ) + input_cost = ( + norm["cache_hit"] / PER_MILLION * hit_rate + + norm["cache_miss"] / PER_MILLION * miss_rate + ) + output_cost = norm["completion"] / PER_MILLION * output_rate native = usage.get("cost") if isinstance(usage, dict) else None if isinstance(native, (int, float)) and not isinstance(native, bool): total = max(0.0, float(native)) diff --git a/devplacepy/services/presence_relay.py b/devplacepy/services/presence_relay.py index 4b28b7d8..c00e14d2 100644 --- a/devplacepy/services/presence_relay.py +++ b/devplacepy/services/presence_relay.py @@ -7,6 +7,7 @@ from typing import Optional from devplacepy.config import PRESENCE_ONLINE_LIMIT from devplacepy.database import db, get_users_by_uids +from devplacepy.database.awards import award_is_prominent from devplacepy.services import presence from devplacepy.services.base import BaseService from devplacepy.services.pubsub import publish as pubsub_publish @@ -104,6 +105,8 @@ class PresenceRelayService(BaseService): "uid": row["uid"], "username": row["username"], "avatar_seed": row.get("avatar_seed") or row["username"], + "last_award_slug": row.get("last_award_slug") or "", + "award_prominent": award_is_prominent(row), } for row in users ], diff --git a/devplacepy/services/statistics/__init__.py b/devplacepy/services/statistics/__init__.py new file mode 100644 index 00000000..b8f73fd5 --- /dev/null +++ b/devplacepy/services/statistics/__init__.py @@ -0,0 +1 @@ +# retoor \ No newline at end of file diff --git a/devplacepy/services/statistics/build.py b/devplacepy/services/statistics/build.py new file mode 100644 index 00000000..863d921b --- /dev/null +++ b/devplacepy/services/statistics/build.py @@ -0,0 +1,77 @@ +# retoor + +from __future__ import annotations + +from devplacepy.services.statistics.common import ( + VALID_TABS, + cache_get, + cache_set, + iso, + now_utc, + window_config, +) +from devplacepy.services.statistics.tabs_data import ( + build_ai, + build_awards, + build_containers, + build_content, + build_devii, + build_engagement, + build_game, + build_members, + build_moderation, + build_overview, + build_services, + build_social, + build_storage, + build_tools, + build_visitors, +) + +TAB_BUILDERS = { + "overview": build_overview, + "visitors": build_visitors, + "members": build_members, + "content": build_content, + "engagement": build_engagement, + "social": build_social, + "ai": build_ai, + "devii": build_devii, + "services": build_services, + "containers": build_containers, + "game": build_game, + "awards": build_awards, + "moderation": build_moderation, + "tools": build_tools, + "storage": build_storage, +} + + +def build_statistics_tab( + tab: str, + hours: int = 168, + *, + compare: bool = True, + top_n: int = 10, +) -> dict: + key = (tab or "overview").strip().lower() + if key not in VALID_TABS: + key = "overview" + bounded_top = max(1, min(int(top_n or 10), 50)) + window = window_config(hours) + cache_key = f"stats:{key}:{window['hours']}:{int(compare)}:{bounded_top}" + cached = cache_get(cache_key) + if cached is not None: + return cached + builder = TAB_BUILDERS[key] + body = builder(window["hours"], compare, bounded_top) + payload = { + "tab": key, + "window_hours": window["hours"], + "granularity": window["granularity"], + "generated_at": iso(now_utc()), + "compare": bool(compare), + **body, + } + cache_set(cache_key, payload) + return payload \ No newline at end of file diff --git a/devplacepy/services/statistics/common.py b/devplacepy/services/statistics/common.py new file mode 100644 index 00000000..434d43e3 --- /dev/null +++ b/devplacepy/services/statistics/common.py @@ -0,0 +1,169 @@ +# retoor + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Optional + +from devplacepy.cache import TTLCache + +MAX_WINDOW_HOURS = 2160 +RETENTION_DAYS = 90 + +_stats_cache = TTLCache(ttl=60, max_size=200) + +VALID_TABS = ( + "overview", + "visitors", + "members", + "content", + "engagement", + "social", + "ai", + "devii", + "services", + "containers", + "game", + "awards", + "moderation", + "tools", + "storage", +) + + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def iso(moment: datetime) -> str: + return moment.isoformat(timespec="seconds") + + +def hour_bucket(moment: datetime) -> str: + return moment.replace(minute=0, second=0, microsecond=0).isoformat(timespec="seconds") + + +def window_config(hours: int) -> dict: + now = now_utc() + if hours <= 0: + return { + "hours": 0, + "start": None, + "compare_start": None, + "end": iso(now), + "granularity": "weekly", + } + bounded = max(1, min(int(hours), MAX_WINDOW_HOURS)) + start = now - timedelta(hours=bounded) + compare_start = start - timedelta(hours=bounded) + if bounded <= 48: + granularity = "hourly" + elif bounded <= 720: + granularity = "daily" + else: + granularity = "daily" + return { + "hours": bounded, + "start": iso(start), + "compare_start": iso(compare_start), + "end": iso(now), + "granularity": granularity, + } + + +def bucket_expr(granularity: str, column: str = "created_at") -> str: + if granularity == "hourly": + return f"strftime('%Y-%m-%dT%H:00:00', {column})" + if granularity == "weekly": + return f"strftime('%Y-W%W', {column})" + return f"date({column})" + + +def delta_pair(current: float, previous: float) -> dict: + if previous == 0: + pct = 100.0 if current > 0 else 0.0 + else: + pct = round((current - previous) / previous * 100, 1) + if pct > 0: + direction = "up" + elif pct < 0: + direction = "down" + else: + direction = "flat" + return {"delta": pct, "direction": direction} + + +def metric_card( + key: str, + label: str, + value, + *, + current: Optional[float] = None, + previous: Optional[float] = None, + format_kind: str = "int", +) -> dict: + card = {"key": key, "label": label, "value": value, "format": format_kind} + if current is not None and previous is not None: + card.update(delta_pair(current, previous)) + return card + + +def fill_series( + points: dict[str, float], + start: Optional[str], + end: str, + granularity: str, +) -> list[dict]: + if not start: + ordered = sorted(points.items()) + return [{"t": bucket, "v": points[bucket]} for bucket, _ in ordered] + try: + cursor = datetime.fromisoformat(start) + end_dt = datetime.fromisoformat(end) + except (TypeError, ValueError): + ordered = sorted(points.items()) + return [{"t": bucket, "v": points[bucket]} for bucket, _ in ordered] + if cursor.tzinfo is None: + cursor = cursor.replace(tzinfo=timezone.utc) + if end_dt.tzinfo is None: + end_dt = end_dt.replace(tzinfo=timezone.utc) + out: list[dict] = [] + if granularity == "hourly": + step = timedelta(hours=1) + fmt = lambda moment: hour_bucket(moment) + elif granularity == "weekly": + step = timedelta(days=7) + fmt = lambda moment: moment.strftime("%Y-W%W") + else: + step = timedelta(days=1) + fmt = lambda moment: moment.date().isoformat() + while cursor <= end_dt: + key = fmt(cursor) + out.append({"t": key, "v": points.get(key, 0)}) + cursor += step + return out + + +def cache_get(key: str): + return _stats_cache.get(key) + + +def cache_set(key: str, payload: dict) -> None: + _stats_cache.set(key, payload) + + +def series_payload( + key: str, + label: str, + points: dict[str, float], + window: dict, +) -> dict: + return { + "key": key, + "label": label, + "points": fill_series(points, window["start"], window["end"], window["granularity"]), + } + + +def table_payload(key: str, title: str, columns: list[str], rows: list[list]) -> dict: + return {"key": key, "title": title, "columns": columns, "rows": rows} \ No newline at end of file diff --git a/devplacepy/services/statistics/tabs_data.py b/devplacepy/services/statistics/tabs_data.py new file mode 100644 index 00000000..b1feb350 --- /dev/null +++ b/devplacepy/services/statistics/tabs_data.py @@ -0,0 +1,1288 @@ +# retoor + +from __future__ import annotations + +from devplacepy.config import PRESENCE_TIMEOUT_SECONDS +from devplacepy.database import ( + SOFT_DELETE_TABLES, + count_deleted, + db, + get_platform_analytics, + get_site_stats, + get_users_by_uids, +) +from devplacepy.services.manager import service_manager +from devplacepy.services.openai_gateway.analytics import build_analytics +from devplacepy.services.openai_gateway.usage import pricing_from_cfg +from devplacepy.services.presence import online_cutoff_iso as presence_cutoff +from devplacepy.services.statistics.common import ( + bucket_expr, + metric_card, + series_payload, + table_payload, + window_config, +) +from devplacepy.services.statistics.tracking import RETENTION_DAYS + +GATEWAY_LEDGER = "gateway_usage_ledger" + + +def _table_exists(name: str) -> bool: + return name in db.tables + + +def _username(users_map: dict, uid: str) -> str: + user = users_map.get(uid) + if user and user.get("username"): + return user["username"] + return uid + + +def _instance_label(inst: dict) -> str: + name = (inst.get("name") or "").strip() + if name: + return name + slug = (inst.get("slug") or "").strip() + if slug: + return slug + return inst.get("uid", "") + + +def _has_column(table: str, column: str) -> bool: + if not _table_exists(table): + return False + try: + return db[table].has_column(column) + except Exception: + return False + + +def _live_clause(table: str, alias: str = "") -> str: + prefix = f"{alias}" if alias else "" + if _has_column(table, "deleted_at"): + return f"{prefix}deleted_at IS NULL" + return "1=1" + + +def _count_window( + table: str, + start: str, + end: str, + extra: str = "", + *, + date_column: str = "created_at", +) -> int: + if not _table_exists(table) or not _has_column(table, date_column): + return 0 + clause = "1=1" + if _live_clause(table) != "1=1": + clause = _live_clause(table) + if extra: + clause = f"{clause} AND {extra}" + rows = list( + db.query( + f"SELECT COUNT(*) AS c FROM {table} WHERE {clause} " + f"AND {date_column} >= :start AND {date_column} < :end", + start=start, + end=end, + ) + ) + return int(rows[0]["c"]) if rows else 0 + + +def _sum_window( + table: str, + column: str, + start: str, + end: str, + extra: str = "", + *, + date_column: str = "created_at", +) -> float: + if not _table_exists(table) or not _has_column(table, date_column): + return 0.0 + clause = extra or "1=1" + rows = list( + db.query( + f"SELECT COALESCE(SUM({column}), 0) AS s FROM {table} " + f"WHERE {date_column} >= :start AND {date_column} < :end AND " + clause, + start=start, + end=end, + ) + ) + return float(rows[0]["s"]) if rows else 0.0 + + +def _series_table( + table: str, + window: dict, + *, + extra: str = "", + value_expr: str = "COUNT(*)", + date_column: str = "created_at", +) -> dict[str, float]: + if not _table_exists(table) or not window["start"] or not _has_column(table, date_column): + return {} + clause = extra or "1=1" + live = _live_clause(table) + if live != "1=1": + clause = f"{live} AND {clause}" + expr = bucket_expr(window["granularity"], date_column) + rows = db.query( + f"SELECT {expr} AS bucket, {value_expr} AS v FROM {table} " + f"WHERE {date_column} >= :start AND {date_column} < :end AND {clause} " + "GROUP BY bucket ORDER BY bucket", + start=window["start"], + end=window["end"], + ) + return {row["bucket"]: float(row["v"]) for row in rows if row.get("bucket")} + + +def _active_members(start: str, end: str) -> int: + sources = [ + table + for table in ("posts", "comments", "gists", "projects") + if _table_exists(table) + ] + if not sources: + return 0 + union = " UNION ALL ".join( + f"SELECT user_uid, created_at FROM {table} WHERE deleted_at IS NULL" + for table in sources + ) + rows = list( + db.query( + f"SELECT COUNT(DISTINCT user_uid) AS c FROM ({union}) " + "WHERE created_at >= :start AND created_at < :end", + start=start, + end=end, + ) + ) + return int(rows[0]["c"]) if rows else 0 + + +def _online_now() -> int: + if not _table_exists("users"): + return 0 + cutoff = presence_cutoff() + rows = list( + db.query( + "SELECT COUNT(*) AS c FROM users WHERE last_seen >= :cutoff", + cutoff=cutoff, + ) + ) + return int(rows[0]["c"]) if rows else 0 + + +def _visit_totals(start: str, end: str) -> dict: + if not _table_exists("visit_stats_hourly"): + return {"views": 0, "member_views": 0, "guest_views": 0, "unique_visitors": 0} + rows = list( + db.query( + "SELECT COALESCE(SUM(views), 0) AS views, " + "COALESCE(SUM(member_views), 0) AS member_views, " + "COALESCE(SUM(guest_views), 0) AS guest_views " + "FROM visit_stats_hourly WHERE bucket_start >= :start AND bucket_start < :end", + start=start, + end=end, + ) + ) + totals = rows[0] if rows else {} + unique_rows = list( + db.query( + "SELECT COUNT(DISTINCT visitor_hash) AS c FROM visit_unique_slots " + "WHERE bucket_start >= :start AND bucket_start < :end", + start=start, + end=end, + ) + ) + return { + "views": int(totals.get("views") or 0), + "member_views": int(totals.get("member_views") or 0), + "guest_views": int(totals.get("guest_views") or 0), + "unique_visitors": int(unique_rows[0]["c"]) if unique_rows else 0, + } + + +def _visit_series(window: dict, field: str) -> dict[str, float]: + if not _table_exists("visit_stats_hourly") or not window["start"]: + return {} + if field == "unique_visitors": + expr = bucket_expr(window["granularity"], "bucket_start") + rows = db.query( + f"SELECT {expr} AS bucket, COUNT(DISTINCT visitor_hash) AS v " + "FROM visit_unique_slots " + "WHERE bucket_start >= :start AND bucket_start < :end " + "GROUP BY bucket ORDER BY bucket", + start=window["start"], + end=window["end"], + ) + return {row["bucket"]: float(row["v"]) for row in rows if row.get("bucket")} + rows = db.query( + f"SELECT {bucket_expr(window['granularity'], 'bucket_start')} AS bucket, " + f"COALESCE(SUM({field}), 0) AS v FROM visit_stats_hourly " + "WHERE bucket_start >= :start AND bucket_start < :end " + "GROUP BY bucket ORDER BY bucket", + start=window["start"], + end=window["end"], + ) + return {row["bucket"]: float(row["v"]) for row in rows if row.get("bucket")} + + +def build_overview(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + compare_start = window["compare_start"] or start + site = get_site_stats() + platform = get_platform_analytics(top_n) + current_active = _active_members(start, end) if window["start"] else platform["active_30d"] + previous_active = ( + _active_members(compare_start, start) if compare and window["start"] else 0 + ) + signups = _count_window("users", start, end) if window["start"] else site["total_members"] + prev_signups = _count_window("users", compare_start, start) if compare and window["start"] else 0 + posts = _count_window("posts", start, end) if window["start"] else platform["totals"]["posts"] + prev_posts = _count_window("posts", compare_start, start) if compare and window["start"] else 0 + ai_spend = 0.0 + prev_ai_spend = 0.0 + if _table_exists(GATEWAY_LEDGER): + ai_spend = _sum_window(GATEWAY_LEDGER, "cost_usd", start, end) + if compare and window["start"]: + prev_ai_spend = _sum_window(GATEWAY_LEDGER, "cost_usd", compare_start, start) + deleted = sum(count_deleted(table) for table in SOFT_DELETE_TABLES if _table_exists(table)) + visits = _visit_totals(start, end) if window["start"] else {"views": 0, "unique_visitors": 0} + cards = [ + metric_card( + "members", + "Total members", + site["total_members"], + format_kind="int", + ), + metric_card( + "signups", + "New signups", + signups, + current=signups, + previous=prev_signups if compare else None, + ), + metric_card( + "active", + "Active members", + current_active, + current=current_active, + previous=previous_active if compare else None, + ), + metric_card( + "online", + "Online now", + _online_now(), + format_kind="int", + ), + metric_card( + "posts", + "Posts created", + posts, + current=posts, + previous=prev_posts if compare else None, + ), + metric_card( + "views", + "Page views", + visits["views"], + format_kind="int", + ), + metric_card( + "ai_spend", + "AI spend", + round(ai_spend, 4), + current=ai_spend, + previous=prev_ai_spend if compare else None, + format_kind="money", + ), + metric_card("trash", "In trash", deleted, format_kind="int"), + ] + series = [] + if window["start"]: + series.append( + series_payload( + "signups", + "Signups", + _series_table("users", window), + window, + ) + ) + series.append( + series_payload( + "posts", + "Posts", + _series_table("posts", window), + window, + ) + ) + series.append( + series_payload( + "comments", + "Comments", + _series_table("comments", window), + window, + ) + ) + series.append( + series_payload( + "views", + "Page views", + _visit_series(window, "views"), + window, + ) + ) + highlights = _highlights() + tables = [ + table_payload( + "top_authors", + "Top authors", + ["Username", "Stars"], + [[author["username"], author["stars"]] for author in platform["top_authors"]], + ) + ] + return { + "cards": cards, + "series": series, + "tables": tables, + "highlights": highlights, + "notes": { + "active": platform["active_definition"], + "presence_timeout": PRESENCE_TIMEOUT_SECONDS, + }, + } + + +def build_visitors(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + compare_start = window["compare_start"] or start + current = _visit_totals(start, end) + previous = _visit_totals(compare_start, start) if compare and window["start"] else {} + cards = [ + metric_card( + "views", + "Page views", + current["views"], + current=current["views"], + previous=previous.get("views") if compare else None, + ), + metric_card( + "unique", + "Unique visitors", + current["unique_visitors"], + current=current["unique_visitors"], + previous=previous.get("unique_visitors") if compare else None, + ), + metric_card("member_views", "Member views", current["member_views"], format_kind="int"), + metric_card("guest_views", "Guest views", current["guest_views"], format_kind="int"), + metric_card("online", "Online now", _online_now(), format_kind="int"), + ] + series = [] + if window["start"]: + for key, label, field in ( + ("views", "Page views", "views"), + ("unique", "Unique visitors", "unique_visitors"), + ("member_views", "Member views", "member_views"), + ("guest_views", "Guest views", "guest_views"), + ): + series.append(series_payload(key, label, _visit_series(window, field), window)) + tables = [] + if _table_exists("visit_stats_hourly") and window["start"]: + page_rows = db.query( + "SELECT page_group, SUM(views) AS views FROM visit_stats_hourly " + "WHERE bucket_start >= :start AND bucket_start < :end " + "GROUP BY page_group ORDER BY views DESC LIMIT :limit", + start=start, + end=end, + limit=top_n, + ) + tables.append( + table_payload( + "pages", + "Top page groups", + ["Page group", "Views"], + [[row["page_group"], int(row["views"] or 0)] for row in page_rows], + ) + ) + ref_rows = db.query( + "SELECT referrer_group, SUM(views) AS views FROM visit_stats_hourly " + "WHERE bucket_start >= :start AND bucket_start < :end " + "GROUP BY referrer_group ORDER BY views DESC LIMIT :limit", + start=start, + end=end, + limit=top_n, + ) + tables.append( + table_payload( + "referrers", + "Top referrers", + ["Referrer", "Views"], + [[row["referrer_group"], int(row["views"] or 0)] for row in ref_rows], + ) + ) + return { + "cards": cards, + "series": series, + "tables": tables, + "notes": {"retention_days": RETENTION_DAYS}, + } + + +def build_members(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + compare_start = window["compare_start"] or start + signups = _count_window("users", start, end) if window["start"] else db["users"].count() + prev_signups = _count_window("users", compare_start, start) if compare and window["start"] else 0 + disabled = db["users"].count(is_active=0) if _table_exists("users") else 0 + avg_level = 0.0 + avg_xp = 0.0 + if _table_exists("users"): + row = list(db.query("SELECT AVG(level) AS l, AVG(xp) AS x FROM users")) + if row: + avg_level = round(float(row[0]["l"] or 0), 1) + avg_xp = round(float(row[0]["x"] or 0), 0) + cards = [ + metric_card( + "signups", + "New signups", + signups, + current=signups, + previous=prev_signups if compare else None, + ), + metric_card("total", "Total members", db["users"].count() if _table_exists("users") else 0), + metric_card("disabled", "Disabled accounts", disabled), + metric_card("avg_level", "Avg level", avg_level, format_kind="decimal"), + metric_card("avg_xp", "Avg XP", avg_xp, format_kind="int"), + ] + series = [] + if window["start"]: + series.append(series_payload("signups", "Signups", _series_table("users", window), window)) + tables = [] + if _table_exists("users") and window["start"]: + active_rows = db.query( + "SELECT u.username, COUNT(*) AS c FROM (" + "SELECT user_uid, created_at FROM posts WHERE deleted_at IS NULL " + "UNION ALL SELECT user_uid, created_at FROM comments WHERE deleted_at IS NULL " + "UNION ALL SELECT user_uid, created_at FROM gists WHERE deleted_at IS NULL " + "UNION ALL SELECT user_uid, created_at FROM projects WHERE deleted_at IS NULL" + ") a JOIN users u ON u.uid = a.user_uid " + "WHERE a.created_at >= :start AND a.created_at < :end " + "GROUP BY u.username ORDER BY c DESC LIMIT :limit", + start=start, + end=end, + limit=top_n, + ) + tables.append( + table_payload( + "active", + "Most active members", + ["Username", "Actions"], + [[row["username"], int(row["c"] or 0)] for row in active_rows], + ) + ) + newest = list( + db["users"].find(order_by=["-created_at"], _limit=top_n) + ) + tables.append( + table_payload( + "newest", + "Newest members", + ["Username", "Joined"], + [[row.get("username", ""), (row.get("created_at") or "")[:10]] for row in newest], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_content(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + compare_start = window["compare_start"] or start + kinds = ( + ("posts", "Posts", "created_at"), + ("comments", "Comments", "created_at"), + ("gists", "Gists", "created_at"), + ("projects", "Projects", "created_at"), + ("news", "News", "synced_at"), + ) + cards = [] + series = [] + for kind, label, date_column in kinds: + count = _count_window(kind, start, end, date_column=date_column) if window["start"] else ( + db[kind].count(deleted_at=None) if _table_exists(kind) else 0 + ) + prev = ( + _count_window(kind, compare_start, start, date_column=date_column) + if compare and window["start"] + else 0 + ) + cards.append( + metric_card( + kind, + label, + count, + current=count, + previous=prev if compare else None, + ) + ) + if window["start"]: + series.append( + series_payload( + kind, + label, + _series_table(kind, window, date_column=date_column), + window, + ) + ) + tables = [] + if _table_exists("posts") and window["start"]: + post_live = _live_clause("posts") + topic_rows = db.query( + f"SELECT topic, COUNT(*) AS c FROM posts " + f"WHERE {post_live} AND created_at >= :start AND created_at < :end " + "GROUP BY topic ORDER BY c DESC LIMIT :limit", + start=start, + end=end, + limit=top_n, + ) + tables.append( + table_payload( + "topics", + "Top topics", + ["Topic", "Posts"], + [[row["topic"] or "(none)", int(row["c"] or 0)] for row in topic_rows], + ) + ) + if _table_exists("gists"): + gist_live = _live_clause("gists") + window_clause = "" + params: dict = {} + if window["start"]: + window_clause = " AND created_at >= :start AND created_at < :end" + params = {"start": start, "end": end} + lang_rows = db.query( + f"SELECT language, COUNT(*) AS c FROM gists WHERE {gist_live}" + f"{window_clause} GROUP BY language ORDER BY c DESC, language ASC", + **params, + ) + if lang_rows: + tables.append( + table_payload( + "languages", + "Gist languages", + ["Language", "Gists"], + [ + [row["language"] or "(none)", int(row["c"] or 0)] + for row in lang_rows + ], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_engagement(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + compare_start = window["compare_start"] or start + kinds = ( + ("votes", "Votes"), + ("reactions", "Reactions"), + ("bookmarks", "Bookmarks"), + ("follows", "Follows"), + ) + cards = [] + series = [] + for key, label in kinds: + count = _count_window(key, start, end) if window["start"] else ( + db[key].count(deleted_at=None) if _table_exists(key) else 0 + ) + prev = _count_window(key, compare_start, start) if compare and window["start"] else 0 + cards.append( + metric_card(key, label, count, current=count, previous=prev if compare else None) + ) + if window["start"]: + series.append(series_payload(key, label, _series_table(key, window), window)) + if _table_exists("poll_votes") and window["start"]: + poll_count = _count_window("poll_votes", start, end) + cards.append(metric_card("poll_votes", "Poll votes", poll_count)) + series.append( + series_payload("poll_votes", "Poll votes", _series_table("poll_votes", window), window) + ) + tables = [] + if _table_exists("reactions"): + reaction_live = _live_clause("reactions") + emoji_rows = db.query( + f"SELECT emoji, COUNT(*) AS c FROM reactions WHERE {reaction_live} " + + ("AND created_at >= :start AND created_at < :end " if window["start"] else "") + + "GROUP BY emoji ORDER BY c DESC LIMIT :limit", + **({"start": start, "end": end} if window["start"] else {}), + limit=top_n, + ) + tables.append( + table_payload( + "emoji", + "Top reaction emojis", + ["Emoji", "Count"], + [[row["emoji"], int(row["c"] or 0)] for row in emoji_rows], + ) + ) + if _table_exists("votes") and window["start"]: + vote_live = _live_clause("votes") + vote_rows = list( + db.query( + "SELECT SUM(CASE WHEN value > 0 THEN 1 ELSE 0 END) AS up, " + "SUM(CASE WHEN value < 0 THEN 1 ELSE 0 END) AS down " + f"FROM votes WHERE {vote_live} " + "AND created_at >= :start AND created_at < :end", + start=start, + end=end, + ) + ) + if vote_rows: + tables.append( + table_payload( + "vote_ratio", + "Vote ratio", + ["Upvotes", "Downvotes"], + [[int(vote_rows[0]["up"] or 0), int(vote_rows[0]["down"] or 0)]], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_social(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + compare_start = window["compare_start"] or start + messages = _count_window("messages", start, end) if window["start"] else ( + db["messages"].count(deleted_at=None) if _table_exists("messages") else 0 + ) + prev_messages = _count_window("messages", compare_start, start) if compare and window["start"] else 0 + notifications = _count_window("notifications", start, end) if window["start"] else ( + db["notifications"].count(deleted_at=None) if _table_exists("notifications") else 0 + ) + follows = _count_window("follows", start, end) if window["start"] else ( + db["follows"].count(deleted_at=None) if _table_exists("follows") else 0 + ) + push_count = db["push_registration"].count() if _table_exists("push_registration") else 0 + telegram_count = db["telegram_links"].count() if _table_exists("telegram_links") else 0 + cards = [ + metric_card( + "messages", + "Messages sent", + messages, + current=messages, + previous=prev_messages if compare else None, + ), + metric_card("notifications", "Notifications", notifications), + metric_card("follows", "New follows", follows), + metric_card("push", "Push subscriptions", push_count), + metric_card("telegram", "Telegram links", telegram_count), + ] + series = [] + if window["start"]: + series.append(series_payload("messages", "Messages", _series_table("messages", window), window)) + series.append( + series_payload("notifications", "Notifications", _series_table("notifications", window), window) + ) + series.append(series_payload("follows", "Follows", _series_table("follows", window), window)) + tables = [] + if _table_exists("notifications"): + notif_live = _live_clause("notifications") + type_rows = db.query( + f"SELECT type, COUNT(*) AS c FROM notifications WHERE {notif_live} " + + ("AND created_at >= :start AND created_at < :end " if window["start"] else "") + + "GROUP BY type ORDER BY c DESC LIMIT :limit", + **({"start": start, "end": end} if window["start"] else {}), + limit=top_n, + ) + tables.append( + table_payload( + "notification_types", + "Notification types", + ["Type", "Count"], + [[row["type"], int(row["c"] or 0)] for row in type_rows], + ) + ) + if _table_exists("follows"): + follow_live = _live_clause("follows", "f.") + followed_rows = db.query( + "SELECT u.username, COUNT(*) AS c FROM follows f " + "JOIN users u ON u.uid = f.following_uid " + f"WHERE {follow_live} " + + ("AND f.created_at >= :start AND f.created_at < :end " if window["start"] else "") + + "GROUP BY u.username ORDER BY c DESC LIMIT :limit", + **({"start": start, "end": end} if window["start"] else {}), + limit=top_n, + ) + tables.append( + table_payload( + "most_followed", + "Most followed", + ["Username", "New followers"], + [[row["username"], int(row["c"] or 0)] for row in followed_rows], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_ai(hours: int, compare: bool, top_n: int) -> dict: + bounded = hours if hours > 0 else 168 + service = service_manager.get_service("openai") + pricing = pricing_from_cfg(service.get_config()) if service is not None else None + payload = build_analytics(bounded, top_n=top_n, pricing=pricing) + cards = [ + metric_card("requests", "Requests", payload.get("requests", 0)), + metric_card( + "cost", + "Cost USD", + payload.get("cost", {}).get("total_usd", 0), + format_kind="money", + ), + metric_card( + "tokens", + "Total tokens", + payload.get("tokens", {}).get("total", 0), + ), + metric_card( + "errors", + "Failed requests", + payload.get("errors", {}).get("failed", 0), + ), + metric_card( + "latency_p95", + "Latency p95 ms", + payload.get("latency", {}).get("upstream", {}).get("p95", 0), + format_kind="decimal", + ), + ] + series = [] + hourly = payload.get("hourly") or [] + if hourly: + points = {row["hour"]: float(row.get("requests") or 0) for row in hourly if row.get("hour")} + window = window_config(bounded) + series.append(series_payload("requests", "Requests", points, window)) + cost_points = {row["hour"]: float(row.get("cost_usd") or 0) for row in hourly if row.get("hour")} + series.append(series_payload("cost", "Cost USD", cost_points, window)) + tables = [] + models = (payload.get("cost") or {}).get("per_model") or [] + if models: + tables.append( + table_payload( + "models", + "Top models by cost", + ["Model", "Requests", "Cost"], + [ + [row.get("key", ""), row.get("requests", 0), round(row.get("cost_usd", 0), 4)] + for row in models[:top_n] + ], + ) + ) + return { + "cards": cards, + "series": series, + "tables": tables, + "notes": {"full_page": "/admin/ai-usage"}, + } + + +def build_devii(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + turns = _count_window("devii_turns", start, end, "1=1") if window["start"] else ( + db["devii_turns"].count() if _table_exists("devii_turns") else 0 + ) + cost = _sum_window("devii_usage_ledger", "cost_usd", start, end) if _table_exists("devii_usage_ledger") else 0.0 + tool_calls = 0 + if _table_exists("devii_turns") and window["start"]: + row = list( + db.query( + "SELECT COALESCE(SUM(tool_calls), 0) AS s FROM devii_turns " + "WHERE started_at >= :start AND started_at < :end", + start=start, + end=end, + ) + ) + tool_calls = int(row[0]["s"]) if row else 0 + errors = 0 + if _table_exists("devii_turns") and window["start"]: + errors = _count_window("devii_turns", start, end, "error IS NOT NULL AND error != ''") + cards = [ + metric_card("turns", "Turns", turns), + metric_card("cost", "Cost USD", round(cost, 4), format_kind="money"), + metric_card("tool_calls", "Tool calls", tool_calls), + metric_card("errors", "Errors", errors), + ] + series = [] + if _table_exists("devii_turns") and window["start"]: + expr = bucket_expr(window["granularity"], "started_at") + rows = db.query( + f"SELECT {expr} AS bucket, COUNT(*) AS v FROM devii_turns " + "WHERE started_at >= :start AND started_at < :end GROUP BY bucket", + start=start, + end=end, + ) + points = {row["bucket"]: float(row["v"]) for row in rows if row.get("bucket")} + series.append(series_payload("turns", "Turns", points, window)) + tables = [] + if _table_exists("devii_turns"): + owner_rows = db.query( + "SELECT owner_kind, COUNT(*) AS c FROM devii_turns " + + ("WHERE started_at >= :start AND started_at < :end " if window["start"] else "") + + "GROUP BY owner_kind ORDER BY c DESC LIMIT :limit", + **({"start": start, "end": end} if window["start"] else {}), + limit=top_n, + ) + tables.append( + table_payload( + "owners", + "Turns by owner kind", + ["Owner", "Turns"], + [[row["owner_kind"], int(row["c"] or 0)] for row in owner_rows], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_services(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + completed = 0 + failed = 0 + if _table_exists("jobs"): + if window["start"]: + completed = _count_window("jobs", start, end, "status = 'completed'") + failed = _count_window("jobs", start, end, "status = 'failed'") + else: + completed = db["jobs"].count(status="completed") + failed = db["jobs"].count(status="failed") + avg_duration = 0.0 + if _table_exists("jobs") and window["start"]: + row = list( + db.query( + "SELECT AVG(duration_ms) AS a FROM jobs " + "WHERE status = 'completed' AND created_at >= :start AND created_at < :end", + start=start, + end=end, + ) + ) + if row and row[0]["a"]: + avg_duration = round(float(row[0]["a"]), 0) + running_services = 0 + if _table_exists("service_state"): + running_services = db["service_state"].count(status="running") + cards = [ + metric_card("completed", "Jobs completed", completed), + metric_card("failed", "Jobs failed", failed), + metric_card("avg_duration", "Avg duration ms", avg_duration, format_kind="decimal"), + metric_card("running_services", "Running services", running_services), + ] + series = [] + if _table_exists("jobs") and window["start"]: + series.append( + series_payload("completed", "Completed jobs", _series_table("jobs", window, extra="status = 'completed'"), window) + ) + series.append( + series_payload("failed", "Failed jobs", _series_table("jobs", window, extra="status = 'failed'"), window) + ) + tables = [] + if _table_exists("jobs"): + kind_rows = db.query( + "SELECT kind, status, COUNT(*) AS c FROM jobs " + + ("WHERE created_at >= :start AND created_at < :end " if window["start"] else "") + + "GROUP BY kind, status ORDER BY c DESC LIMIT :limit", + **({"start": start, "end": end} if window["start"] else {}), + limit=top_n, + ) + tables.append( + table_payload( + "job_kinds", + "Jobs by kind", + ["Kind", "Status", "Count"], + [[row["kind"], row["status"], int(row["c"] or 0)] for row in kind_rows], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_containers(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + running = db["instances"].count(status="running") if _table_exists("instances") else 0 + restarts = 0 + if _table_exists("instances"): + row = list(db.query("SELECT COALESCE(SUM(restart_count), 0) AS s FROM instances")) + restarts = int(row[0]["s"]) if row else 0 + events = _count_window("instance_events", start, end) if _table_exists("instance_events") and window["start"] else 0 + cards = [ + metric_card("running", "Running instances", running), + metric_card("restarts", "Total restarts", restarts), + metric_card("events", "Events in window", events), + ] + series = [] + if _table_exists("instance_events") and window["start"]: + series.append( + series_payload("events", "Instance events", _series_table("instance_events", window), window) + ) + tables = [] + if _table_exists("instances"): + top_restart = list( + db["instances"].find(order_by=["-restart_count"], _limit=top_n) + ) + tables.append( + table_payload( + "restarts", + "Most restarted instances", + ["Instance", "Restarts", "Status"], + [ + [ + _instance_label(row), + int(row.get("restart_count") or 0), + row.get("status", ""), + ] + for row in top_restart + ], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_game(hours: int, compare: bool, top_n: int) -> dict: + farms = db["game_farms"].count() if _table_exists("game_farms") else 0 + steals = 0 + harvests = 0 + coins = 0 + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + if _table_exists("game_steals"): + if window["start"]: + row = list( + db.query( + "SELECT COUNT(*) AS c FROM game_steals " + "WHERE stolen_at >= :start AND stolen_at < :end", + start=start, + end=end, + ) + ) + steals = int(row[0]["c"]) if row else 0 + else: + steals = db["game_steals"].count() + if _table_exists("game_farms"): + row = list(db.query("SELECT COALESCE(SUM(total_harvests), 0) AS h, COALESCE(SUM(coins), 0) AS c FROM game_farms")) + if row: + harvests = int(row[0]["h"] or 0) + coins = int(row[0]["c"] or 0) + cards = [ + metric_card("farms", "Active farms", farms), + metric_card("steals", "Steals in window", steals), + metric_card("harvests", "Total harvests", harvests), + metric_card("coins", "Coins in circulation", coins), + ] + series = [] + if _table_exists("game_steals") and window["start"]: + series.append( + series_payload( + "steals", + "Steals", + _series_table("game_steals", window, extra="1=1", date_column="stolen_at"), + window, + ) + ) + tables = [] + if _table_exists("game_farms"): + leaders = list(db["game_farms"].find(order_by=["-coins"], _limit=top_n)) + users_map = get_users_by_uids([row.get("user_uid") for row in leaders if row.get("user_uid")]) + tables.append( + table_payload( + "richest", + "Richest farms", + ["Username", "Coins", "Level"], + [ + [ + _username(users_map, row.get("user_uid", "")), + int(row.get("coins") or 0), + int(row.get("level") or 0), + ] + for row in leaders + ], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_awards(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + given = _count_window("awards", start, end) if _table_exists("awards") and window["start"] else ( + db["awards"].count(deleted_at=None) if _table_exists("awards") else 0 + ) + badges = _count_window("badges", start, end) if _table_exists("badges") and window["start"] else ( + db["badges"].count() if _table_exists("badges") else 0 + ) + cards = [ + metric_card("awards", "Awards given", given), + metric_card("badges", "Badges earned", badges), + ] + series = [] + if window["start"]: + if _table_exists("awards"): + series.append(series_payload("awards", "Awards", _series_table("awards", window), window)) + if _table_exists("badges"): + series.append(series_payload("badges", "Badges", _series_table("badges", window), window)) + tables = [] + if _table_exists("badges"): + badge_rows = db.query( + "SELECT badge_name, COUNT(*) AS c FROM badges " + + ("WHERE created_at >= :start AND created_at < :end " if window["start"] else "") + + "GROUP BY badge_name ORDER BY c DESC LIMIT :limit", + **({"start": start, "end": end} if window["start"] else {}), + limit=top_n, + ) + tables.append( + table_payload( + "badge_names", + "Badges by name", + ["Badge", "Count"], + [[row["badge_name"], int(row["c"] or 0)] for row in badge_rows], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_moderation(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + deleted = sum(count_deleted(table) for table in SOFT_DELETE_TABLES if _table_exists(table)) + blocks = db["user_relations"].count(kind="block") if _table_exists("user_relations") else 0 + denials = 0 + if _table_exists("audit_log") and window["start"]: + denials = _count_window("audit_log", start, end, "result = 'denied'") + cards = [ + metric_card("trash", "Items in trash", deleted), + metric_card("blocks", "Blocks", blocks), + metric_card("denials", "Audit denials", denials), + ] + series = [] + if _table_exists("audit_log") and window["start"]: + series.append( + series_payload( + "denials", + "Audit denials", + _series_table("audit_log", window, extra="result = 'denied'"), + window, + ) + ) + tables = [] + trash_rows = [] + for table in SOFT_DELETE_TABLES: + if not _table_exists(table): + continue + count = count_deleted(table) + if count: + trash_rows.append([table, count]) + trash_rows.sort(key=lambda row: row[1], reverse=True) + if trash_rows: + tables.append( + table_payload( + "trash_tables", + "Trash by table", + ["Table", "Count"], + trash_rows[:top_n], + ) + ) + if _table_exists("audit_log") and window["start"]: + cat_rows = db.query( + "SELECT category, COUNT(*) AS c FROM audit_log " + "WHERE created_at >= :start AND created_at < :end " + "GROUP BY category ORDER BY c DESC LIMIT :limit", + start=start, + end=end, + limit=top_n, + ) + tables.append( + table_payload( + "audit_categories", + "Audit by category", + ["Category", "Events"], + [[row["category"], int(row["c"] or 0)] for row in cat_rows], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_tools(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + deepsearch = _count_window("deepsearch_sessions", start, end) if _table_exists("deepsearch_sessions") and window["start"] else ( + db["deepsearch_sessions"].count(deleted_at=None) if _table_exists("deepsearch_sessions") else 0 + ) + isslop = 0 + if _table_exists("isslop_analyses"): + isslop = _count_window("isslop_analyses", start, end) if window["start"] else db["isslop_analyses"].count(deleted_at=None) + seo = 0 + if _table_exists("seo_metadata"): + seo = _count_window("seo_metadata", start, end, "1=1") if window["start"] else db["seo_metadata"].count() + issues = _count_window("issue_tickets", start, end) if _table_exists("issue_tickets") and window["start"] else ( + db["issue_tickets"].count() if _table_exists("issue_tickets") else 0 + ) + cards = [ + metric_card("deepsearch", "DeepSearch sessions", deepsearch), + metric_card("isslop", "IsSlop analyses", isslop), + metric_card("seo", "SEO metadata jobs", seo), + metric_card("issues", "Issue tickets", issues), + ] + series = [] + if window["start"]: + if _table_exists("deepsearch_sessions"): + series.append( + series_payload("deepsearch", "DeepSearch", _series_table("deepsearch_sessions", window), window) + ) + if _table_exists("isslop_analyses"): + series.append( + series_payload("isslop", "IsSlop", _series_table("isslop_analyses", window), window) + ) + tables = [] + if _table_exists("isslop_analyses"): + isslop_live = _live_clause("isslop_analyses") + grade_rows = db.query( + f"SELECT grade, COUNT(*) AS c FROM isslop_analyses WHERE {isslop_live} " + + ("AND created_at >= :start AND created_at < :end " if window["start"] else "") + + "GROUP BY grade ORDER BY c DESC LIMIT :limit", + **({"start": start, "end": end} if window["start"] else {}), + limit=top_n, + ) + tables.append( + table_payload( + "isslop_grades", + "IsSlop grades", + ["Grade", "Count"], + [[row["grade"], int(row["c"] or 0)] for row in grade_rows], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def build_storage(hours: int, compare: bool, top_n: int) -> dict: + window = window_config(hours) + end = window["end"] + start = window["start"] or "0000-01-01T00:00:00+00:00" + uploads = _count_window("attachments", start, end) if _table_exists("attachments") and window["start"] else ( + db["attachments"].count(deleted_at=None) if _table_exists("attachments") else 0 + ) + bytes_total = 0 + if _table_exists("attachments"): + attach_live = _live_clause("attachments") + row = list(db.query(f"SELECT COALESCE(SUM(file_size), 0) AS s FROM attachments WHERE {attach_live}")) + if row: + bytes_total = int(row[0]["s"] or 0) + project_files = db["project_files"].count(deleted_at=None) if _table_exists("project_files") else 0 + backup_bytes = 0 + if _table_exists("backups"): + row = list(db.query("SELECT COALESCE(SUM(size_bytes), 0) AS s FROM backups WHERE status = 'completed'")) + if row: + backup_bytes = int(row[0]["s"] or 0) + cards = [ + metric_card("uploads", "Uploads in window", uploads), + metric_card("attachment_bytes", "Attachment bytes", bytes_total), + metric_card("project_files", "Project files", project_files), + metric_card("backup_bytes", "Backup bytes", backup_bytes), + ] + series = [] + if _table_exists("attachments") and window["start"]: + series.append(series_payload("uploads", "Uploads", _series_table("attachments", window), window)) + attach_live = _live_clause("attachments") + size_rows = db.query( + f"SELECT {bucket_expr(window['granularity'])} AS bucket, " + "COALESCE(SUM(file_size), 0) AS v FROM attachments " + f"WHERE {attach_live} AND created_at >= :start AND created_at < :end " + "GROUP BY bucket", + start=start, + end=end, + ) + size_points = {row["bucket"]: float(row["v"]) for row in size_rows if row.get("bucket")} + series.append(series_payload("bytes", "Bytes uploaded", size_points, window)) + tables = [] + if _table_exists("attachments"): + attach_live = _live_clause("attachments") + mime_rows = db.query( + f"SELECT mime_type, COUNT(*) AS c FROM attachments WHERE {attach_live} " + + ("AND created_at >= :start AND created_at < :end " if window["start"] else "") + + "GROUP BY mime_type ORDER BY c DESC LIMIT :limit", + **({"start": start, "end": end} if window["start"] else {}), + limit=top_n, + ) + tables.append( + table_payload( + "mime", + "Upload MIME types", + ["MIME", "Count"], + [[row["mime_type"] or "(unknown)", int(row["c"] or 0)] for row in mime_rows], + ) + ) + return {"cards": cards, "series": series, "tables": tables} + + +def _highlights() -> list[dict]: + items: list[dict] = [] + if _table_exists("reactions"): + reaction_live = _live_clause("reactions") + row = list( + db.query( + f"SELECT emoji, COUNT(*) AS c FROM reactions WHERE {reaction_live} " + "GROUP BY emoji ORDER BY c DESC LIMIT 1" + ) + ) + if row: + items.append({"label": "Top reaction emoji", "value": row[0]["emoji"]}) + if _table_exists("posts"): + post_live = _live_clause("posts") + row = list( + db.query( + f"SELECT strftime('%H', created_at) AS hour, COUNT(*) AS c FROM posts " + f"WHERE {post_live} GROUP BY hour ORDER BY c DESC LIMIT 1" + ) + ) + if row: + items.append({"label": "Peak posting hour (UTC)", "value": f"{row[0]['hour']}:00"}) + if _table_exists("instances"): + row = list( + db.query( + "SELECT name, slug, uid, restart_count FROM instances " + "ORDER BY restart_count DESC LIMIT 1" + ) + ) + if row and int(row[0]["restart_count"] or 0) > 0: + label = _instance_label(row[0]) + items.append( + { + "label": "Most restarted container", + "value": f"{label} ({row[0]['restart_count']} restarts)", + } + ) + if _table_exists("game_steals"): + row = list(db.query("SELECT COUNT(*) AS c FROM game_steals")) + if row: + items.append({"label": "Total farm steals all-time", "value": int(row[0]["c"] or 0)}) + if _table_exists("user_customizations"): + count = db["user_customizations"].count() + if count: + items.append({"label": "Users with custom CSS/JS", "value": count}) + return items \ No newline at end of file diff --git a/devplacepy/services/statistics/tracking.py b/devplacepy/services/statistics/tracking.py new file mode 100644 index 00000000..4ff30a9c --- /dev/null +++ b/devplacepy/services/statistics/tracking.py @@ -0,0 +1,207 @@ +# retoor + +from __future__ import annotations + +import asyncio +import hashlib +import logging +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from typing import Optional +from urllib.parse import urlparse + +from devplacepy.config import SECRET_KEY +from devplacepy.database import db, get_setting +from devplacepy.utils import client_ip, get_current_user + +from .common import RETENTION_DAYS, hour_bucket, now_utc + +logger = logging.getLogger(__name__) + +EXCLUDED_PREFIXES = ( + "/static", + "/avatar", + "/openai", + "/devii/ws", + "/xmlrpc", + "/dbapi", + "/admin/statistics/data", + "/admin/ai-usage/data", + "/admin/services/data", +) + +FLUSH_SECONDS = 30 + +_view_buffer: dict[tuple[str, str, str], dict[str, int]] = defaultdict( + lambda: {"views": 0, "member_views": 0, "guest_views": 0} +) +_unique_buffer: list[tuple[str, str, str, Optional[str]]] = [] +_cached_daily_salt = "" +_flush_task: Optional[asyncio.Task] = None + + +def _tracking_enabled() -> bool: + return get_setting("statistics_tracking_enabled", "1") == "1" + + +def _daily_salt() -> str: + global _cached_daily_salt + day = now_utc().date().isoformat() + if not _cached_daily_salt or not _cached_daily_salt.startswith(day): + _cached_daily_salt = f"{day}:{SECRET_KEY}" + return _cached_daily_salt + + +def _visitor_hash(request) -> str: + ip = client_ip(request, default="") or "" + ua = (request.headers.get("user-agent") or "")[:120] + raw = f"{_daily_salt()}:{ip}:{ua}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _page_group(request) -> str: + route = request.scope.get("route") + path = getattr(route, "path", None) + if path: + return path + segments = [segment for segment in request.url.path.split("/") if segment] + if not segments: + return "/" + return f"/{segments[0]}" + + +def _referrer_group(request) -> str: + raw = request.headers.get("referer") or request.headers.get("referrer") or "" + if not raw: + return "direct" + try: + host = (urlparse(raw).hostname or "").lower() + except ValueError: + return "other" + if not host: + return "direct" + request_host = (request.url.hostname or "").lower() + if host == request_host: + return "internal" + return host[:120] + + +def should_track(request, status_code: int) -> bool: + if not _tracking_enabled(): + return False + if request.method != "GET": + return False + if status_code >= 400: + return False + path = request.url.path + for prefix in EXCLUDED_PREFIXES: + if path.startswith(prefix): + return False + accept = (request.headers.get("accept") or "").lower() + if "application/json" in accept and "text/html" not in accept: + return False + return True + + +def track_visit(request, status_code: int) -> None: + if not should_track(request, status_code): + return + bucket = hour_bucket(now_utc()) + page_group = _page_group(request) + referrer_group = _referrer_group(request) + key = (bucket, page_group, referrer_group) + slot = _view_buffer[key] + slot["views"] += 1 + user = get_current_user(request) + if user: + slot["member_views"] += 1 + member_uid = user["uid"] + else: + slot["guest_views"] += 1 + member_uid = None + visitor_hash = _visitor_hash(request) + _unique_buffer.append((bucket, visitor_hash, page_group, member_uid)) + + +def _flush_views() -> None: + if "visit_stats_hourly" not in db.tables: + return + if not _view_buffer: + return + pending = dict(_view_buffer) + _view_buffer.clear() + with db: + for (bucket, page_group, referrer_group), counts in pending.items(): + db.query( + "INSERT INTO visit_stats_hourly " + "(bucket_start, page_group, referrer_group, views, member_views, guest_views) " + "VALUES (:bucket, :page, :ref, :views, :members, :guests) " + "ON CONFLICT(bucket_start, page_group, referrer_group) DO UPDATE SET " + "views = views + excluded.views, " + "member_views = member_views + excluded.member_views, " + "guest_views = guest_views + excluded.guest_views", + bucket=bucket, + page=page_group, + ref=referrer_group, + views=counts["views"], + members=counts["member_views"], + guests=counts["guest_views"], + ) + + +def _flush_uniques() -> None: + if "visit_unique_slots" not in db.tables: + return + if not _unique_buffer: + return + pending = list(_unique_buffer) + _unique_buffer.clear() + with db: + for bucket, visitor_hash, page_group, member_uid in pending: + db.query( + "INSERT OR IGNORE INTO visit_unique_slots " + "(bucket_start, visitor_hash, page_group, user_uid) " + "VALUES (:bucket, :hash, :page, :uid)", + bucket=bucket, + hash=visitor_hash, + page=page_group, + uid=member_uid, + ) + + +def _prune_old() -> None: + cutoff = (now_utc() - timedelta(days=RETENTION_DAYS)).isoformat(timespec="seconds") + with db: + if "visit_stats_hourly" in db.tables: + db.query("DELETE FROM visit_stats_hourly WHERE bucket_start < :cutoff", cutoff=cutoff) + if "visit_unique_slots" in db.tables: + db.query("DELETE FROM visit_unique_slots WHERE bucket_start < :cutoff", cutoff=cutoff) + + +def flush_visits(*, prune: bool = False) -> None: + try: + _flush_views() + _flush_uniques() + if prune: + _prune_old() + except Exception: + logger.exception("visit statistics flush failed") + + +async def _flush_loop() -> None: + ticks = 0 + while True: + await asyncio.sleep(FLUSH_SECONDS) + ticks += 1 + flush_visits(prune=ticks % 120 == 0) + + +def start_visit_flusher() -> None: + global _flush_task + if _flush_task is not None: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + _flush_task = loop.create_task(_flush_loop()) \ No newline at end of file diff --git a/devplacepy/static/css/admin.css b/devplacepy/static/css/admin.css index ab4d25b2..58da4762 100644 --- a/devplacepy/static/css/admin.css +++ b/devplacepy/static/css/admin.css @@ -40,6 +40,30 @@ margin-bottom: 1.25rem; } +.admin-tabs[data-overflow-tabs] { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 0.25rem; +} + +.admin-tabs[data-overflow-tabs] .overflow-tabs-more { + background: none; + border: none; + box-shadow: none; + cursor: pointer; + padding: 0.4rem 0.85rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary); +} + +.admin-tabs[data-overflow-tabs] .overflow-tabs-more:hover { + color: var(--text-primary); + border-color: transparent; + background: none; +} + .admin-tab { display: inline-flex; align-items: center; diff --git a/devplacepy/static/css/awards.css b/devplacepy/static/css/awards.css new file mode 100644 index 00000000..4d319050 --- /dev/null +++ b/devplacepy/static/css/awards.css @@ -0,0 +1,107 @@ +.award-prominent { + margin-bottom: 1.25rem; + padding: 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + text-align: center; +} + +.award-prominent-image { + width: min(256px, 100%); + height: auto; + object-fit: contain; + margin: 0 auto 0.75rem; +} + +.award-prominent-desc { + font-size: 1.05rem; + margin: 0 0 0.5rem; +} + +.award-prominent-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + justify-content: center; + align-items: center; + color: var(--text-muted); + font-size: 0.9rem; +} + +.award-prominent-giver { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.awards-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +@media (max-width: 720px) { + .awards-grid { + grid-template-columns: 1fr; + } +} + +.award-tile { + position: relative; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); +} + +.award-tile-image { + width: 100%; + max-width: 256px; + height: auto; + object-fit: contain; + display: block; + margin: 0 auto 0.75rem; +} + +.award-tile-desc { + margin: 0 0 0.75rem; + word-break: break-word; +} + +.award-tile-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + align-items: center; + justify-content: space-between; + color: var(--text-muted); + font-size: 0.9rem; +} + +.award-tile-giver { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.award-revoke-form { + margin-top: 0.75rem; +} + +.award-revoke-btn { + width: 100%; +} + +#award-give-modal textarea { + width: 100%; + min-height: 5rem; + resize: vertical; +} + +.award-char-count { + display: block; + text-align: right; + color: var(--text-muted); + font-size: 0.85rem; +} \ No newline at end of file diff --git a/devplacepy/static/css/base.css b/devplacepy/static/css/base.css index d43235d0..6bca8275 100644 --- a/devplacepy/static/css/base.css +++ b/devplacepy/static/css/base.css @@ -30,6 +30,34 @@ overflow: hidden; } +[data-overflow-tabs] { + display: flex; + align-items: center; + gap: 0.25rem; + width: 100%; + max-width: 100%; + min-width: 0; +} + +.overflow-tabs-strip { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + flex-wrap: nowrap; + gap: inherit; + overflow: hidden; +} + +.overflow-tabs-strip > * { + flex-shrink: 0; + white-space: nowrap; +} + +[data-overflow-tabs] .overflow-tabs-more { + flex-shrink: 0; +} + .modal-card { width: 100%; max-width: 560px; @@ -394,22 +422,14 @@ img { display: block; } -::-webkit-scrollbar { - width: 6px; - height: 6px; +* { + scrollbar-width: none; } -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 3px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--border-light); +*::-webkit-scrollbar { + display: none; + width: 0; + height: 0; } .btn { @@ -559,6 +579,16 @@ img { .presence-dot.online { background: var(--success, #2f9e44); } +.award-badge { + position: absolute; + left: 0; + bottom: 0; + width: 25%; + height: 25%; + object-fit: contain; + pointer-events: none; +} + .topnav { position: fixed; top: 0; diff --git a/devplacepy/static/css/gateway.css b/devplacepy/static/css/gateway.css index 11f91b9a..29460e17 100644 --- a/devplacepy/static/css/gateway.css +++ b/devplacepy/static/css/gateway.css @@ -57,6 +57,18 @@ color: var(--text-muted); } +.gw-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 999px; + background: var(--bg-card-hover); + border: 1px solid var(--border); + font-size: 0.75rem; + color: var(--text-secondary); + margin-right: 0.25rem; + white-space: nowrap; +} + .gw-actions { text-align: right; white-space: nowrap; @@ -82,6 +94,17 @@ color: var(--text-primary); } +.gw-form-subtitle { + margin-top: var(--space-sm); + padding-top: var(--space-sm); + border-top: 1px dashed var(--border); +} + +.gw-form .gw-section-hint { + grid-column: 1 / -1; + margin: 0; +} + .gw-field { display: flex; flex-direction: column; diff --git a/devplacepy/static/css/profile.css b/devplacepy/static/css/profile.css index e8cf28f3..d373dd42 100644 --- a/devplacepy/static/css/profile.css +++ b/devplacepy/static/css/profile.css @@ -205,31 +205,20 @@ a.profile-stat-value:hover { } .profile-tabs { - display: flex; - align-items: center; - flex-wrap: nowrap; - gap: 0.25rem; margin-bottom: 1rem; background: var(--bg-card); border-radius: var(--radius-lg); padding: 0.25rem; border: 1px solid var(--border); - overflow-x: auto; - -webkit-overflow-scrolling: touch; scroll-margin-top: calc(var(--nav-height) + 1rem); } -.profile-tabs.tabs-managed { - overflow: hidden; -} - .profile-tab { flex-shrink: 0; white-space: nowrap; } -.profile-tabs-more { - margin-left: auto; +.profile-tabs .overflow-tabs-more { background: none; border: none; cursor: pointer; diff --git a/devplacepy/static/css/statistics.css b/devplacepy/static/css/statistics.css new file mode 100644 index 00000000..ec92afb7 --- /dev/null +++ b/devplacepy/static/css/statistics.css @@ -0,0 +1,229 @@ +/* retoor */ + +.statistics-toolbar { + flex-wrap: wrap; + align-items: flex-start; + gap: var(--space-md); +} + +.statistics-toolbar h2 { + flex: 1 1 auto; + min-width: 8rem; +} + +.statistics-controls { + flex: 1 1 16rem; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--space-sm) var(--space-lg); +} + +.statistics-controls-primary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm) var(--space-lg); +} + +.statistics-controls label, +.statistics-compare-label { + font-size: 0.8125rem; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.statistics-compare-label span { + white-space: normal; + line-height: 1.35; +} + +.statistics-generated { + flex: 1 1 12rem; + margin-left: 0; + font-size: 0.75rem; + color: var(--text-muted); + text-align: right; + white-space: normal; + word-break: break-word; + line-height: 1.35; +} + +@media (max-width: 720px) { + .statistics-toolbar { + flex-direction: column; + align-items: stretch; + } + + .statistics-controls { + justify-content: flex-start; + } + + .statistics-generated { + flex-basis: 100%; + text-align: left; + } +} + +.statistics-section { + margin: var(--space-xl) 0; +} + +.statistics-section h3 { + margin: 0 0 var(--space-base); + font-size: 0.9375rem; + color: var(--text-primary); +} + +.statistics-delta { + font-size: 0.6875rem; + font-weight: 600; + margin-top: 0.125rem; +} + +.statistics-delta.up { + color: var(--success); +} + +.statistics-delta.down { + color: var(--danger); +} + +.statistics-delta.flat { + color: var(--text-muted); +} + +.statistics-charts { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-md); + margin-bottom: var(--space-md); +} + +@media (min-width: 768px) { + .statistics-charts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.statistics-chart-panel { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-md); + min-height: 16rem; + display: flex; + flex-direction: column; +} + +.statistics-chart-panel h4 { + margin: 0 0 var(--space-sm); + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.statistics-chart-wrap { + position: relative; + flex: 1; + min-height: 12rem; +} + +.statistics-breakdowns-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--space-md); +} + +.statistics-table-panel { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.statistics-table-panel.is-wide { + grid-column: 1 / -1; +} + +.statistics-table-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-md) var(--space-md) var(--space-sm); + border-bottom: 1px solid var(--border); +} + +.statistics-table-panel-head h4 { + margin: 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.statistics-table-panel-count { + font-size: 0.6875rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.statistics-table-panel .admin-table-wrap { + overflow: visible; + max-height: none; +} + +.statistics-table-panel .admin-table td.statistics-col-name { + font-weight: 600; + color: var(--text-primary); +} + +.statistics-table-panel .admin-table td.statistics-col-key { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-primary); +} + +.statistics-table-panel .admin-table th.statistics-col-num, +.statistics-table-panel .admin-table td.statistics-col-num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.statistics-table-panel .admin-table td.statistics-col-num { + font-weight: 600; + color: var(--text-primary); +} + +.statistics-table-panel .admin-table th.statistics-col-text, +.statistics-table-panel .admin-table td.statistics-col-text { + color: var(--text-secondary); +} + +.statistics-note { + font-size: 0.75rem; + color: var(--text-muted); + margin: var(--space-sm) 0 var(--space-lg); +} + +.statistics-link { + font-size: 0.8125rem; + color: var(--accent); +} + +.statistics-link:hover { + color: var(--accent-hover); +} \ No newline at end of file diff --git a/devplacepy/static/js/Application.js b/devplacepy/static/js/Application.js index 8e2505f6..de9d1d24 100644 --- a/devplacepy/static/js/Application.js +++ b/devplacepy/static/js/Application.js @@ -44,6 +44,7 @@ import { LocalTime } from "./LocalTime.js"; import { ScrollMemory } from "./ScrollMemory.js"; import { GameFarm } from "./GameFarm.js"; import { Accessibility } from "./Accessibility.js"; +import { OverflowTabs } from "./OverflowTabs.js"; class Application { constructor() { @@ -95,6 +96,7 @@ class Application { this.localTime = new LocalTime(); this.scrollMemory = new ScrollMemory(); this.gameFarm = new GameFarm(); + this.overflowTabs = new OverflowTabs(); } } diff --git a/devplacepy/static/js/AwardGiver.js b/devplacepy/static/js/AwardGiver.js new file mode 100644 index 00000000..64ff06cc --- /dev/null +++ b/devplacepy/static/js/AwardGiver.js @@ -0,0 +1,66 @@ +// retoor + +import { Http } from "./Http.js"; + +class AwardGiver { + constructor() { + this.btn = document.querySelector("[data-award-give]"); + this.modal = document.getElementById("award-give-modal"); + if (!this.btn || !this.modal) return; + this.form = this.modal.querySelector("form"); + this.textarea = this.modal.querySelector('textarea[name="description"]'); + this.counter = this.modal.querySelector("[data-award-char-count]"); + this.btn.addEventListener("click", () => this.open()); + if (this.form) { + this.form.addEventListener("submit", (event) => this.submit(event)); + } + if (this.textarea) { + this.textarea.addEventListener("input", () => this.updateCounter()); + this.updateCounter(); + } + } + + open() { + if (this.btn.disabled) return; + this.modal.classList.add("visible"); + if (this.textarea) { + this.textarea.focus(); + } + } + + updateCounter() { + if (!this.counter || !this.textarea) return; + const max = Number(this.textarea.getAttribute("maxlength") || 125); + const length = this.textarea.value.length; + this.counter.textContent = `${length}/${max}`; + } + + async submit(event) { + event.preventDefault(); + const username = this.btn.dataset.username; + const description = (this.textarea?.value || "").trim(); + if (!description) return; + const submitBtn = this.form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.disabled = true; + try { + await Http.postJson(`/profile/${username}/award`, { description }); + this.modal.classList.remove("visible"); + if (this.form) this.form.reset(); + this.updateCounter(); + await window.app.dialog.alert({ + title: "Award submitted", + message: `Your award for @${username} is being created. They will be notified when it is ready.`, + }); + } catch { + await window.app.dialog.alert({ + title: "Award failed", + message: "Could not submit the award. Try again later.", + }); + } finally { + if (submitBtn) submitBtn.disabled = false; + } + } +} + +window.AwardGiver = AwardGiver; +export { AwardGiver }; \ No newline at end of file diff --git a/devplacepy/static/js/GatewayAdmin.js b/devplacepy/static/js/GatewayAdmin.js index 98b54819..887f4356 100644 --- a/devplacepy/static/js/GatewayAdmin.js +++ b/devplacepy/static/js/GatewayAdmin.js @@ -15,9 +15,25 @@ export class GatewayAdmin { async start() { this.bind(); + this.syncKindFields(); await this.reload(); } + syncKindFields() { + const kind = this.modelForm.kind.value || "chat"; + const priceInputLabel = this.root.querySelector("#gw-model-price-input-label"); + if (priceInputLabel) { + priceInputLabel.textContent = + kind === "image" + ? "Price per image ($)" + : "Price input / 1M ($) (embed/vision)"; + } + this.root.querySelectorAll("[data-kind-field]").forEach((field) => { + const kinds = (field.dataset.kindField || "").split(/\s+/).filter(Boolean); + field.style.display = kinds.includes(kind) ? "" : "none"; + }); + } + bind() { this.providerForm.addEventListener("submit", (event) => { event.preventDefault(); @@ -27,6 +43,7 @@ export class GatewayAdmin { event.preventDefault(); this.saveModel(); }); + this.modelForm.kind.addEventListener("change", () => this.syncKindFields()); this.providersBody.addEventListener("click", (event) => this.onProviderClick(event)); this.modelsBody.addEventListener("click", (event) => this.onModelClick(event)); } @@ -71,7 +88,9 @@ export class GatewayAdmin { el.innerHTML = ` default (from Services config): chat ${this.escape(def.model)} at ${this.escape(def.base_url)}, - embed ${this.escape(def.embed_model)}, vision ${this.escape(def.vision_model)}`; + embed ${this.escape(def.embed_model)}, + image ${this.escape(def.image_model)}, + vision ${this.escape(def.vision_model)}`; } renderProviders() { @@ -109,7 +128,7 @@ export class GatewayAdmin { const data = await Http.getJson("/admin/gateway/models"); const models = data.models || []; if (!models.length) { - this.modelsBody.innerHTML = `No model routes. Requests fall through to the default upstream.`; + this.modelsBody.innerHTML = `No model routes. Requests fall through to the default upstream.`; return 0; } this.modelsBody.innerHTML = models @@ -118,12 +137,26 @@ export class GatewayAdmin { ? `${this.escape(m.vision_provider || m.provider || "default")}/${this.escape(m.vision_model)}` : `-`; const provider = m.provider || "default"; + const badges = []; + if (m.context_tier_threshold_tokens) { + badges.push(`tiered`); + } + if (m.off_peak_start_minute !== null && m.off_peak_start_minute !== undefined) { + const start = this.minutesToTime(m.off_peak_start_minute); + const end = this.minutesToTime(m.off_peak_end_minute); + badges.push(`off-peak`); + } + if (m.kind === "image" && m.price_input_per_m) { + badges.push(`$${m.price_input_per_m}/img`); + } + const economy = badges.length ? badges.join(" ") : `-`; return ` ${this.escape(m.source_model)} ${this.escape(provider)} ${this.escape(m.target_model)} ${this.escape(m.kind)} ${vision} + ${economy} @@ -142,6 +175,28 @@ export class GatewayAdmin { return values; } + timeToMinutes(value) { + if (!value) return null; + const [hours, minutes] = value.split(":").map((part) => parseInt(part, 10)); + if (Number.isNaN(hours) || Number.isNaN(minutes)) return null; + return hours * 60 + minutes; + } + + minutesToTime(minutes) { + if (minutes === null || minutes === undefined || minutes === "") return ""; + const total = parseInt(minutes, 10); + if (Number.isNaN(total)) return ""; + const hours = Math.floor(total / 60) % 24; + const mins = total % 60; + return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; + } + + optionalFloat(value) { + if (value === "" || value === null || value === undefined) return null; + const parsed = parseFloat(value); + return Number.isNaN(parsed) ? null : parsed; + } + async saveProvider() { const values = this.formValues(this.providerForm); const payload = { @@ -174,6 +229,14 @@ export class GatewayAdmin { price_cache_miss_per_m: parseFloat(values.price_cache_miss_per_m) || 0, price_output_per_m: parseFloat(values.price_output_per_m) || 0, price_input_per_m: parseFloat(values.price_input_per_m) || 0, + context_tier_threshold_tokens: parseInt(values.context_tier_threshold_tokens, 10) || 0, + price_cache_hit_per_m_tier2: this.optionalFloat(values.price_cache_hit_per_m_tier2), + price_cache_miss_per_m_tier2: this.optionalFloat(values.price_cache_miss_per_m_tier2), + price_output_per_m_tier2: this.optionalFloat(values.price_output_per_m_tier2), + price_input_per_m_tier2: this.optionalFloat(values.price_input_per_m_tier2), + off_peak_start_minute: this.timeToMinutes(values.off_peak_start), + off_peak_end_minute: this.timeToMinutes(values.off_peak_end), + off_peak_discount_pct: parseFloat(values.off_peak_discount_pct) || 0, is_active: values.is_active === "1", }; try { @@ -243,6 +306,7 @@ export class GatewayAdmin { form.provider.value = model.provider || ""; form.target_model.value = model.target_model || ""; form.kind.value = model.kind || "chat"; + this.syncKindFields(); form.vision_provider.value = model.vision_provider || ""; form.vision_model.value = model.vision_model || ""; form.context_window.value = model.context_window || 0; @@ -250,6 +314,14 @@ export class GatewayAdmin { form.price_cache_miss_per_m.value = model.price_cache_miss_per_m || 0; form.price_output_per_m.value = model.price_output_per_m || 0; form.price_input_per_m.value = model.price_input_per_m || 0; + form.context_tier_threshold_tokens.value = model.context_tier_threshold_tokens || 0; + form.price_cache_hit_per_m_tier2.value = model.price_cache_hit_per_m_tier2 ?? ""; + form.price_cache_miss_per_m_tier2.value = model.price_cache_miss_per_m_tier2 ?? ""; + form.price_output_per_m_tier2.value = model.price_output_per_m_tier2 ?? ""; + form.price_input_per_m_tier2.value = model.price_input_per_m_tier2 ?? ""; + form.off_peak_start.value = this.minutesToTime(model.off_peak_start_minute); + form.off_peak_end.value = this.minutesToTime(model.off_peak_end_minute); + form.off_peak_discount_pct.value = model.off_peak_discount_pct || 0; form.is_active.value = model.is_active ? "1" : "0"; form.source_model.scrollIntoView({ block: "center" }); } diff --git a/devplacepy/static/js/LocalTime.js b/devplacepy/static/js/LocalTime.js index a30ebbde..2336a5bd 100644 --- a/devplacepy/static/js/LocalTime.js +++ b/devplacepy/static/js/LocalTime.js @@ -22,6 +22,13 @@ export class LocalTime { const date = new Date(iso); if (isNaN(date.getTime())) return null; if (mode === "ago") return this.relative(date); + if (mode === "award") { + const weekday = date.toLocaleDateString([], { weekday: "long" }); + const day = String(date.getDate()).padStart(2, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const year = date.getFullYear(); + return `${weekday}, ${day}-${month}-${year}`; + } if (mode === "date") return date.toLocaleDateString(); if (mode === "time") { return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); diff --git a/devplacepy/static/js/OnlineUsers.js b/devplacepy/static/js/OnlineUsers.js index d7bb7576..3b60ed35 100644 --- a/devplacepy/static/js/OnlineUsers.js +++ b/devplacepy/static/js/OnlineUsers.js @@ -49,6 +49,14 @@ export class OnlineUsers { dot.setAttribute("aria-hidden", "true"); badge.appendChild(img); badge.appendChild(dot); + if (user.award_prominent && user.last_award_slug) { + const award = document.createElement("img"); + award.className = "award-badge"; + award.src = `/awards/${encodeURIComponent(user.last_award_slug)}/64`; + award.alt = "Latest award"; + award.loading = "lazy"; + badge.appendChild(award); + } link.appendChild(badge); return link; } diff --git a/devplacepy/static/js/OverflowTabs.js b/devplacepy/static/js/OverflowTabs.js new file mode 100644 index 00000000..20970d1b --- /dev/null +++ b/devplacepy/static/js/OverflowTabs.js @@ -0,0 +1,115 @@ +// retoor + +export class OverflowTabs { + constructor() { + this.instances = []; + document.querySelectorAll("[data-overflow-tabs]").forEach((root) => { + const instance = this.create(root); + if (instance) { + this.instances.push(instance); + } + }); + } + + create(root) { + const more = root.querySelector(".overflow-tabs-more"); + const strip = root.querySelector(".overflow-tabs-strip"); + if (!more || !strip) { + return null; + } + const tabs = Array.from(strip.children).filter( + (el) => !el.classList.contains("overflow-tabs-more") + ); + if (!tabs.length) { + return null; + } + + const state = { + root, + strip, + more, + tabs, + overflow: [], + }; + + state.sync = () => { + void strip.offsetWidth; + }; + + state.stripOverflows = () => strip.scrollWidth > strip.clientWidth + 1; + + state.layout = () => { + tabs.forEach((tab) => { + tab.hidden = false; + }); + more.hidden = true; + state.sync(); + + if (!state.stripOverflows()) { + state.overflow = []; + return; + } + + more.hidden = false; + state.sync(); + + while (state.stripOverflows()) { + const victim = tabs + .filter((tab) => !tab.hidden && !tab.classList.contains("active")) + .pop(); + if (!victim) { + break; + } + victim.hidden = true; + state.sync(); + } + + state.overflow = tabs.filter((tab) => tab.hidden); + if (state.overflow.length > 0 || state.stripOverflows()) { + more.hidden = false; + } + }; + + state.open = (event) => { + event.preventDefault(); + event.stopPropagation(); + const items = state.overflow.map((tab) => ({ + icon: tab.dataset.menuIcon || "", + label: tab.dataset.menuLabel || tab.textContent.trim(), + onSelect: () => tab.click(), + })); + if (!items.length) { + return; + } + const rect = more.getBoundingClientRect(); + window.app.contextMenu.open(rect.left, rect.bottom + 4, items); + }; + + root.classList.add("tabs-managed"); + more.addEventListener("click", (event) => state.open(event)); + for (const tab of tabs) { + tab.addEventListener("click", () => { + requestAnimationFrame(() => state.layout()); + }); + } + + const scheduleLayout = () => { + requestAnimationFrame(() => state.layout()); + }; + + window.addEventListener("resize", scheduleLayout); + if (typeof ResizeObserver !== "undefined") { + state.resizeObserver = new ResizeObserver(scheduleLayout); + state.resizeObserver.observe(root); + state.resizeObserver.observe(strip); + } + + scheduleLayout(); + if (document.fonts?.ready) { + document.fonts.ready.then(scheduleLayout); + } + window.addEventListener("load", scheduleLayout, { once: true }); + + return state; + } +} \ No newline at end of file diff --git a/devplacepy/static/js/ProfileTabs.js b/devplacepy/static/js/ProfileTabs.js deleted file mode 100644 index 8153e9b5..00000000 --- a/devplacepy/static/js/ProfileTabs.js +++ /dev/null @@ -1,64 +0,0 @@ -// retoor - -export class ProfileTabs { - constructor(root) { - this.root = root; - this.more = root.querySelector(".profile-tabs-more"); - this.tabs = Array.from(root.querySelectorAll(".profile-tab")).filter( - (tab) => tab !== this.more - ); - if (!this.more || !this.tabs.length) { - return; - } - this.overflow = []; - this.root.classList.add("tabs-managed"); - this.more.addEventListener("click", (event) => this.open(event)); - this._onResize = () => this.layout(); - window.addEventListener("resize", this._onResize); - this.layout(); - } - - layout() { - this.tabs.forEach((tab) => { - tab.hidden = false; - }); - this.more.hidden = false; - - if (this._fits()) { - this.more.hidden = true; - this.overflow = []; - return; - } - - while (!this._fits()) { - const victim = this.tabs - .filter((tab) => !tab.hidden && !tab.classList.contains("active")) - .pop(); - if (!victim) { - break; - } - victim.hidden = true; - } - - this.overflow = this.tabs.filter((tab) => tab.hidden); - } - - _fits() { - return this.root.scrollWidth <= this.root.clientWidth; - } - - open(event) { - event.preventDefault(); - event.stopPropagation(); - const items = this.overflow.map((tab) => ({ - icon: tab.dataset.menuIcon || "", - label: tab.dataset.menuLabel || tab.textContent.trim(), - onSelect: () => tab.click(), - })); - if (!items.length) { - return; - } - const rect = this.more.getBoundingClientRect(); - window.app.contextMenu.open(rect.left, rect.bottom + 4, items); - } -} diff --git a/devplacepy/static/js/StatisticsCharts.js b/devplacepy/static/js/StatisticsCharts.js new file mode 100644 index 00000000..dffe5250 --- /dev/null +++ b/devplacepy/static/js/StatisticsCharts.js @@ -0,0 +1,111 @@ +// retoor + +const CHART_COLORS = [ + "#ff6b35", + "#7c4dff", + "#00bfa5", + "#448aff", + "#ffab00", + "#ff5252", + "#42a5f5", + "#ff4f8b", +]; + +export class StatisticsCharts { + constructor() { + this.instances = []; + } + + destroy() { + for (const chart of this.instances) { + try { + chart.destroy(); + } catch { + // ignore destroy errors + } + } + this.instances = []; + } + + render(container, seriesList) { + this.destroy(); + if (!container || !window.Chart || !Array.isArray(seriesList)) return; + const panels = []; + for (const series of seriesList) { + const panel = document.createElement("div"); + panel.className = "statistics-chart-panel"; + const title = document.createElement("h4"); + title.textContent = series.label || series.key || "Series"; + panel.appendChild(title); + const wrap = document.createElement("div"); + wrap.className = "statistics-chart-wrap"; + const canvas = document.createElement("canvas"); + canvas.setAttribute("role", "img"); + canvas.setAttribute("aria-label", `${series.label || "Chart"} over time`); + wrap.appendChild(canvas); + panel.appendChild(wrap); + container.appendChild(panel); + panels.push({ canvas, series }); + } + for (let index = 0; index < panels.length; index += 1) { + const { canvas, series } = panels[index]; + const labels = (series.points || []).map((point) => point.t); + const values = (series.points || []).map((point) => Number(point.v || 0)); + const color = CHART_COLORS[index % CHART_COLORS.length]; + const chart = new window.Chart(canvas, { + type: "line", + data: { + labels, + datasets: [ + { + label: series.label || series.key, + data: values, + borderColor: color, + backgroundColor: `${color}22`, + fill: true, + tension: 0.25, + pointRadius: labels.length > 40 ? 0 : 2, + pointHoverRadius: 4, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label(context) { + const value = context.parsed.y; + return `${series.label}: ${Number(value).toLocaleString("en-GB")}`; + }, + }, + }, + }, + scales: { + x: { + ticks: { + maxRotation: 0, + autoSkip: true, + maxTicksLimit: 8, + }, + grid: { color: "rgba(255, 255, 255, 0.08)" }, + ticks: { color: "#7a6a90" }, + }, + y: { + beginAtZero: true, + ticks: { + precision: 0, + color: "#7a6a90", + }, + grid: { color: "rgba(255, 255, 255, 0.08)" }, + }, + }, + }, + }); + this.instances.push(chart); + } + } +} \ No newline at end of file diff --git a/devplacepy/static/js/StatisticsDashboard.js b/devplacepy/static/js/StatisticsDashboard.js new file mode 100644 index 00000000..0d2c3d10 --- /dev/null +++ b/devplacepy/static/js/StatisticsDashboard.js @@ -0,0 +1,327 @@ +// retoor + +import { Http } from "./Http.js"; +import { StatisticsCharts } from "./StatisticsCharts.js"; + +class StatisticsDashboard { + constructor(options = {}) { + this.root = document.getElementById(options.rootId || "statistics-root"); + this.initialNode = document.getElementById(options.initialId || "statistics-initial"); + this.windowSelect = document.getElementById("statistics-window"); + this.compareToggle = document.getElementById("statistics-compare"); + this.generated = document.querySelector("[data-meta='generated']"); + this.tabLinks = Array.from(document.querySelectorAll(".admin-tabs .admin-tab[data-tab]")); + this.activeTab = options.activeTab || "overview"; + this.windowHours = Number(options.windowHours || 168); + this.charts = new StatisticsCharts(); + this.cache = new Map(); + this.loading = false; + } + + start() { + if (!this.root) return; + if (this.windowSelect) { + this.windowSelect.addEventListener("change", () => { + const parsed = parseInt(this.windowSelect.value, 10); + this.windowHours = Number.isNaN(parsed) ? 168 : parsed; + this.cache.clear(); + this.loadTab(this.activeTab, true); + }); + } + if (this.compareToggle) { + this.compareToggle.addEventListener("change", () => { + this.cache.clear(); + this.loadTab(this.activeTab, true); + }); + } + for (const link of this.tabLinks) { + link.addEventListener("click", (event) => { + event.preventDefault(); + const tab = link.dataset.tab; + if (!tab || tab === this.activeTab) return; + for (const node of this.tabLinks) { + node.classList.toggle("active", node.dataset.tab === tab); + } + this.activeTab = tab; + const url = new URL(window.location.href); + url.searchParams.set("tab", tab); + url.searchParams.set("hours", String(this.windowHours)); + window.history.replaceState({}, "", url); + this.loadTab(tab, false); + }); + } + const initial = this.readInitial(); + if (initial) { + this.cache.set(this.cacheKey(this.activeTab), initial); + this.render(initial); + } else { + this.loadTab(this.activeTab, true); + } + } + + readInitial() { + if (!this.initialNode) return null; + try { + return JSON.parse(this.initialNode.textContent || "null"); + } catch { + return null; + } + } + + cacheKey(tab) { + const compare = this.compareToggle && this.compareToggle.checked ? 1 : 0; + return `${tab}:${this.windowHours}:${compare}`; + } + + async loadTab(tab, force) { + const key = this.cacheKey(tab); + if (!force && this.cache.has(key)) { + this.render(this.cache.get(key)); + return; + } + if (this.loading) return; + this.loading = true; + this.root.innerHTML = '

Loading statistics...

'; + try { + const compare = this.compareToggle && this.compareToggle.checked ? 1 : 0; + const data = await Http.getJson( + `/admin/statistics/data?tab=${encodeURIComponent(tab)}&hours=${this.windowHours}&compare=${compare}` + ); + this.cache.set(key, data); + this.render(data); + } catch { + this.root.innerHTML = '

Could not load statistics.

'; + } finally { + this.loading = false; + } + } + + formatValue(card) { + const value = card.value; + const kind = card.format || "int"; + if (kind === "money") { + const number = Number(value || 0); + return number < 1 ? `$${number.toFixed(4)}` : `$${number.toFixed(2)}`; + } + if (kind === "decimal") { + return Number(value || 0).toLocaleString("en-GB", { maximumFractionDigits: 1 }); + } + return Number(value || 0).toLocaleString("en-GB"); + } + + deltaMarkup(card) { + if (card.delta === undefined || card.delta === null) return null; + const direction = card.direction || "flat"; + const arrow = direction === "up" ? "\u2191" : direction === "down" ? "\u2193" : "\u2192"; + const node = document.createElement("span"); + node.className = `statistics-delta ${direction}`; + node.textContent = `${arrow} ${Math.abs(Number(card.delta || 0)).toLocaleString("en-GB")}%`; + return node; + } + + isNumericValue(value) { + if (typeof value === "number" && Number.isFinite(value)) return true; + const text = String(value ?? "").trim().replace(/,/g, ""); + if (!text) return false; + return /^-?\d+(\.\d+)?$/.test(text); + } + + columnIsNumeric(columnIndex, columns, rows) { + const header = (columns[columnIndex] || "").toLowerCase(); + const numericHeaders = [ + "count", "events", "views", "gists", "posts", "votes", "actions", "stars", + "turns", "requests", "cost", "coins", "level", "restarts", "followers", + "upvotes", "downvotes", "members", "guests", "tokens", "errors", + ]; + if (numericHeaders.some((token) => header.includes(token))) return true; + const sample = rows.slice(0, 12); + if (!sample.length) return columnIndex > 0; + return sample.every((row) => this.isNumericValue(row[columnIndex])); + } + + formatTableCell(value) { + if (typeof value === "number" && Number.isFinite(value)) { + if (Number.isInteger(value)) return value.toLocaleString("en-GB"); + return value.toLocaleString("en-GB", { maximumFractionDigits: 2 }); + } + const text = String(value ?? ""); + const plain = text.trim().replace(/,/g, ""); + if (/^-?\d+(\.\d+)?$/.test(plain)) { + const number = Number(plain); + if (Number.isInteger(number)) return number.toLocaleString("en-GB"); + return number.toLocaleString("en-GB", { maximumFractionDigits: 2 }); + } + return text; + } + + columnClass(columnIndex, columns, rows) { + const header = (columns[columnIndex] || "").toLowerCase(); + if (columnIndex === 0) { + if ( + header.includes("username") + || header === "user" + || header.includes("instance") + || header === "name" + ) { + return "statistics-col-name"; + } + return "statistics-col-key"; + } + if (this.columnIsNumeric(columnIndex, columns, rows)) return "statistics-col-num"; + return "statistics-col-text"; + } + + renderBreakdownTable(table) { + const columns = table.columns || []; + const rows = table.rows || []; + const panel = document.createElement("article"); + panel.className = "statistics-table-panel"; + if (columns.length > 2) panel.classList.add("is-wide"); + + const head = document.createElement("div"); + head.className = "statistics-table-panel-head"; + const title = document.createElement("h4"); + title.textContent = table.title || ""; + head.appendChild(title); + const count = document.createElement("span"); + count.className = "statistics-table-panel-count"; + count.textContent = `${rows.length.toLocaleString("en-GB")} rows`; + head.appendChild(count); + panel.appendChild(head); + + const wrap = document.createElement("div"); + wrap.className = "admin-table-wrap"; + + const node = document.createElement("table"); + node.className = "admin-table"; + + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (let index = 0; index < columns.length; index += 1) { + const th = document.createElement("th"); + th.className = this.columnClass(index, columns, rows); + th.textContent = columns[index]; + headRow.appendChild(th); + } + thead.appendChild(headRow); + node.appendChild(thead); + + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + for (let index = 0; index < columns.length; index += 1) { + const td = document.createElement("td"); + const cellClass = this.columnClass(index, columns, rows); + td.className = cellClass; + const cell = row[index]; + td.textContent = cellClass === "statistics-col-num" + ? this.formatTableCell(cell) + : String(cell ?? ""); + tr.appendChild(td); + } + tbody.appendChild(tr); + } + node.appendChild(tbody); + wrap.appendChild(node); + panel.appendChild(wrap); + return panel; + } + + render(data) { + if (!data) return; + if (this.generated && data.generated_at) { + this.generated.textContent = `Generated ${data.generated_at.replace("T", " ").slice(0, 19)} UTC`; + } + this.root.innerHTML = ""; + if (Array.isArray(data.highlights) && data.highlights.length) { + const wrap = document.createElement("div"); + wrap.className = "metric-cards"; + for (const item of data.highlights) { + const card = document.createElement("div"); + card.className = "metric-card"; + const value = document.createElement("span"); + value.className = "metric-value"; + value.textContent = String(item.value ?? ""); + const label = document.createElement("span"); + label.className = "metric-label"; + label.textContent = item.label || ""; + card.appendChild(value); + card.appendChild(label); + wrap.appendChild(card); + } + this.root.appendChild(wrap); + } + if (Array.isArray(data.cards) && data.cards.length) { + const grid = document.createElement("div"); + grid.className = "metric-cards"; + for (const card of data.cards) { + const node = document.createElement("div"); + node.className = "metric-card"; + const value = document.createElement("span"); + value.className = "metric-value"; + value.textContent = this.formatValue(card); + const label = document.createElement("span"); + label.className = "metric-label"; + label.textContent = card.label || ""; + node.appendChild(value); + node.appendChild(label); + const delta = this.deltaMarkup(card); + if (delta) node.appendChild(delta); + grid.appendChild(node); + } + this.root.appendChild(grid); + } + if (Array.isArray(data.series) && data.series.length) { + const section = document.createElement("div"); + section.className = "statistics-section"; + const heading = document.createElement("h3"); + heading.textContent = "Trends"; + section.appendChild(heading); + const charts = document.createElement("div"); + charts.className = "statistics-charts"; + section.appendChild(charts); + this.root.appendChild(section); + const renderCharts = () => this.charts.render(charts, data.series); + if (window.Chart) { + renderCharts(); + } else { + window.addEventListener("load", renderCharts, { once: true }); + } + } else { + this.charts.destroy(); + } + if (Array.isArray(data.tables) && data.tables.length) { + const section = document.createElement("div"); + section.className = "statistics-section statistics-breakdowns"; + const heading = document.createElement("h3"); + heading.textContent = "Breakdowns"; + section.appendChild(heading); + const grid = document.createElement("div"); + grid.className = "statistics-breakdowns-grid"; + for (const table of data.tables) { + grid.appendChild(this.renderBreakdownTable(table)); + } + section.appendChild(grid); + this.root.appendChild(section); + } + if (data.notes && data.notes.full_page) { + const note = document.createElement("p"); + note.className = "statistics-note"; + const link = document.createElement("a"); + link.className = "statistics-link"; + link.href = data.notes.full_page; + link.textContent = "Open full detail page"; + note.appendChild(link); + this.root.appendChild(note); + } + if (data.notes && data.notes.retention_days) { + const note = document.createElement("p"); + note.className = "statistics-note"; + note.textContent = `Visitor data retention: ${data.notes.retention_days} days.`; + this.root.appendChild(note); + } + } +} + +window.StatisticsDashboard = StatisticsDashboard; +export { StatisticsDashboard }; \ No newline at end of file diff --git a/devplacepy/static/vendor/chartjs/chart.umd.min.js b/devplacepy/static/vendor/chartjs/chart.umd.min.js new file mode 100644 index 00000000..0bae5b84 --- /dev/null +++ b/devplacepy/static/vendor/chartjs/chart.umd.min.js @@ -0,0 +1,20 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/chart.js@4.4.7/dist/chart.umd.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Chart.js v4.4.7 + * https://www.chartjs.org + * (c) 2024 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}b.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return b}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.7";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=Z(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Ko(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Ko(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=qo(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); +//# sourceMappingURL=chart.umd.js.map diff --git a/devplacepy/stealth.py b/devplacepy/stealth.py index f9eb755a..8e96e0bd 100644 --- a/devplacepy/stealth.py +++ b/devplacepy/stealth.py @@ -6,6 +6,7 @@ import logging import re import ssl from dataclasses import dataclass, field +from os import environ from pathlib import Path from typing import Iterable, Mapping, Sequence from urllib.parse import unquote, urlsplit @@ -14,6 +15,8 @@ import httpx logger = logging.getLogger("stealth") +OUTBOUND_PROXY_URL: str | None = environ.get("DEVPLACE_OUTBOUND_PROXY_URL", "").strip() or None + def _supported_accept_encoding() -> str: encodings = ["gzip", "deflate"] @@ -55,7 +58,7 @@ CHROME_CIPHER_SUITES: str = ":".join( ALPN_PROTOCOLS: tuple[str, ...] = ("h2", "http/1.1") -DEFAULT_CHROME_VERSION: str = "149" +DEFAULT_CHROME_VERSION: str = "146" DEFAULT_CHUNK_SIZE: int = 65536 @@ -155,6 +158,24 @@ def resource_kind_for_url(url: str) -> str: return "image" if path.endswith(IMAGE_EXTENSIONS) else "empty" +CONSENT_GATE_MARKERS: tuple[str, ...] = ("privacy gate", "privacy-gate", "cmpproperties", "consent.js") + +CONSENT_GATE_CALLBACK_RE = re.compile(r"decodeURIComponent\(\s*['\"]([^'\"]+)['\"]\s*\)") + + +def detect_consent_gate(html: str) -> str | None: + lowered = html.lower() + if not any(marker in lowered for marker in CONSENT_GATE_MARKERS): + return None + match = CONSENT_GATE_CALLBACK_RE.search(html) + if not match: + return None + candidate = unquote(match.group(1)) + if not candidate.startswith(("http://", "https://")): + return None + return candidate + + def build_chrome_ssl_context() -> ssl.SSLContext: context = ssl.create_default_context() context.check_hostname = True @@ -207,13 +228,26 @@ def chrome_base_headers(profile: ChromeProfile | None = None) -> dict[str, str]: } +def configured_proxy_url() -> str | None: + try: + from devplacepy.database import get_setting + + configured = get_setting("outbound_proxy_url", "").strip() + except ImportError: + configured = "" + return configured or OUTBOUND_PROXY_URL + + def stealth_transport( profile: ChromeProfile | None = None, + *, + proxy: str | None = None, **kwargs: object, ) -> httpx.AsyncBaseTransport: + effective_proxy = proxy or configured_proxy_url() if CURL_AVAILABLE: - return CurlTransport() - return httpx.AsyncHTTPTransport(verify=chrome_ssl_context(), http2=True, **kwargs) + return CurlTransport(proxy=effective_proxy) + return httpx.AsyncHTTPTransport(verify=chrome_ssl_context(), http2=True, proxy=effective_proxy, **kwargs) def stealth_async_client( @@ -221,13 +255,14 @@ def stealth_async_client( profile: ChromeProfile | None = None, headers: Mapping[str, str] | None = None, transport: httpx.AsyncBaseTransport | None = None, + proxy: str | None = None, **kwargs: object, ) -> httpx.AsyncClient: merged_headers: dict[str, str] = {} if CURL_AVAILABLE else chrome_base_headers(profile) if headers: merged_headers.update({key.lower(): value for key, value in headers.items()}) if transport is None: - transport = stealth_transport(profile) + transport = stealth_transport(profile, proxy=proxy) return httpx.AsyncClient( transport=transport, trust_env=True, @@ -240,6 +275,7 @@ def stealth_sync_client( *, profile: ChromeProfile | None = None, headers: Mapping[str, str] | None = None, + proxy: str | None = None, **kwargs: object, ) -> httpx.Client: merged_headers = chrome_base_headers(profile) @@ -250,6 +286,7 @@ def stealth_sync_client( verify=chrome_ssl_context(), trust_env=True, headers=merged_headers, + proxy=proxy or configured_proxy_url(), **kwargs, ) @@ -627,4 +664,6 @@ __all__ = [ "curl_active", "resource_kind_for_url", "slugify_filename", + "detect_consent_gate", + "configured_proxy_url", ] diff --git a/devplacepy/templates/_avatar_link.html b/devplacepy/templates/_avatar_link.html index 0f079fdb..47c284b7 100644 --- a/devplacepy/templates/_avatar_link.html +++ b/devplacepy/templates/_avatar_link.html @@ -2,5 +2,6 @@ {{ _user['username'] }} {% include "_presence_dot.html" %} +{% include "_award_badge.html" %} {% endif %} \ No newline at end of file diff --git a/devplacepy/templates/_award_badge.html b/devplacepy/templates/_award_badge.html new file mode 100644 index 00000000..98625070 --- /dev/null +++ b/devplacepy/templates/_award_badge.html @@ -0,0 +1,3 @@ +{% if award_is_prominent(_user) %} +Latest award +{% endif %} \ No newline at end of file diff --git a/devplacepy/templates/_awards_gallery.html b/devplacepy/templates/_awards_gallery.html new file mode 100644 index 00000000..19597334 --- /dev/null +++ b/devplacepy/templates/_awards_gallery.html @@ -0,0 +1,31 @@ +
+ {% for award in awards %} +
+ {{ award['description'] }} +
{{ render_content(award['description']) }}
+
+ {% if award.get('giver') %} + + {{ award['giver']['username'] }} + @{{ award['giver']['username'] }} + + {% endif %} + {% if award.get('generated_at') %} + {{ award_date(award['generated_at']) }} + {% endif %} +
+ {% if viewer_is_admin %} +
+ +
+ {% endif %} +
+ {% else %} +
No awards yet.
+ {% endfor %} +
\ No newline at end of file diff --git a/devplacepy/templates/admin_base.html b/devplacepy/templates/admin_base.html index cbfcaeea..279e0a68 100644 --- a/devplacepy/templates/admin_base.html +++ b/devplacepy/templates/admin_base.html @@ -35,6 +35,9 @@ 📊 AI usage + + 📈 Statistics + 📋 Audit Log diff --git a/devplacepy/templates/admin_gateway.html b/devplacepy/templates/admin_gateway.html index 665f0ee6..9b305ae1 100644 --- a/devplacepy/templates/admin_gateway.html +++ b/devplacepy/templates/admin_gateway.html @@ -9,7 +9,7 @@

Gateway routing

-

Map any requested model name onto a provider and target model, each with its own pricing economy and an optional vision model for image to text merging. Unmapped requests fall through to the default upstream unchanged.

+

Map any requested model name onto a provider and target model, each with its own pricing economy and an optional vision model for image to text merging. Image routes use a flat per-image price. Unmapped requests fall through to the default upstream unchanged.

@@ -40,12 +40,12 @@

Model routes

-

Each source model maps to a provider and target model with its own economy (USD per 1M tokens). Chat uses cache-hit, cache-miss and output prices; embeddings use the input price; a vision model adds input and output pricing for the image description merge.

+

Each source model maps to a provider and target model with its own economy. Chat uses cache-hit, cache-miss and output prices (USD per 1M tokens); embeddings use the input price; image routes use the input price as a flat USD per image; a vision model adds input and output pricing for the image description merge.

- +
Model routes
SourceProviderTargetKindVisionActions
SourceProviderTargetKindVisionEconomyActions
@@ -55,15 +55,25 @@
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+

Tiered / off-peak pricing (optional)

+

Some providers charge a different rate once a request crosses a context-length threshold, or discount a fixed time-of-day window. Leave blank/zero to keep the flat rates above at all times.

+
+
+
+
+
+
+
+
diff --git a/devplacepy/templates/admin_settings.html b/devplacepy/templates/admin_settings.html index 3ea8d7fd..c4e2d8b5 100644 --- a/devplacepy/templates/admin_settings.html +++ b/devplacepy/templates/admin_settings.html @@ -113,6 +113,12 @@ Cookie lifetime when "remember me" is checked at login. +
+ + + Routes every outbound stealth-client request (Devii fetch, DeepSearch, news, attachments, and more) through this proxy. Leave empty to connect directly. Needed only when a destination blocks this server's own IP/network (not its browser fingerprint) - most sites never need this. +
+

Custom Code

diff --git a/devplacepy/templates/admin_statistics.html b/devplacepy/templates/admin_statistics.html new file mode 100644 index 00000000..6b7a6ec9 --- /dev/null +++ b/devplacepy/templates/admin_statistics.html @@ -0,0 +1,58 @@ +{% extends "admin_base.html" %} +{% block extra_head %} +{{ super() }} + + +{% endblock %} +{% block admin_content %} +
+

Statistics

+
+
+ + +
+ - +
+
+ + + +
+

Loading statistics...

+
+ + +{% endblock %} +{% block extra_js %} + + + +{% endblock %} \ No newline at end of file diff --git a/devplacepy/templates/admin_trash.html b/devplacepy/templates/admin_trash.html index 1bf67296..d73ed383 100644 --- a/devplacepy/templates/admin_trash.html +++ b/devplacepy/templates/admin_trash.html @@ -5,12 +5,15 @@ {{ pagination.total }} soft-deleted
-