Merge feature/stats-quota-taxonomy
- refactor: canonical quota-action taxonomy + rename migration 0020 + i18n labels - feat: consolidate Settings>Sync into a per-user Stats page (admin System tab)
This commit is contained in:
commit
46de58082c
22 changed files with 509 additions and 339 deletions
51
backend/alembic/versions/0020_rename_quota_actions.py
Normal file
51
backend/alembic/versions/0020_rename_quota_actions.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""rename quota_events.action to the canonical <entity>_<action> taxonomy
|
||||||
|
|
||||||
|
Revision ID: 0020_rename_quota_actions
|
||||||
|
Revises: 0019_user_yt_channel_id
|
||||||
|
Create Date: 2026-06-19
|
||||||
|
|
||||||
|
The quota-attribution action keys were inconsistent (mixed verbs, ad-hoc names). Rename the
|
||||||
|
stored historical values to the canonical scheme defined in app.quota.QuotaAction:
|
||||||
|
``<entity>_<action>``, snake_case, explicit pull/push. Pure data migration on quota_events;
|
||||||
|
fully reversible. (Scheduler job ids and progress phase labels are a separate namespace and
|
||||||
|
are NOT touched.)
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0020_rename_quota_actions"
|
||||||
|
down_revision: Union[str, None] = "0019_user_yt_channel_id"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
# old action value -> new canonical key (mirrors app.quota.QuotaAction).
|
||||||
|
RENAMES: list[tuple[str, str]] = [
|
||||||
|
("sync_subscriptions", "subscriptions_pull"),
|
||||||
|
("subscription_resync", "subscriptions_resync"),
|
||||||
|
("playlist_sync", "playlists_pull"),
|
||||||
|
("playlist_push", "playlists_push"),
|
||||||
|
("playlist_delete", "playlists_delete"),
|
||||||
|
("backfill_recent", "videos_backfill_recent"),
|
||||||
|
("backfill_deep", "videos_backfill_full"),
|
||||||
|
("enrich", "videos_enrich"),
|
||||||
|
("video_lookup", "videos_lookup"),
|
||||||
|
("discovery", "channels_discover"),
|
||||||
|
("subscribe", "channels_subscribe"),
|
||||||
|
("unsubscribe", "channels_unsubscribe"),
|
||||||
|
("maintenance", "maintenance_revalidate"),
|
||||||
|
("api", "other"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_SQL = sa.text("UPDATE quota_events SET action = :to WHERE action = :frm")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for frm, to in RENAMES:
|
||||||
|
op.execute(_SQL.bindparams(frm=frm, to=to))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for frm, to in RENAMES:
|
||||||
|
op.execute(_SQL.bindparams(frm=to, to=frm))
|
||||||
|
|
@ -17,6 +17,32 @@ from app.models import ApiQuotaUsage, QuotaEvent
|
||||||
|
|
||||||
_PACIFIC = ZoneInfo("America/Los_Angeles")
|
_PACIFIC = ZoneInfo("America/Los_Angeles")
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaAction:
|
||||||
|
"""Canonical quota-attribution action keys (one source of truth).
|
||||||
|
|
||||||
|
Naming scheme: ``<entity>_<action>``, all snake_case, explicit pull/push direction.
|
||||||
|
The stored value is this machine key; the user-facing label is resolved from i18n
|
||||||
|
(frontend ``quotaActions.<key>``). Distinct from scheduler job ids and progress phase
|
||||||
|
labels — do not conflate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUBSCRIPTIONS_PULL = "subscriptions_pull"
|
||||||
|
SUBSCRIPTIONS_RESYNC = "subscriptions_resync"
|
||||||
|
PLAYLISTS_PULL = "playlists_pull"
|
||||||
|
PLAYLISTS_PUSH = "playlists_push"
|
||||||
|
PLAYLISTS_DELETE = "playlists_delete"
|
||||||
|
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
|
||||||
|
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
|
||||||
|
VIDEOS_ENRICH = "videos_enrich"
|
||||||
|
VIDEOS_LOOKUP = "videos_lookup"
|
||||||
|
CHANNELS_DISCOVER = "channels_discover"
|
||||||
|
CHANNELS_SUBSCRIBE = "channels_subscribe"
|
||||||
|
CHANNELS_UNSUBSCRIBE = "channels_unsubscribe"
|
||||||
|
MAINTENANCE_REVALIDATE = "maintenance_revalidate"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
# Request/job-scoped attribution for quota spend: who triggered it and what kind of work.
|
# Request/job-scoped attribution for quota spend: who triggered it and what kind of work.
|
||||||
# Set at entry points (route handlers, scheduler jobs) via attribute(); read by
|
# Set at entry points (route handlers, scheduler jobs) via attribute(); read by
|
||||||
# record_usage. Default = background/system, generic action.
|
# record_usage. Default = background/system, generic action.
|
||||||
|
|
@ -24,7 +50,7 @@ _actor_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
|
||||||
"quota_actor_id", default=None
|
"quota_actor_id", default=None
|
||||||
)
|
)
|
||||||
_action: contextvars.ContextVar[str] = contextvars.ContextVar(
|
_action: contextvars.ContextVar[str] = contextvars.ContextVar(
|
||||||
"quota_action", default="api"
|
"quota_action", default=QuotaAction.OTHER
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ def discover_channels(
|
||||||
# to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it.
|
# to yourself. Lazy + cached; needs OAuth, so demo / token-less users just skip it.
|
||||||
if user.yt_channel_id is None and user.token is not None and not user.is_demo:
|
if user.yt_channel_id is None and user.token is not None and not user.is_demo:
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt:
|
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
|
||||||
own = yt.get_my_channel_id()
|
own = yt.get_my_channel_id()
|
||||||
if own:
|
if own:
|
||||||
user.yt_channel_id = own
|
user.yt_channel_id = own
|
||||||
|
|
@ -181,7 +181,7 @@ def discover_channels(
|
||||||
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
|
need = [ch.id for ch, _, _ in rows if ch.details_synced_at is None][:DISCOVERY_ENRICH_CAP]
|
||||||
if need:
|
if need:
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "discovery"), YouTubeClient(db, user) as yt:
|
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
|
||||||
apply_channel_details(db, yt.get_channels(need))
|
apply_channel_details(db, yt.get_channels(need))
|
||||||
db.commit()
|
db.commit()
|
||||||
rows = _discovery_rows(db, user)
|
rows = _discovery_rows(db, user)
|
||||||
|
|
@ -243,7 +243,7 @@ def update_channel(
|
||||||
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
|
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
|
||||||
# the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
|
# the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
|
||||||
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
|
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
|
||||||
with quota.attribute(user.id, "backfill_recent"):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||||
run_recent_backfill(db, [channel], max_channels=1)
|
run_recent_backfill(db, [channel], max_channels=1)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -274,7 +274,7 @@ def reset_backfill(
|
||||||
channel.recent_synced_at = None
|
channel.recent_synced_at = None
|
||||||
sub.deep_requested = True
|
sub.deep_requested = True
|
||||||
db.commit()
|
db.commit()
|
||||||
with quota.attribute(user.id, "backfill_recent"):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||||
run_recent_backfill(db, [channel], max_channels=1)
|
run_recent_backfill(db, [channel], max_channels=1)
|
||||||
return {"id": channel_id, "reset": True}
|
return {"id": channel_id, "reset": True}
|
||||||
|
|
||||||
|
|
@ -306,7 +306,7 @@ def subscribe(
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
|
raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "subscribe"), YouTubeClient(db, user) as yt:
|
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt:
|
||||||
yt_sub_id = yt.insert_subscription(channel_id)
|
yt_sub_id = yt.insert_subscription(channel_id)
|
||||||
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up
|
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up
|
||||||
# properly right away; no video pull here.
|
# properly right away; no video pull here.
|
||||||
|
|
@ -356,7 +356,7 @@ def unsubscribe(
|
||||||
detail="No YouTube subscription id on record — sync subscriptions first.",
|
detail="No YouTube subscription id on record — sync subscriptions first.",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "unsubscribe"), YouTubeClient(db, user) as yt:
|
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE), YouTubeClient(db, user) as yt:
|
||||||
yt.delete_subscription(sub.yt_subscription_id)
|
yt.delete_subscription(sub.yt_subscription_id)
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
|
||||||
|
|
|
||||||
|
|
@ -549,7 +549,7 @@ def get_video_detail(
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt:
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_LOOKUP), YouTubeClient(db, user) as yt:
|
||||||
items = yt.get_videos([video_id])
|
items = yt.get_videos([video_id])
|
||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}")
|
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}")
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ def sync_youtube(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Connect YouTube (read access) to sync your playlists.",
|
detail="Connect YouTube (read access) to sync your playlists.",
|
||||||
)
|
)
|
||||||
with quota.attribute(user.id, "playlist_sync"):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
return sync_user_playlists(db, user)
|
return sync_user_playlists(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -266,7 +266,7 @@ def revert_youtube(
|
||||||
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
|
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "playlist_sync"):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
return repull_playlist(db, user, pl)
|
return repull_playlist(db, user, pl)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
@ -298,7 +298,7 @@ def push_plan(
|
||||||
status_code=403, detail="Enable YouTube editing in Settings first."
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "playlist_push"):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
plan = plan_push(db, user, pl)
|
plan = plan_push(db, user, pl)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
|
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
|
||||||
|
|
@ -322,7 +322,7 @@ def push(
|
||||||
status_code=403, detail="Enable YouTube editing in Settings first."
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "playlist_push"):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||||
plan = plan_push(db, user, pl)
|
plan = plan_push(db, user, pl)
|
||||||
if plan["units_estimate"] > quota.remaining_today(db):
|
if plan["units_estimate"] > quota.remaining_today(db):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -418,7 +418,7 @@ def delete_playlist(
|
||||||
status_code=403, detail="Enable YouTube editing in Settings first."
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "playlist_delete"):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
yt.delete_playlist(pl.yt_playlist_id)
|
yt.delete_playlist(pl.yt_playlist_id)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ def sync_subscriptions(
|
||||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
detail="Connect your YouTube account (read access) to import subscriptions.",
|
||||||
)
|
)
|
||||||
before = quota.units_used_today(db)
|
before = quota.units_used_today(db)
|
||||||
with quota.attribute(user.id, "sync_subscriptions"):
|
with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL):
|
||||||
result = import_subscriptions(db, user)
|
result = import_subscriptions(db, user)
|
||||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
||||||
result["quota_remaining_today"] = quota.remaining_today(db)
|
result["quota_remaining_today"] = quota.remaining_today(db)
|
||||||
|
|
@ -63,7 +63,7 @@ def sync_backfill(
|
||||||
max_channels: int = 25,
|
max_channels: int = 25,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
before = quota.units_used_today(db)
|
before = quota.units_used_today(db)
|
||||||
with quota.attribute(user.id, "backfill_recent"):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||||
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
||||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
||||||
return result
|
return result
|
||||||
|
|
@ -74,7 +74,7 @@ def sync_enrich(
|
||||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
before = quota.units_used_today(db)
|
before = quota.units_used_today(db)
|
||||||
with quota.attribute(user.id, "enrich"):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
|
||||||
enriched = run_enrich(db)
|
enriched = run_enrich(db)
|
||||||
return {
|
return {
|
||||||
"enriched": enriched,
|
"enriched": enriched,
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ def _rss_job() -> None:
|
||||||
|
|
||||||
def _enrich_job() -> None:
|
def _enrich_job() -> None:
|
||||||
def work(db):
|
def work(db):
|
||||||
with quota.attribute(None, "enrich"):
|
with quota.attribute(None, quota.QuotaAction.VIDEOS_ENRICH):
|
||||||
return run_enrich(db)
|
return run_enrich(db)
|
||||||
|
|
||||||
_job("enrich", work)
|
_job("enrich", work)
|
||||||
|
|
@ -214,9 +214,9 @@ def _backfill_job() -> None:
|
||||||
# Recent-first for not-yet-synced channels, then deep backfill for the rest. All
|
# Recent-first for not-yet-synced channels, then deep backfill for the rest. All
|
||||||
# background spend is attributed to the system (no actor), split by action.
|
# background spend is attributed to the system (no actor), split by action.
|
||||||
def work(db):
|
def work(db):
|
||||||
with quota.attribute(None, "backfill_recent"):
|
with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||||
recent = run_recent_backfill(db, max_channels=25)
|
recent = run_recent_backfill(db, max_channels=25)
|
||||||
with quota.attribute(None, "backfill_deep"):
|
with quota.attribute(None, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
|
||||||
deep = run_deep_backfill(db, max_channels=10)
|
deep = run_deep_backfill(db, max_channels=10)
|
||||||
return {"recent": recent, "deep": deep}
|
return {"recent": recent, "deep": deep}
|
||||||
|
|
||||||
|
|
@ -233,7 +233,7 @@ def _shorts_job() -> None:
|
||||||
|
|
||||||
def _subscriptions_job() -> None:
|
def _subscriptions_job() -> None:
|
||||||
def work(db):
|
def work(db):
|
||||||
with quota.attribute(None, "subscription_resync"):
|
with quota.attribute(None, quota.QuotaAction.SUBSCRIPTIONS_RESYNC):
|
||||||
return run_subscription_resync(db)
|
return run_subscription_resync(db)
|
||||||
|
|
||||||
_job("subscriptions", work)
|
_job("subscriptions", work)
|
||||||
|
|
@ -247,7 +247,7 @@ def _playlist_sync_job() -> None:
|
||||||
|
|
||||||
def _maintenance_job() -> None:
|
def _maintenance_job() -> None:
|
||||||
def work(db):
|
def work(db):
|
||||||
with quota.attribute(None, "maintenance"):
|
with quota.attribute(None, quota.QuotaAction.MAINTENANCE_REVALIDATE):
|
||||||
return run_maintenance(db)
|
return run_maintenance(db)
|
||||||
|
|
||||||
_job("maintenance", work)
|
_job("maintenance", work)
|
||||||
|
|
|
||||||
|
|
@ -387,7 +387,7 @@ def sync_all_playlists(db: Session) -> dict:
|
||||||
progress.report(i, len(users), "playlist_sync")
|
progress.report(i, len(users), "playlist_sync")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, "playlist_sync"):
|
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||||
total += sync_user_playlists(db, user).get("synced", 0)
|
total += sync_user_playlists(db, user).get("synced", 0)
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
|
||||||
|
|
@ -465,8 +465,8 @@ export default function App() {
|
||||||
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
|
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : page === "stats" && meQuery.data!.role === "admin" ? (
|
) : page === "stats" ? (
|
||||||
<Stats />
|
<Stats me={meQuery.data!} />
|
||||||
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
|
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
|
||||||
<Scheduler />
|
<Scheduler />
|
||||||
) : page === "playlists" ? (
|
) : page === "playlists" ? (
|
||||||
|
|
|
||||||
|
|
@ -133,13 +133,12 @@ export default function NavSidebar({
|
||||||
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
||||||
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
||||||
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
|
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
|
||||||
|
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
|
||||||
|
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
||||||
];
|
];
|
||||||
const systemItems: NavItem[] =
|
const systemItems: NavItem[] =
|
||||||
me.role === "admin"
|
me.role === "admin"
|
||||||
? [
|
? [{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }]
|
||||||
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
|
||||||
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
|
||||||
]
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const rowBase =
|
const rowBase =
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
|
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, type Invite, type Me } from "../lib/api";
|
import { api, type Invite, type Me } from "../lib/api";
|
||||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
@ -32,11 +31,10 @@ export interface PrefsController {
|
||||||
saveState: PrefsSaveState;
|
saveState: PrefsSaveState;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = "appearance" | "notifications" | "sync" | "account";
|
type TabId = "appearance" | "notifications" | "account";
|
||||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "appearance", icon: Monitor },
|
{ id: "appearance", icon: Monitor },
|
||||||
{ id: "notifications", icon: Bell },
|
{ id: "notifications", icon: Bell },
|
||||||
{ id: "sync", icon: RefreshCw },
|
|
||||||
{ id: "account", icon: User },
|
{ id: "account", icon: User },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -98,7 +96,6 @@ export default function SettingsPanel({
|
||||||
<div className="flex-1 min-w-0 p-4">
|
<div className="flex-1 min-w-0 p-4">
|
||||||
{tab === "appearance" && <Appearance prefs={prefs} />}
|
{tab === "appearance" && <Appearance prefs={prefs} />}
|
||||||
{tab === "notifications" && <Notifications prefs={prefs} />}
|
{tab === "notifications" && <Notifications prefs={prefs} />}
|
||||||
{tab === "sync" && <Sync me={me} />}
|
|
||||||
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -317,158 +314,6 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sync({ me }: { me: Me }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
|
||||||
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
|
|
||||||
const s = status.data;
|
|
||||||
const syncSubs = useMutation({
|
|
||||||
mutationFn: () => api.syncSubscriptions(),
|
|
||||||
onSuccess: (r: { subscriptions?: number }) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
|
||||||
notify({
|
|
||||||
level: "success",
|
|
||||||
message: t("settings.sync.synced", { count: r.subscriptions ?? 0 }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => notify({ level: "error", message: t("settings.sync.syncFailed") }),
|
|
||||||
});
|
|
||||||
const pauseResume = useMutation({
|
|
||||||
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
|
|
||||||
});
|
|
||||||
const deepAll = useMutation({
|
|
||||||
mutationFn: () => api.deepAll(true),
|
|
||||||
onSuccess: (r: { updated?: number }) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
|
||||||
notify({
|
|
||||||
level: "success",
|
|
||||||
message: t("settings.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => notify({ level: "error", message: t("settings.sync.fullHistoryFailed") }),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Section title={t("settings.sync.myStatus")}>
|
|
||||||
{s ? (
|
|
||||||
<div className="space-y-1.5 text-sm">
|
|
||||||
<Row label={t("settings.sync.channels")} hint={t("settings.sync.channelsHint")}>
|
|
||||||
{s.channels_total}
|
|
||||||
</Row>
|
|
||||||
<Row
|
|
||||||
label={t("settings.sync.recentSynced")}
|
|
||||||
hint={t("settings.sync.recentSyncedHint")}
|
|
||||||
>
|
|
||||||
{`${s.channels_recent_synced}/${s.channels_total}`}
|
|
||||||
</Row>
|
|
||||||
<Row
|
|
||||||
label={t("settings.sync.fullHistory")}
|
|
||||||
hint={t("settings.sync.fullHistoryHint")}
|
|
||||||
>
|
|
||||||
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
|
||||||
</Row>
|
|
||||||
{s.deep_pending_count > 0 && (
|
|
||||||
<Row
|
|
||||||
label={t("settings.sync.fullHistoryEta")}
|
|
||||||
hint={t("settings.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
|
||||||
>
|
|
||||||
{formatEta(s.deep_eta_seconds)}
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
<Row label={t("settings.sync.myVideos")} hint={t("settings.sync.myVideosHint")}>
|
|
||||||
{s.my_videos.toLocaleString()}
|
|
||||||
</Row>
|
|
||||||
<Row
|
|
||||||
label={t("settings.sync.quotaLeft")}
|
|
||||||
hint={t("settings.sync.quotaLeftHint")}
|
|
||||||
>
|
|
||||||
{s.quota_remaining_today.toLocaleString()}
|
|
||||||
</Row>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted text-sm">{t("settings.sync.loading")}</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title={t("settings.sync.apiUsage")}>
|
|
||||||
{usage.data ? (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1.5 text-sm">
|
|
||||||
<Row label={t("settings.sync.today")} hint={t("settings.sync.todayHint")}>
|
|
||||||
{usage.data.today.toLocaleString()}
|
|
||||||
</Row>
|
|
||||||
<Row label={t("settings.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
|
|
||||||
<Row label={t("settings.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
|
|
||||||
<Row label={t("settings.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
|
|
||||||
</div>
|
|
||||||
{Object.keys(usage.data.by_action).length > 0 && (
|
|
||||||
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
|
|
||||||
<div className="uppercase tracking-wide">{t("settings.sync.byAction")}</div>
|
|
||||||
{Object.entries(usage.data.by_action)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.map(([action, units]) => (
|
|
||||||
<div key={action} className="flex justify-between gap-2">
|
|
||||||
<span>{quotaActionLabel(action)}</span>
|
|
||||||
<span className="tabular-nums">{units.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted text-sm">{t("settings.sync.noUsage")}</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{!me.is_demo && (
|
|
||||||
<Section title={t("settings.sync.actions")}>
|
|
||||||
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
|
||||||
<button
|
|
||||||
onClick={() => syncSubs.mutate()}
|
|
||||||
disabled={syncSubs.isPending}
|
|
||||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
|
||||||
{t("settings.sync.syncSubscriptions")}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
|
|
||||||
<button
|
|
||||||
onClick={() => deepAll.mutate()}
|
|
||||||
disabled={deepAll.isPending}
|
|
||||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
|
||||||
>
|
|
||||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
|
||||||
{t("settings.sync.backfillEverything")}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{me.role === "admin" && s && (
|
|
||||||
<Section title={t("settings.sync.admin")}>
|
|
||||||
<Tooltip hint={t("settings.sync.adminPauseHint")}>
|
|
||||||
<button
|
|
||||||
onClick={() => pauseResume.mutate(s.paused)}
|
|
||||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
|
|
||||||
>
|
|
||||||
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
|
||||||
{s.paused
|
|
||||||
? t("settings.sync.resumeBackgroundSync")
|
|
||||||
: t("settings.sync.pauseBackgroundSync")}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccessRow({
|
function AccessRow({
|
||||||
title,
|
title,
|
||||||
granted,
|
granted,
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,236 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, type AdminQuotaRow } from "../lib/api";
|
import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||||
import { quotaActionLabel } from "../lib/format";
|
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||||
|
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
const RANGES = [7, 30, 90] as const;
|
const RANGES = [7, 30, 90] as const;
|
||||||
|
const STATS_TAB_KEY = "siftlode.statsTab";
|
||||||
|
type StatsTab = "overview" | "system";
|
||||||
|
|
||||||
export default function Stats() {
|
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
|
||||||
|
// "System" is the admin-only instance-wide quota dashboard + background-sync control. The
|
||||||
|
// per-user content used to live in Settings → Sync; it moved here so Settings stays config-only.
|
||||||
|
export default function Stats({ me }: { me: Me }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isAdmin = me.role === "admin";
|
||||||
|
const [tab, setTabState] = useState<StatsTab>(() => {
|
||||||
|
const v = localStorage.getItem(STATS_TAB_KEY);
|
||||||
|
return v === "system" && isAdmin ? "system" : "overview";
|
||||||
|
});
|
||||||
|
const setTab = (id: StatsTab) => {
|
||||||
|
setTabState(id);
|
||||||
|
localStorage.setItem(STATS_TAB_KEY, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-4xl mx-auto">
|
||||||
|
{/* Only admins have a second (System) tab; everyone else just sees the Overview. */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex items-center gap-1 mb-4">
|
||||||
|
{(["overview", "system"] as StatsTab[]).map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setTab(id)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm border transition ${
|
||||||
|
tab === id
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border text-muted hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`stats.tabs.${id}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "system" && isAdmin ? <SystemStats /> : <Overview me={me} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
||||||
|
<Tooltip hint={hint ?? ""}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
|
||||||
|
function Overview({ me }: { me: Me }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||||
|
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
|
||||||
|
const s = status.data;
|
||||||
|
const syncSubs = useMutation({
|
||||||
|
mutationFn: () => api.syncSubscriptions(),
|
||||||
|
onSuccess: (r: { subscriptions?: number }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
notify({ level: "success", message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }) });
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }),
|
||||||
|
});
|
||||||
|
const deepAll = useMutation({
|
||||||
|
mutationFn: () => api.deepAll(true),
|
||||||
|
onSuccess: (r: { updated?: number }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
notify({
|
||||||
|
level: "success",
|
||||||
|
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: t("stats.sync.fullHistoryFailed") }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section title={t("stats.sync.myStatus")}>
|
||||||
|
{s ? (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<Row label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
|
||||||
|
{s.channels_total}
|
||||||
|
</Row>
|
||||||
|
<Row label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
|
||||||
|
{`${s.channels_recent_synced}/${s.channels_total}`}
|
||||||
|
</Row>
|
||||||
|
<Row label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
|
||||||
|
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||||
|
</Row>
|
||||||
|
{s.deep_pending_count > 0 && (
|
||||||
|
<Row
|
||||||
|
label={t("stats.sync.fullHistoryEta")}
|
||||||
|
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
||||||
|
>
|
||||||
|
{formatEta(s.deep_eta_seconds)}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<Row label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
||||||
|
{s.my_videos.toLocaleString()}
|
||||||
|
</Row>
|
||||||
|
<Row label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
||||||
|
{s.quota_remaining_today.toLocaleString()}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted text-sm">{t("stats.sync.loading")}</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t("stats.sync.apiUsage")}>
|
||||||
|
{usage.data ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<Row label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
||||||
|
{usage.data.today.toLocaleString()}
|
||||||
|
</Row>
|
||||||
|
<Row label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
|
||||||
|
<Row label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
|
||||||
|
<Row label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
|
||||||
|
</div>
|
||||||
|
{Object.keys(usage.data.by_action).length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
|
||||||
|
<div className="uppercase tracking-wide">{t("stats.sync.byAction")}</div>
|
||||||
|
{Object.entries(usage.data.by_action)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([action, units]) => (
|
||||||
|
<div key={action} className="flex justify-between gap-2">
|
||||||
|
<span>{quotaActionLabel(action)}</span>
|
||||||
|
<span className="tabular-nums">{units.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted text-sm">{t("stats.sync.noUsage")}</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{!me.is_demo && (
|
||||||
|
<Section title={t("stats.sync.actions")}>
|
||||||
|
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
|
||||||
|
<button
|
||||||
|
onClick={() => syncSubs.mutate()}
|
||||||
|
disabled={syncSubs.isPending}
|
||||||
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||||
|
{t("stats.sync.syncSubscriptions")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
|
||||||
|
<button
|
||||||
|
onClick={() => deepAll.mutate()}
|
||||||
|
disabled={deepAll.isPending}
|
||||||
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
||||||
|
>
|
||||||
|
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||||
|
{t("stats.sync.backfillEverything")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
|
||||||
|
function SystemStats() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
const [days, setDays] = useState<number>(30);
|
const [days, setDays] = useState<number>(30);
|
||||||
const q = useQuery({
|
const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) });
|
||||||
queryKey: ["admin-quota", days],
|
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||||
queryFn: () => api.adminQuota(days),
|
const pauseResume = useMutation({
|
||||||
|
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
|
||||||
});
|
});
|
||||||
const data = q.data;
|
const data = q.data;
|
||||||
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
||||||
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
|
const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0);
|
||||||
|
const s = status.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-4xl mx-auto">
|
<>
|
||||||
|
{s && (
|
||||||
|
<Section title={t("stats.sync.admin")}>
|
||||||
|
<Tooltip hint={t("stats.sync.adminPauseHint")}>
|
||||||
|
<button
|
||||||
|
onClick={() => pauseResume.mutate(s.paused)}
|
||||||
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
|
||||||
|
>
|
||||||
|
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||||
|
{s.paused ? t("stats.sync.resumeBackgroundSync") : t("stats.sync.pauseBackgroundSync")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 mb-4">
|
<div className="flex items-center justify-between gap-3 mb-4">
|
||||||
<h2 className="text-lg font-semibold">{t("stats.title")}</h2>
|
<h2 className="text-lg font-semibold">{t("stats.title")}</h2>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -53,25 +265,17 @@ export default function Stats() {
|
||||||
{/* Daily totals (instance-wide, Pacific days) */}
|
{/* Daily totals (instance-wide, Pacific days) */}
|
||||||
<div className="glass-card rounded-xl p-3 mb-4">
|
<div className="glass-card rounded-xl p-3 mb-4">
|
||||||
<div className="text-xs text-muted mb-2">
|
<div className="text-xs text-muted mb-2">
|
||||||
{t("stats.dailyTotal", {
|
{t("stats.dailyTotal", { count: data.range_days, units: grandTotal.toLocaleString() })}
|
||||||
count: data.range_days,
|
|
||||||
units: grandTotal.toLocaleString(),
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
{data.daily.length === 0 ? (
|
{data.daily.length === 0 ? (
|
||||||
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-end gap-0.5 h-24">
|
<div className="flex items-end gap-0.5 h-24">
|
||||||
{data.daily.map((d) => (
|
{data.daily.map((d) => (
|
||||||
// Each day gets a full-height track so the column is always visible
|
|
||||||
// (even on near-zero days); the accent fill renders the value on top.
|
|
||||||
<div
|
<div
|
||||||
key={d.day}
|
key={d.day}
|
||||||
className="flex-1 h-full flex items-end bg-card/50 rounded-t"
|
className="flex-1 h-full flex items-end bg-card/50 rounded-t"
|
||||||
title={t("stats.dailyTooltip", {
|
title={t("stats.dailyTooltip", { day: d.day, units: d.total.toLocaleString() })}
|
||||||
day: d.day,
|
|
||||||
units: d.total.toLocaleString(),
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full bg-accent hover:opacity-80 rounded-t transition"
|
className="w-full bg-accent hover:opacity-80 rounded-t transition"
|
||||||
|
|
@ -95,7 +299,7 @@ export default function Stats() {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,16 +310,11 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||||
const isSystem = row.user_id === null;
|
const isSystem = row.user_id === null;
|
||||||
return (
|
return (
|
||||||
<div className="glass-card rounded-xl p-3">
|
<div className="glass-card rounded-xl p-3">
|
||||||
<button
|
<button onClick={() => setOpen((o) => !o)} className="w-full flex items-center gap-3 text-left">
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
className="w-full flex items-center gap-3 text-left"
|
|
||||||
>
|
|
||||||
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
||||||
{isSystem ? t("stats.system") : row.email}
|
{isSystem ? t("stats.system") : row.email}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold tabular-nums shrink-0">
|
<span className="text-sm font-semibold tabular-nums shrink-0">{row.total.toLocaleString()}</span>
|
||||||
{row.total.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{/* Proportion bar */}
|
{/* Proportion bar */}
|
||||||
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
<div className="mt-2 h-1.5 bg-border/40 rounded-full overflow-hidden">
|
||||||
|
|
|
||||||
16
frontend/src/i18n/locales/de/quotaActions.json
Normal file
16
frontend/src/i18n/locales/de/quotaActions.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"subscriptions_pull": "Abonnements einlesen",
|
||||||
|
"subscriptions_resync": "Automatische Abo-Resynchronisierung",
|
||||||
|
"playlists_pull": "Playlists einlesen",
|
||||||
|
"playlists_push": "Playlists hochladen",
|
||||||
|
"playlists_delete": "Playlist löschen",
|
||||||
|
"videos_backfill_recent": "Aktuelle Uploads laden",
|
||||||
|
"videos_backfill_full": "Vollständigen Verlauf laden",
|
||||||
|
"videos_enrich": "Video-Anreicherung",
|
||||||
|
"videos_lookup": "Video-Abfrage",
|
||||||
|
"channels_discover": "Kanal-Entdeckung",
|
||||||
|
"channels_subscribe": "Abonnieren",
|
||||||
|
"channels_unsubscribe": "Abo beenden",
|
||||||
|
"maintenance_revalidate": "Wartungs-Neuvalidierung",
|
||||||
|
"other": "Sonstiges"
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"appearance": "Darstellung",
|
"appearance": "Darstellung",
|
||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"sync": "Synchronisierung",
|
|
||||||
"account": "Konto"
|
"account": "Konto"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
|
|
@ -42,43 +41,6 @@
|
||||||
"testTitle": "Testbenachrichtigung",
|
"testTitle": "Testbenachrichtigung",
|
||||||
"testMessage": "So sieht eine Benachrichtigung aus."
|
"testMessage": "So sieht eine Benachrichtigung aus."
|
||||||
},
|
},
|
||||||
"sync": {
|
|
||||||
"myStatus": "Mein Synchronisierungsstatus",
|
|
||||||
"channels": "Kanäle",
|
|
||||||
"channelsHint": "Wie viele Kanäle du abonniert hast.",
|
|
||||||
"recentSynced": "Neueste synchronisiert",
|
|
||||||
"recentSyncedHint": "Kanäle, deren neueste Uploads (~letztes Jahr) bereits in der lokalen Datenbank sind und so in deinem Feed erscheinen.",
|
|
||||||
"fullHistory": "Vollständiger Verlauf",
|
|
||||||
"fullHistoryHint": "Kanäle, deren gesamter Katalog abgerufen wurde, von denen, für die du den vollständigen Verlauf angefordert hast (pro Kanal auf der Kanäle-Seite aktivierbar).",
|
|
||||||
"fullHistoryEta": "Vollständiger Verlauf – Restzeit",
|
|
||||||
"fullHistoryEtaHint": "Grobe Schätzung, um das Nachladen des vollständigen Verlaufs von {{count}} ausstehenden Kanal/Kanälen abzuschließen, basierend auf dem gemeinsamen Tageskontingent.",
|
|
||||||
"myVideos": "Meine Videos",
|
|
||||||
"myVideosHint": "Gesamtzahl der dir verfügbaren Videos über deine abonnierten Kanäle.",
|
|
||||||
"quotaLeft": "Heute verbleibendes Kontingent",
|
|
||||||
"quotaLeftHint": "Heute verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifik zurückgesetzt). Das Nachladen ordnet sich diesem unter.",
|
|
||||||
"loading": "Wird geladen…",
|
|
||||||
"apiUsage": "Deine API-Nutzung",
|
|
||||||
"today": "Heute",
|
|
||||||
"todayHint": "YouTube-API-Einheiten, die deine eigenen Aktionen heute verbraucht haben (wird um Mitternacht US-Pazifik zurückgesetzt). Die Hintergrund-Synchronisierung zählt hier nicht.",
|
|
||||||
"last7d": "Letzte 7 Tage",
|
|
||||||
"last30d": "Letzte 30 Tage",
|
|
||||||
"allTime": "Gesamt",
|
|
||||||
"byAction": "Nach Aktion (30 Tage)",
|
|
||||||
"noUsage": "Noch keine Nutzung.",
|
|
||||||
"actions": "Aktionen",
|
|
||||||
"syncSubscriptions": "Abos synchronisieren",
|
|
||||||
"syncSubscriptionsHint": "Importiert deine YouTube-Abos erneut und holt neu gefolgte Kanäle hinzu.",
|
|
||||||
"backfillEverything": "Alles nachladen",
|
|
||||||
"backfillEverythingHint": "Fordert das Nachladen des vollständigen Katalogs für jeden Kanal an, den du abonniert hast. Ältere Videos und die vollständige Suche werden gefüllt, soweit das gemeinsame Tageskontingent es zulässt.",
|
|
||||||
"synced": "{{count}} Abos synchronisiert",
|
|
||||||
"syncFailed": "Synchronisierung fehlgeschlagen",
|
|
||||||
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
|
|
||||||
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
|
|
||||||
"admin": "Admin",
|
|
||||||
"adminPauseHint": "Pausiert oder setzt die Hintergrund-Synchronisierung für die gesamte App fort (alle Nutzer).",
|
|
||||||
"resumeBackgroundSync": "Hintergrund-Synchronisierung fortsetzen",
|
|
||||||
"pauseBackgroundSync": "Hintergrund-Synchronisierung pausieren"
|
|
||||||
},
|
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Konto",
|
"title": "Konto",
|
||||||
"youtubeAccess": "YouTube-Zugriff",
|
"youtubeAccess": "YouTube-Zugriff",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
{
|
{
|
||||||
|
"tabs": {
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
"title": "API-Kontingentnutzung",
|
"title": "API-Kontingentnutzung",
|
||||||
"rangeDays": "{{count}} T",
|
"rangeDays": "{{count}} T",
|
||||||
"introBefore": "YouTube-Data-API-Einheiten, zugeordnet danach, wer die Nutzung ausgelöst hat. ",
|
"introBefore": "YouTube-Data-API-Einheiten, zugeordnet danach, wer die Nutzung ausgelöst hat. ",
|
||||||
|
|
@ -9,5 +13,42 @@
|
||||||
"dailyTotal": "Tagessumme ({{count}} T · {{units}} Einheiten)",
|
"dailyTotal": "Tagessumme ({{count}} T · {{units}} Einheiten)",
|
||||||
"noUsageInRange": "Keine Nutzung in diesem Zeitraum.",
|
"noUsageInRange": "Keine Nutzung in diesem Zeitraum.",
|
||||||
"dailyTooltip": "{{day}}: {{units}} Einheiten",
|
"dailyTooltip": "{{day}}: {{units}} Einheiten",
|
||||||
"system": "System (Hintergrund)"
|
"system": "System (Hintergrund)",
|
||||||
|
"sync": {
|
||||||
|
"myStatus": "Mein Synchronisierungsstatus",
|
||||||
|
"channels": "Kanäle",
|
||||||
|
"channelsHint": "Wie viele Kanäle du abonniert hast.",
|
||||||
|
"recentSynced": "Neueste synchronisiert",
|
||||||
|
"recentSyncedHint": "Kanäle, deren neueste Uploads (~letztes Jahr) bereits in der lokalen Datenbank sind und so in deinem Feed erscheinen.",
|
||||||
|
"fullHistory": "Vollständiger Verlauf",
|
||||||
|
"fullHistoryHint": "Kanäle, deren gesamter Katalog abgerufen wurde, von denen, für die du den vollständigen Verlauf angefordert hast (pro Kanal auf der Kanäle-Seite aktivierbar).",
|
||||||
|
"fullHistoryEta": "Vollständiger Verlauf – Restzeit",
|
||||||
|
"fullHistoryEtaHint": "Grobe Schätzung, um das Nachladen des vollständigen Verlaufs von {{count}} ausstehenden Kanal/Kanälen abzuschließen, basierend auf dem gemeinsamen Tageskontingent.",
|
||||||
|
"myVideos": "Meine Videos",
|
||||||
|
"myVideosHint": "Gesamtzahl der dir verfügbaren Videos über deine abonnierten Kanäle.",
|
||||||
|
"quotaLeft": "Heute verbleibendes Kontingent",
|
||||||
|
"quotaLeftHint": "Heute verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifik zurückgesetzt). Das Nachladen ordnet sich diesem unter.",
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"apiUsage": "Deine API-Nutzung",
|
||||||
|
"today": "Heute",
|
||||||
|
"todayHint": "YouTube-API-Einheiten, die deine eigenen Aktionen heute verbraucht haben (wird um Mitternacht US-Pazifik zurückgesetzt). Die Hintergrund-Synchronisierung zählt hier nicht.",
|
||||||
|
"last7d": "Letzte 7 Tage",
|
||||||
|
"last30d": "Letzte 30 Tage",
|
||||||
|
"allTime": "Gesamt",
|
||||||
|
"byAction": "Nach Aktion (30 Tage)",
|
||||||
|
"noUsage": "Noch keine Nutzung.",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"syncSubscriptions": "Abos synchronisieren",
|
||||||
|
"syncSubscriptionsHint": "Importiert deine YouTube-Abos erneut und holt neu gefolgte Kanäle hinzu.",
|
||||||
|
"backfillEverything": "Alles nachladen",
|
||||||
|
"backfillEverythingHint": "Fordert das Nachladen des vollständigen Katalogs für jeden Kanal an, den du abonniert hast. Ältere Videos und die vollständige Suche werden gefüllt, soweit das gemeinsame Tageskontingent es zulässt.",
|
||||||
|
"synced": "{{count}} Abos synchronisiert",
|
||||||
|
"syncFailed": "Synchronisierung fehlgeschlagen",
|
||||||
|
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
|
||||||
|
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
|
||||||
|
"admin": "Admin",
|
||||||
|
"adminPauseHint": "Pausiert oder setzt die Hintergrund-Synchronisierung für die gesamte App fort (alle Nutzer).",
|
||||||
|
"resumeBackgroundSync": "Hintergrund-Synchronisierung fortsetzen",
|
||||||
|
"pauseBackgroundSync": "Hintergrund-Synchronisierung pausieren"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
frontend/src/i18n/locales/en/quotaActions.json
Normal file
16
frontend/src/i18n/locales/en/quotaActions.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"subscriptions_pull": "Read subscriptions",
|
||||||
|
"subscriptions_resync": "Auto subscription resync",
|
||||||
|
"playlists_pull": "Read playlists",
|
||||||
|
"playlists_push": "Push playlists",
|
||||||
|
"playlists_delete": "Delete playlist",
|
||||||
|
"videos_backfill_recent": "Recent backfill",
|
||||||
|
"videos_backfill_full": "Full-history backfill",
|
||||||
|
"videos_enrich": "Video enrichment",
|
||||||
|
"videos_lookup": "Video lookup",
|
||||||
|
"channels_discover": "Channel discovery",
|
||||||
|
"channels_subscribe": "Subscribe",
|
||||||
|
"channels_unsubscribe": "Unsubscribe",
|
||||||
|
"maintenance_revalidate": "Maintenance re-validation",
|
||||||
|
"other": "Other"
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"sync": "Sync",
|
|
||||||
"account": "Account"
|
"account": "Account"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
|
|
@ -42,43 +41,6 @@
|
||||||
"testTitle": "Test notification",
|
"testTitle": "Test notification",
|
||||||
"testMessage": "This is what a notification looks like."
|
"testMessage": "This is what a notification looks like."
|
||||||
},
|
},
|
||||||
"sync": {
|
|
||||||
"myStatus": "My sync status",
|
|
||||||
"channels": "Channels",
|
|
||||||
"channelsHint": "How many channels you're subscribed to.",
|
|
||||||
"recentSynced": "Recent synced",
|
|
||||||
"recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your feed.",
|
|
||||||
"fullHistory": "Full history",
|
|
||||||
"fullHistoryHint": "Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page).",
|
|
||||||
"fullHistoryEta": "Full history ETA",
|
|
||||||
"fullHistoryEtaHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.",
|
|
||||||
"myVideos": "My videos",
|
|
||||||
"myVideosHint": "Total videos available to you across your subscribed channels.",
|
|
||||||
"quotaLeft": "Quota left today",
|
|
||||||
"quotaLeftHint": "Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it.",
|
|
||||||
"loading": "Loading…",
|
|
||||||
"apiUsage": "Your API usage",
|
|
||||||
"today": "Today",
|
|
||||||
"todayHint": "YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.",
|
|
||||||
"last7d": "Last 7 days",
|
|
||||||
"last30d": "Last 30 days",
|
|
||||||
"allTime": "All time",
|
|
||||||
"byAction": "By action (30d)",
|
|
||||||
"noUsage": "No usage yet.",
|
|
||||||
"actions": "Actions",
|
|
||||||
"syncSubscriptions": "Sync subscriptions",
|
|
||||||
"syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.",
|
|
||||||
"backfillEverything": "Backfill everything",
|
|
||||||
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.",
|
|
||||||
"synced": "Synced {{count}} subscriptions",
|
|
||||||
"syncFailed": "Sync failed",
|
|
||||||
"fullHistoryRequested": "Full history requested for {{count}} channels",
|
|
||||||
"fullHistoryFailed": "Couldn't request full history",
|
|
||||||
"admin": "Admin",
|
|
||||||
"adminPauseHint": "Pause or resume the background sync for the whole app (all users).",
|
|
||||||
"resumeBackgroundSync": "Resume background sync",
|
|
||||||
"pauseBackgroundSync": "Pause background sync"
|
|
||||||
},
|
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Account",
|
"title": "Account",
|
||||||
"youtubeAccess": "YouTube access",
|
"youtubeAccess": "YouTube access",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
{
|
{
|
||||||
|
"tabs": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
"title": "API quota usage",
|
"title": "API quota usage",
|
||||||
"rangeDays": "{{count}}d",
|
"rangeDays": "{{count}}d",
|
||||||
"introBefore": "YouTube Data API units attributed by who triggered the spend. ",
|
"introBefore": "YouTube Data API units attributed by who triggered the spend. ",
|
||||||
|
|
@ -9,5 +13,42 @@
|
||||||
"dailyTotal": "Daily total ({{count}}d · {{units}} units)",
|
"dailyTotal": "Daily total ({{count}}d · {{units}} units)",
|
||||||
"noUsageInRange": "No usage in this range.",
|
"noUsageInRange": "No usage in this range.",
|
||||||
"dailyTooltip": "{{day}}: {{units}} units",
|
"dailyTooltip": "{{day}}: {{units}} units",
|
||||||
"system": "System (background)"
|
"system": "System (background)",
|
||||||
|
"sync": {
|
||||||
|
"myStatus": "My sync status",
|
||||||
|
"channels": "Channels",
|
||||||
|
"channelsHint": "How many channels you're subscribed to.",
|
||||||
|
"recentSynced": "Recent synced",
|
||||||
|
"recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your feed.",
|
||||||
|
"fullHistory": "Full history",
|
||||||
|
"fullHistoryHint": "Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page).",
|
||||||
|
"fullHistoryEta": "Full history ETA",
|
||||||
|
"fullHistoryEtaHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.",
|
||||||
|
"myVideos": "My videos",
|
||||||
|
"myVideosHint": "Total videos available to you across your subscribed channels.",
|
||||||
|
"quotaLeft": "Quota left today",
|
||||||
|
"quotaLeftHint": "Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it.",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"apiUsage": "Your API usage",
|
||||||
|
"today": "Today",
|
||||||
|
"todayHint": "YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.",
|
||||||
|
"last7d": "Last 7 days",
|
||||||
|
"last30d": "Last 30 days",
|
||||||
|
"allTime": "All time",
|
||||||
|
"byAction": "By action (30d)",
|
||||||
|
"noUsage": "No usage yet.",
|
||||||
|
"actions": "Actions",
|
||||||
|
"syncSubscriptions": "Sync subscriptions",
|
||||||
|
"syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.",
|
||||||
|
"backfillEverything": "Backfill everything",
|
||||||
|
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.",
|
||||||
|
"synced": "Synced {{count}} subscriptions",
|
||||||
|
"syncFailed": "Sync failed",
|
||||||
|
"fullHistoryRequested": "Full history requested for {{count}} channels",
|
||||||
|
"fullHistoryFailed": "Couldn't request full history",
|
||||||
|
"admin": "Admin",
|
||||||
|
"adminPauseHint": "Pause or resume the background sync for the whole app (all users).",
|
||||||
|
"resumeBackgroundSync": "Resume background sync",
|
||||||
|
"pauseBackgroundSync": "Pause background sync"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
frontend/src/i18n/locales/hu/quotaActions.json
Normal file
16
frontend/src/i18n/locales/hu/quotaActions.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"subscriptions_pull": "Feliratkozások beolvasása",
|
||||||
|
"subscriptions_resync": "Automatikus feliratkozás-újraszinkron",
|
||||||
|
"playlists_pull": "Lejátszási listák beolvasása",
|
||||||
|
"playlists_push": "Lejátszási listák feltöltése",
|
||||||
|
"playlists_delete": "Lejátszási lista törlése",
|
||||||
|
"videos_backfill_recent": "Friss feltöltések betöltése",
|
||||||
|
"videos_backfill_full": "Teljes előzmény betöltése",
|
||||||
|
"videos_enrich": "Videó-gazdagítás",
|
||||||
|
"videos_lookup": "Videó-lekérdezés",
|
||||||
|
"channels_discover": "Csatorna-felfedezés",
|
||||||
|
"channels_subscribe": "Feliratkozás",
|
||||||
|
"channels_unsubscribe": "Leiratkozás",
|
||||||
|
"maintenance_revalidate": "Karbantartó újraellenőrzés",
|
||||||
|
"other": "Egyéb"
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"appearance": "Megjelenés",
|
"appearance": "Megjelenés",
|
||||||
"notifications": "Értesítések",
|
"notifications": "Értesítések",
|
||||||
"sync": "Szinkronizálás",
|
|
||||||
"account": "Fiók"
|
"account": "Fiók"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
|
|
@ -42,43 +41,6 @@
|
||||||
"testTitle": "Tesztértesítés",
|
"testTitle": "Tesztértesítés",
|
||||||
"testMessage": "Így néz ki egy értesítés."
|
"testMessage": "Így néz ki egy értesítés."
|
||||||
},
|
},
|
||||||
"sync": {
|
|
||||||
"myStatus": "Saját szinkronizálási állapot",
|
|
||||||
"channels": "Csatornák",
|
|
||||||
"channelsHint": "Hány csatornára vagy feliratkozva.",
|
|
||||||
"recentSynced": "Friss szinkronizálva",
|
|
||||||
"recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a hírfolyamodban.",
|
|
||||||
"fullHistory": "Teljes előzmény",
|
|
||||||
"fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).",
|
|
||||||
"fullHistoryEta": "Teljes előzmény becsült ideje",
|
|
||||||
"fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltéséhez, a megosztott napi kvóta alapján.",
|
|
||||||
"myVideos": "Saját videók",
|
|
||||||
"myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.",
|
|
||||||
"quotaLeft": "Ma maradt kvóta",
|
|
||||||
"quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A letöltés ennek alárendelődik.",
|
|
||||||
"loading": "Betöltés…",
|
|
||||||
"apiUsage": "Saját API-használat",
|
|
||||||
"today": "Ma",
|
|
||||||
"todayHint": "A saját műveleteid által ma használt YouTube API-egységek (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A háttér-szinkronizálás itt nem számít bele.",
|
|
||||||
"last7d": "Utolsó 7 nap",
|
|
||||||
"last30d": "Utolsó 30 nap",
|
|
||||||
"allTime": "Mindenkori",
|
|
||||||
"byAction": "Művelet szerint (30 nap)",
|
|
||||||
"noUsage": "Még nincs használat.",
|
|
||||||
"actions": "Műveletek",
|
|
||||||
"syncSubscriptions": "Feliratkozások szinkronizálása",
|
|
||||||
"syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.",
|
|
||||||
"backfillEverything": "Minden letöltése",
|
|
||||||
"backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.",
|
|
||||||
"synced": "{{count}} feliratkozás szinkronizálva",
|
|
||||||
"syncFailed": "A szinkronizálás sikertelen",
|
|
||||||
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
|
||||||
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
|
|
||||||
"admin": "Adminisztrátor",
|
|
||||||
"adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).",
|
|
||||||
"resumeBackgroundSync": "Háttér-szinkronizálás folytatása",
|
|
||||||
"pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése"
|
|
||||||
},
|
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Fiók",
|
"title": "Fiók",
|
||||||
"youtubeAccess": "YouTube-hozzáférés",
|
"youtubeAccess": "YouTube-hozzáférés",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
{
|
{
|
||||||
|
"tabs": {
|
||||||
|
"overview": "Áttekintés",
|
||||||
|
"system": "Rendszer"
|
||||||
|
},
|
||||||
"title": "API-kvóta használat",
|
"title": "API-kvóta használat",
|
||||||
"rangeDays": "{{count}} nap",
|
"rangeDays": "{{count}} nap",
|
||||||
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
|
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
|
||||||
|
|
@ -9,5 +13,42 @@
|
||||||
"dailyTotal": "Napi összesen ({{count}} nap · {{units}} egység)",
|
"dailyTotal": "Napi összesen ({{count}} nap · {{units}} egység)",
|
||||||
"noUsageInRange": "Nincs használat ebben az időszakban.",
|
"noUsageInRange": "Nincs használat ebben az időszakban.",
|
||||||
"dailyTooltip": "{{day}}: {{units}} egység",
|
"dailyTooltip": "{{day}}: {{units}} egység",
|
||||||
"system": "Rendszer (háttér)"
|
"system": "Rendszer (háttér)",
|
||||||
|
"sync": {
|
||||||
|
"myStatus": "Saját szinkronizálási állapot",
|
||||||
|
"channels": "Csatornák",
|
||||||
|
"channelsHint": "Hány csatornára vagy feliratkozva.",
|
||||||
|
"recentSynced": "Friss szinkronizálva",
|
||||||
|
"recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a hírfolyamodban.",
|
||||||
|
"fullHistory": "Teljes előzmény",
|
||||||
|
"fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).",
|
||||||
|
"fullHistoryEta": "Teljes előzmény becsült ideje",
|
||||||
|
"fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltéséhez, a megosztott napi kvóta alapján.",
|
||||||
|
"myVideos": "Saját videók",
|
||||||
|
"myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.",
|
||||||
|
"quotaLeft": "Ma maradt kvóta",
|
||||||
|
"quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A letöltés ennek alárendelődik.",
|
||||||
|
"loading": "Betöltés…",
|
||||||
|
"apiUsage": "Saját API-használat",
|
||||||
|
"today": "Ma",
|
||||||
|
"todayHint": "A saját műveleteid által ma használt YouTube API-egységek (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A háttér-szinkronizálás itt nem számít bele.",
|
||||||
|
"last7d": "Utolsó 7 nap",
|
||||||
|
"last30d": "Utolsó 30 nap",
|
||||||
|
"allTime": "Mindenkori",
|
||||||
|
"byAction": "Művelet szerint (30 nap)",
|
||||||
|
"noUsage": "Még nincs használat.",
|
||||||
|
"actions": "Műveletek",
|
||||||
|
"syncSubscriptions": "Feliratkozások szinkronizálása",
|
||||||
|
"syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.",
|
||||||
|
"backfillEverything": "Minden letöltése",
|
||||||
|
"backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.",
|
||||||
|
"synced": "{{count}} feliratkozás szinkronizálva",
|
||||||
|
"syncFailed": "A szinkronizálás sikertelen",
|
||||||
|
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
||||||
|
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
|
||||||
|
"admin": "Adminisztrátor",
|
||||||
|
"adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).",
|
||||||
|
"resumeBackgroundSync": "Háttér-szinkronizálás folytatása",
|
||||||
|
"pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,18 +44,11 @@ export function formatDuration(sec: number | null): string {
|
||||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUOTA_ACTION_LABELS: Record<string, string> = {
|
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
|
||||||
sync_subscriptions: "Sync subscriptions",
|
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
|
||||||
backfill_recent: "Recent backfill",
|
// key for any unmapped/legacy value.
|
||||||
backfill_deep: "Full-history backfill",
|
|
||||||
enrich: "Enrichment",
|
|
||||||
unsubscribe: "Unsubscribe",
|
|
||||||
subscription_resync: "Auto subscription resync",
|
|
||||||
api: "Other",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function quotaActionLabel(action: string): string {
|
export function quotaActionLabel(action: string): string {
|
||||||
return QUOTA_ACTION_LABELS[action] ?? action;
|
return i18n.t(`quotaActions.${action}`, { defaultValue: action });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */
|
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue