Build 2602261523: live updates, invite cleanup and nuclear resync
This commit is contained in:
@@ -1 +1 @@
|
|||||||
2602261442
|
2602261523
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status, Request
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
@@ -38,7 +38,7 @@ def _extract_client_ip(request: Request) -> str:
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
|
def _load_current_user_from_token(token: str, request: Optional[Request] = None) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
payload = safe_decode_token(token)
|
payload = safe_decode_token(token)
|
||||||
except TokenError as exc:
|
except TokenError as exc:
|
||||||
@@ -73,7 +73,32 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
|
||||||
|
return _load_current_user_from_token(token, request)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
|
||||||
|
"""EventSource cannot send Authorization headers, so allow a query token here only."""
|
||||||
|
token = None
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
if not token:
|
||||||
|
token = request.query_params.get("access_token")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||||
|
return _load_current_user_from_token(token, None)
|
||||||
|
|
||||||
|
|
||||||
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||||
if user.get("role") != "admin":
|
if user.get("role") != "admin":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_event_stream(
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if user.get("role") != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
return user
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
BUILD_NUMBER = "2602261442"
|
BUILD_NUMBER = "2602261523"
|
||||||
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
||||||
|
|||||||
@@ -1991,6 +1991,29 @@ def clear_history() -> Dict[str, int]:
|
|||||||
return {"actions": actions, "snapshots": snapshots}
|
return {"actions": actions, "snapshots": snapshots}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_user_objects_nuclear() -> Dict[str, int]:
|
||||||
|
with _connect() as conn:
|
||||||
|
# Preserve admin accounts, but remove invite/profile references so profile rows can be deleted safely.
|
||||||
|
admin_reset = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET profile_id = NULL,
|
||||||
|
invited_by_code = NULL,
|
||||||
|
invited_at = NULL
|
||||||
|
WHERE role = 'admin'
|
||||||
|
"""
|
||||||
|
).rowcount
|
||||||
|
users = conn.execute("DELETE FROM users WHERE role != 'admin'").rowcount
|
||||||
|
invites = conn.execute("DELETE FROM signup_invites").rowcount
|
||||||
|
profiles = conn.execute("DELETE FROM user_profiles").rowcount
|
||||||
|
return {
|
||||||
|
"users": users,
|
||||||
|
"invites": invites,
|
||||||
|
"profiles": profiles,
|
||||||
|
"adminsReset": admin_reset,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def cleanup_history(days: int) -> Dict[str, int]:
|
def cleanup_history(days: int) -> Dict[str, int]:
|
||||||
if days <= 0:
|
if days <= 0:
|
||||||
return {"actions": 0, "snapshots": 0}
|
return {"actions": 0, "snapshots": 0}
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ from .routers.requests import (
|
|||||||
run_daily_db_cleanup,
|
run_daily_db_cleanup,
|
||||||
)
|
)
|
||||||
from .routers.auth import router as auth_router
|
from .routers.auth import router as auth_router
|
||||||
from .routers.admin import router as admin_router
|
from .routers.admin import router as admin_router, events_router as admin_events_router
|
||||||
from .routers.images import router as images_router
|
from .routers.images import router as images_router
|
||||||
from .routers.branding import router as branding_router
|
from .routers.branding import router as branding_router
|
||||||
from .routers.status import router as status_router
|
from .routers.status import router as status_router
|
||||||
from .routers.feedback import router as feedback_router
|
from .routers.feedback import router as feedback_router
|
||||||
from .routers.site import router as site_router
|
from .routers.site import router as site_router
|
||||||
|
from .routers.events import router as events_router
|
||||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .runtime import get_runtime_settings
|
from .runtime import get_runtime_settings
|
||||||
@@ -53,8 +54,10 @@ async def startup() -> None:
|
|||||||
app.include_router(requests_router)
|
app.include_router(requests_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
app.include_router(admin_events_router)
|
||||||
app.include_router(images_router)
|
app.include_router(images_router)
|
||||||
app.include_router(branding_router)
|
app.include_router(branding_router)
|
||||||
app.include_router(status_router)
|
app.include_router(status_router)
|
||||||
app.include_router(feedback_router)
|
app.include_router(feedback_router)
|
||||||
app.include_router(site_router)
|
app.include_router(site_router)
|
||||||
|
app.include_router(events_router)
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import asyncio
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import string
|
import string
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from ..auth import require_admin, get_current_user
|
from ..auth import require_admin, get_current_user, require_admin_event_stream
|
||||||
from ..config import settings as env_settings
|
from ..config import settings as env_settings
|
||||||
from ..db import (
|
from ..db import (
|
||||||
delete_setting,
|
delete_setting,
|
||||||
@@ -37,6 +40,7 @@ from ..db import (
|
|||||||
vacuum_db,
|
vacuum_db,
|
||||||
clear_requests_cache,
|
clear_requests_cache,
|
||||||
clear_history,
|
clear_history,
|
||||||
|
clear_user_objects_nuclear,
|
||||||
cleanup_history,
|
cleanup_history,
|
||||||
update_request_cache_title,
|
update_request_cache_title,
|
||||||
repair_request_cache_titles,
|
repair_request_cache_titles,
|
||||||
@@ -65,6 +69,7 @@ from ..services.user_cache import (
|
|||||||
match_jellyseerr_user_id,
|
match_jellyseerr_user_id,
|
||||||
save_jellyfin_users_cache,
|
save_jellyfin_users_cache,
|
||||||
save_jellyseerr_users_cache,
|
save_jellyseerr_users_cache,
|
||||||
|
clear_user_import_caches,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
from ..logging_config import configure_logging
|
from ..logging_config import configure_logging
|
||||||
@@ -72,6 +77,7 @@ from ..routers import requests as requests_router
|
|||||||
from ..routers.branding import save_branding_image
|
from ..routers.branding import save_branding_image
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||||
|
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SENSITIVE_KEYS = {
|
SENSITIVE_KEYS = {
|
||||||
@@ -130,6 +136,36 @@ SETTING_KEYS: List[str] = [
|
|||||||
"site_banner_tone",
|
"site_banner_tone",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_live_state_snapshot() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "admin_live_state",
|
||||||
|
"requestsSync": requests_router.get_requests_sync_state(),
|
||||||
|
"artworkPrefetch": requests_router.get_artwork_prefetch_state(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_encode(data: Dict[str, Any]) -> str:
|
||||||
|
payload = json.dumps(data, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||||
|
return f"data: {payload}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_tail_lines(lines: int) -> List[str]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
log_file = runtime.log_file
|
||||||
|
if not log_file:
|
||||||
|
raise HTTPException(status_code=400, detail="Log file not configured")
|
||||||
|
if not os.path.isabs(log_file):
|
||||||
|
log_file = os.path.join(os.getcwd(), log_file)
|
||||||
|
if not os.path.exists(log_file):
|
||||||
|
raise HTTPException(status_code=404, detail="Log file not found")
|
||||||
|
lines = max(1, min(lines, 1000))
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
with open(log_file, "r", encoding="utf-8", errors="replace") as handle:
|
||||||
|
tail = deque(handle, maxlen=lines)
|
||||||
|
return list(tail)
|
||||||
|
|
||||||
def _normalize_username(value: str) -> str:
|
def _normalize_username(value: str) -> str:
|
||||||
normalized = value.strip().lower()
|
normalized = value.strip().lower()
|
||||||
if "@" in normalized:
|
if "@" in normalized:
|
||||||
@@ -608,22 +644,65 @@ async def requests_sync_status() -> Dict[str, Any]:
|
|||||||
return {"status": "ok", "sync": requests_router.get_requests_sync_state()}
|
return {"status": "ok", "sync": requests_router.get_requests_sync_state()}
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.get("/stream")
|
||||||
|
async def admin_events_stream(
|
||||||
|
request: Request,
|
||||||
|
include_logs: bool = False,
|
||||||
|
log_lines: int = 200,
|
||||||
|
_: Dict[str, Any] = Depends(require_admin_event_stream),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
async def event_generator():
|
||||||
|
# Advise client reconnect timing once per stream.
|
||||||
|
yield "retry: 2000\n\n"
|
||||||
|
last_snapshot: Optional[str] = None
|
||||||
|
heartbeat_counter = 0
|
||||||
|
log_refresh_counter = 5 if include_logs else 0
|
||||||
|
latest_logs_payload: Optional[Dict[str, Any]] = None
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
snapshot_payload = _admin_live_state_snapshot()
|
||||||
|
if include_logs:
|
||||||
|
log_refresh_counter += 1
|
||||||
|
if log_refresh_counter >= 5:
|
||||||
|
log_refresh_counter = 0
|
||||||
|
try:
|
||||||
|
latest_logs_payload = {
|
||||||
|
"lines": _read_log_tail_lines(log_lines),
|
||||||
|
"count": max(1, min(int(log_lines or 200), 1000)),
|
||||||
|
}
|
||||||
|
except HTTPException as exc:
|
||||||
|
latest_logs_payload = {
|
||||||
|
"error": str(exc.detail) if exc.detail else "Could not read logs",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
latest_logs_payload = {"error": str(exc)}
|
||||||
|
snapshot_payload["logs"] = latest_logs_payload
|
||||||
|
|
||||||
|
snapshot = _sse_encode(snapshot_payload)
|
||||||
|
if snapshot != last_snapshot:
|
||||||
|
last_snapshot = snapshot
|
||||||
|
yield snapshot
|
||||||
|
heartbeat_counter = 0
|
||||||
|
else:
|
||||||
|
heartbeat_counter += 1
|
||||||
|
# Keep the stream alive through proxies even when state is unchanged.
|
||||||
|
if heartbeat_counter >= 15:
|
||||||
|
yield ": ping\n\n"
|
||||||
|
heartbeat_counter = 0
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
}
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs")
|
@router.get("/logs")
|
||||||
async def read_logs(lines: int = 200) -> Dict[str, Any]:
|
async def read_logs(lines: int = 200) -> Dict[str, Any]:
|
||||||
runtime = get_runtime_settings()
|
return {"lines": _read_log_tail_lines(lines)}
|
||||||
log_file = runtime.log_file
|
|
||||||
if not log_file:
|
|
||||||
raise HTTPException(status_code=400, detail="Log file not configured")
|
|
||||||
if not os.path.isabs(log_file):
|
|
||||||
log_file = os.path.join(os.getcwd(), log_file)
|
|
||||||
if not os.path.exists(log_file):
|
|
||||||
raise HTTPException(status_code=404, detail="Log file not found")
|
|
||||||
lines = max(1, min(lines, 1000))
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
with open(log_file, "r", encoding="utf-8", errors="replace") as handle:
|
|
||||||
tail = deque(handle, maxlen=lines)
|
|
||||||
return {"lines": list(tail)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/cache")
|
@router.get("/requests/cache")
|
||||||
@@ -689,9 +768,23 @@ async def repair_database() -> Dict[str, Any]:
|
|||||||
async def flush_database() -> Dict[str, Any]:
|
async def flush_database() -> Dict[str, Any]:
|
||||||
cleared = clear_requests_cache()
|
cleared = clear_requests_cache()
|
||||||
history = clear_history()
|
history = clear_history()
|
||||||
|
user_objects = clear_user_objects_nuclear()
|
||||||
|
user_caches = clear_user_import_caches()
|
||||||
delete_setting("requests_sync_last_at")
|
delete_setting("requests_sync_last_at")
|
||||||
logger.warning("Database flush executed: requests_cache=%s history=%s", cleared, history)
|
logger.warning(
|
||||||
return {"status": "ok", "requestsCleared": cleared, "historyCleared": history}
|
"Database flush executed: requests_cache=%s history=%s user_objects=%s user_caches=%s",
|
||||||
|
cleared,
|
||||||
|
history,
|
||||||
|
user_objects,
|
||||||
|
user_caches,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"requestsCleared": cleared,
|
||||||
|
"historyCleared": history,
|
||||||
|
"userObjectsCleared": user_objects,
|
||||||
|
"userCachesCleared": user_caches,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/cleanup")
|
@router.post("/maintenance/cleanup")
|
||||||
|
|||||||
112
backend/app/routers/events.py
Normal file
112
backend/app/routers/events.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from ..auth import get_current_user_event_stream
|
||||||
|
from . import requests as requests_router
|
||||||
|
from .status import services_status
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_json(payload: Dict[str, Any]) -> str:
|
||||||
|
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def events_stream(
|
||||||
|
request: Request,
|
||||||
|
recent_days: int = 90,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user_event_stream),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
recent_days = max(0, min(int(recent_days or 90), 3650))
|
||||||
|
recent_take = 50 if user.get("role") == "admin" else 6
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
yield "retry: 2000\n\n"
|
||||||
|
last_recent_signature: Optional[str] = None
|
||||||
|
last_services_signature: Optional[str] = None
|
||||||
|
next_recent_at = 0.0
|
||||||
|
next_services_at = 0.0
|
||||||
|
heartbeat_counter = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
sent_any = False
|
||||||
|
|
||||||
|
if now >= next_recent_at:
|
||||||
|
next_recent_at = now + 15.0
|
||||||
|
try:
|
||||||
|
recent_payload = await requests_router.recent_requests(
|
||||||
|
take=recent_take,
|
||||||
|
skip=0,
|
||||||
|
days=recent_days,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
|
||||||
|
payload = {
|
||||||
|
"type": "home_recent",
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"days": recent_days,
|
||||||
|
"results": results if isinstance(results, list) else [],
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
payload = {
|
||||||
|
"type": "home_recent",
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"days": recent_days,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||||
|
if signature != last_recent_signature:
|
||||||
|
last_recent_signature = signature
|
||||||
|
yield _sse_json(payload)
|
||||||
|
sent_any = True
|
||||||
|
|
||||||
|
if now >= next_services_at:
|
||||||
|
next_services_at = now + 30.0
|
||||||
|
try:
|
||||||
|
status_payload = await services_status()
|
||||||
|
payload = {
|
||||||
|
"type": "home_services",
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"status": status_payload,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
payload = {
|
||||||
|
"type": "home_services",
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
||||||
|
if signature != last_services_signature:
|
||||||
|
last_services_signature = signature
|
||||||
|
yield _sse_json(payload)
|
||||||
|
sent_any = True
|
||||||
|
|
||||||
|
if sent_any:
|
||||||
|
heartbeat_counter = 0
|
||||||
|
else:
|
||||||
|
heartbeat_counter += 1
|
||||||
|
if heartbeat_counter >= 15:
|
||||||
|
yield ": ping\n\n"
|
||||||
|
heartbeat_counter = 0
|
||||||
|
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
}
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from ..db import get_setting, set_setting
|
from ..db import get_setting, set_setting, delete_setting
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -142,3 +142,17 @@ def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any
|
|||||||
|
|
||||||
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
|
||||||
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
|
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_user_import_caches() -> Dict[str, int]:
|
||||||
|
cleared = 0
|
||||||
|
for key in (
|
||||||
|
JELLYSEERR_CACHE_KEY,
|
||||||
|
JELLYSEERR_CACHE_AT_KEY,
|
||||||
|
JELLYFIN_CACHE_KEY,
|
||||||
|
JELLYFIN_CACHE_AT_KEY,
|
||||||
|
):
|
||||||
|
delete_setting(key)
|
||||||
|
cleared += 1
|
||||||
|
logger.debug("Cleared user import cache keys: %s", cleared)
|
||||||
|
return {"settingsKeysCleared": cleared}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
import AdminShell from '../ui/AdminShell'
|
import AdminShell from '../ui/AdminShell'
|
||||||
@@ -141,6 +141,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
|
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
|
||||||
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
||||||
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
||||||
|
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||||||
|
const requestsSyncRef = useRef<any | null>(null)
|
||||||
|
const artworkPrefetchRef = useRef<any | null>(null)
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
@@ -338,6 +341,14 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestsSyncRef.current = requestsSync
|
||||||
|
}, [requestsSync])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
artworkPrefetchRef.current = artworkPrefetch
|
||||||
|
}, [artworkPrefetch])
|
||||||
|
|
||||||
const settingDescriptions: Record<string, string> = {
|
const settingDescriptions: Record<string, string> = {
|
||||||
jellyseerr_base_url:
|
jellyseerr_base_url:
|
||||||
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
|
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
|
||||||
@@ -576,7 +587,100 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artworkPrefetch || artworkPrefetch.status !== 'running') {
|
const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
|
||||||
|
if (!shouldSubscribe) {
|
||||||
|
setLiveStreamConnected(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
setLiveStreamConnected(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('access_token', token)
|
||||||
|
if (showLogs) {
|
||||||
|
params.set('include_logs', '1')
|
||||||
|
params.set('log_lines', String(logsCount))
|
||||||
|
}
|
||||||
|
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
|
||||||
|
let closed = false
|
||||||
|
const source = new EventSource(streamUrl)
|
||||||
|
|
||||||
|
source.onopen = () => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(true)
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data)
|
||||||
|
if (!payload || payload.type !== 'admin_live_state') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSync =
|
||||||
|
payload.requestsSync && typeof payload.requestsSync === 'object'
|
||||||
|
? payload.requestsSync
|
||||||
|
: null
|
||||||
|
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
||||||
|
const prevSync = requestsSyncRef.current
|
||||||
|
requestsSyncRef.current = nextSync
|
||||||
|
setRequestsSync(nextSync)
|
||||||
|
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
|
||||||
|
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawArtwork =
|
||||||
|
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
|
||||||
|
? payload.artworkPrefetch
|
||||||
|
: null
|
||||||
|
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
|
||||||
|
const prevArtwork = artworkPrefetchRef.current
|
||||||
|
artworkPrefetchRef.current = nextArtwork
|
||||||
|
setArtworkPrefetch(nextArtwork)
|
||||||
|
if (
|
||||||
|
prevArtwork?.status === 'running' &&
|
||||||
|
nextArtwork?.status &&
|
||||||
|
nextArtwork.status !== 'running'
|
||||||
|
) {
|
||||||
|
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
|
||||||
|
if (showArtworkExtras) {
|
||||||
|
void loadArtworkSummary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.logs && typeof payload.logs === 'object') {
|
||||||
|
if (Array.isArray(payload.logs.lines)) {
|
||||||
|
setLogsLines(payload.logs.lines)
|
||||||
|
setLogsStatus(null)
|
||||||
|
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
|
||||||
|
setLogsStatus(payload.logs.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true
|
||||||
|
setLiveStreamConnected(false)
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let active = true
|
let active = true
|
||||||
@@ -602,7 +706,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
active = false
|
active = false
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
}
|
}
|
||||||
}, [artworkPrefetch, loadArtworkSummary])
|
}, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
|
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
|
||||||
@@ -615,7 +719,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}, [artworkPrefetch])
|
}, [artworkPrefetch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestsSync || requestsSync.status !== 'running') {
|
if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let active = true
|
let active = true
|
||||||
@@ -640,7 +744,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
active = false
|
active = false
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
}
|
}
|
||||||
}, [requestsSync])
|
}, [liveStreamConnected, requestsSync])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestsSync || requestsSync.status === 'running') {
|
if (!requestsSync || requestsSync.status === 'running') {
|
||||||
@@ -683,12 +787,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
if (!showLogs) {
|
if (!showLogs) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (liveStreamConnected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
void loadLogs()
|
void loadLogs()
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
void loadLogs()
|
void loadLogs()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [loadLogs, showLogs])
|
}, [liveStreamConnected, loadLogs, showLogs])
|
||||||
|
|
||||||
const loadCache = async () => {
|
const loadCache = async () => {
|
||||||
setCacheStatus(null)
|
setCacheStatus(null)
|
||||||
@@ -763,7 +870,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
setMaintenanceBusy(true)
|
setMaintenanceBusy(true)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?'
|
'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Jellyseerr. Continue?'
|
||||||
)
|
)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
setMaintenanceBusy(false)
|
setMaintenanceBusy(false)
|
||||||
@@ -772,7 +879,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
setMaintenanceStatus('Flushing database...')
|
setMaintenanceStatus('Running nuclear flush...')
|
||||||
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
|
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
@@ -780,12 +887,25 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
const text = await flushResponse.text()
|
const text = await flushResponse.text()
|
||||||
throw new Error(text || 'Flush failed')
|
throw new Error(text || 'Flush failed')
|
||||||
}
|
}
|
||||||
setMaintenanceStatus('Database flushed. Starting re-sync...')
|
const flushData = await flushResponse.json()
|
||||||
|
const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
|
||||||
|
setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
|
||||||
|
const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (!usersResyncResponse.ok) {
|
||||||
|
const text = await usersResyncResponse.text()
|
||||||
|
throw new Error(text || 'User resync failed')
|
||||||
|
}
|
||||||
|
const usersResyncData = await usersResyncResponse.json()
|
||||||
|
setMaintenanceStatus(
|
||||||
|
`Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
|
||||||
|
)
|
||||||
await syncRequests()
|
await syncRequests()
|
||||||
setMaintenanceStatus('Database flushed. Re-sync running now.')
|
setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setMaintenanceStatus('Flush + resync failed.')
|
setMaintenanceStatus('Nuclear flush + resync failed.')
|
||||||
} finally {
|
} finally {
|
||||||
setMaintenanceBusy(false)
|
setMaintenanceBusy(false)
|
||||||
}
|
}
|
||||||
@@ -1452,7 +1572,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<h2>Maintenance</h2>
|
<h2>Maintenance</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Emergency tools. Use with care: flush will clear saved requests and history.
|
Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests.
|
||||||
</div>
|
</div>
|
||||||
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
|
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
|
||||||
<div className="maintenance-grid">
|
<div className="maintenance-grid">
|
||||||
@@ -1471,7 +1591,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
onClick={runFlushAndResync}
|
onClick={runFlushAndResync}
|
||||||
disabled={maintenanceBusy}
|
disabled={maintenanceBusy}
|
||||||
>
|
>
|
||||||
Flush database + resync
|
Nuclear flush + resync
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -477,26 +477,6 @@ export default function AdminInviteManagementPage() {
|
|||||||
<button type="button" onClick={loadData} disabled={loading}>
|
<button type="button" onClick={loadData} disabled={loading}>
|
||||||
{loading ? 'Loading…' : 'Reload'}
|
{loading ? 'Loading…' : 'Reload'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => {
|
|
||||||
resetInviteEditor()
|
|
||||||
setActiveTab('invites')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New invite
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => {
|
|
||||||
resetProfileEditor()
|
|
||||||
setActiveTab('profiles')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New profile
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -504,29 +484,48 @@ export default function AdminInviteManagementPage() {
|
|||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{status && <div className="status-banner">{status}</div>}
|
{status && <div className="status-banner">{status}</div>}
|
||||||
|
|
||||||
<div className="invite-admin-summary-grid">
|
<div className="admin-panel invite-admin-summary-panel">
|
||||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
<div className="invite-admin-summary-header">
|
||||||
|
<div>
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Quick counts for invite links, profiles, and managed user defaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="invite-admin-summary-list">
|
||||||
|
<div className="invite-admin-summary-row">
|
||||||
<span className="label">Invites</span>
|
<span className="label">Invites</span>
|
||||||
|
<div className="invite-admin-summary-row__value">
|
||||||
<strong>{invites.length}</strong>
|
<strong>{invites.length}</strong>
|
||||||
<small>{usableInvites} usable • {disabledInvites} disabled</small>
|
<span>{usableInvites} usable • {disabledInvites} disabled</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
</div>
|
||||||
|
<div className="invite-admin-summary-row">
|
||||||
<span className="label">Profiles</span>
|
<span className="label">Profiles</span>
|
||||||
|
<div className="invite-admin-summary-row__value">
|
||||||
<strong>{profiles.length}</strong>
|
<strong>{profiles.length}</strong>
|
||||||
<small>{activeProfiles} active profiles</small>
|
<span>{activeProfiles} active profiles</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
</div>
|
||||||
|
<div className="invite-admin-summary-row">
|
||||||
<span className="label">Non-admin users</span>
|
<span className="label">Non-admin users</span>
|
||||||
|
<div className="invite-admin-summary-row__value">
|
||||||
<strong>{nonAdminUsers.length}</strong>
|
<strong>{nonAdminUsers.length}</strong>
|
||||||
<small>{profiledUsers} with profile</small>
|
<span>{profiledUsers} with profile</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
</div>
|
||||||
|
<div className="invite-admin-summary-row">
|
||||||
<span className="label">Expiry rules</span>
|
<span className="label">Expiry rules</span>
|
||||||
|
<div className="invite-admin-summary-row__value">
|
||||||
<strong>{expiringUsers}</strong>
|
<strong>{expiringUsers}</strong>
|
||||||
<small>users with custom expiry</small>
|
<span>users with custom expiry</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-admin-tabbar">
|
||||||
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
|
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -556,6 +555,29 @@ export default function AdminInviteManagementPage() {
|
|||||||
Invites
|
Invites
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-inline-actions invite-admin-tab-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => {
|
||||||
|
resetInviteEditor()
|
||||||
|
setActiveTab('invites')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New invite
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => {
|
||||||
|
resetProfileEditor()
|
||||||
|
setActiveTab('profiles')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{activeTab === 'bulk' && (
|
{activeTab === 'bulk' && (
|
||||||
<div className="admin-split-grid invite-admin-bulk-grid">
|
<div className="admin-split-grid invite-admin-bulk-grid">
|
||||||
@@ -815,10 +837,15 @@ export default function AdminInviteManagementPage() {
|
|||||||
<p className="lede">
|
<p className="lede">
|
||||||
Link an invite to a profile to apply account defaults at sign-up.
|
Link an invite to a profile to apply account defaults at sign-up.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={saveInvite} className="admin-form compact-form">
|
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
||||||
<div className="admin-fields-grid">
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Identity</span>
|
||||||
|
<small>Code and label used to identify the invite link.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
<label>
|
<label>
|
||||||
Code (optional)
|
<span>Code (optional)</span>
|
||||||
<input
|
<input
|
||||||
value={inviteForm.code}
|
value={inviteForm.code}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -828,7 +855,7 @@ export default function AdminInviteManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Label
|
<span>Label</span>
|
||||||
<input
|
<input
|
||||||
value={inviteForm.label}
|
value={inviteForm.label}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -838,8 +865,14 @@ export default function AdminInviteManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
</div>
|
||||||
Description
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Description</span>
|
||||||
|
<small>Optional note shown on the signup page.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control">
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={inviteForm.description}
|
value={inviteForm.description}
|
||||||
@@ -848,10 +881,17 @@ export default function AdminInviteManagementPage() {
|
|||||||
}
|
}
|
||||||
placeholder="Optional note shown on the signup page"
|
placeholder="Optional note shown on the signup page"
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<div className="admin-fields-grid">
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Defaults</span>
|
||||||
|
<small>Choose a profile and optional role override for sign-up.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
<label>
|
<label>
|
||||||
Profile
|
<span>Profile</span>
|
||||||
<select
|
<select
|
||||||
value={inviteForm.profile_id}
|
value={inviteForm.profile_id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -867,7 +907,7 @@ export default function AdminInviteManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Role override
|
<span>Role override</span>
|
||||||
<select
|
<select
|
||||||
value={inviteForm.role}
|
value={inviteForm.role}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -883,9 +923,16 @@ export default function AdminInviteManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-fields-grid">
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Limits</span>
|
||||||
|
<small>Usage cap and optional expiry date/time for the invite.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
<label>
|
<label>
|
||||||
Max uses
|
<span>Max uses</span>
|
||||||
<input
|
<input
|
||||||
value={inviteForm.max_uses}
|
value={inviteForm.max_uses}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -896,7 +943,7 @@ export default function AdminInviteManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Invite expiry (ISO datetime)
|
<span>Invite expiry (ISO datetime)</span>
|
||||||
<input
|
<input
|
||||||
value={inviteForm.expires_at}
|
value={inviteForm.expires_at}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -906,6 +953,14 @@ export default function AdminInviteManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Status</span>
|
||||||
|
<small>Enable or disable the invite before sharing.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||||
<label className="inline-checkbox">
|
<label className="inline-checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -926,6 +981,8 @@ export default function AdminInviteManagementPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
|
|||||||
@@ -1027,6 +1027,30 @@ button span {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header account menu layering fix */
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
position: relative;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-in-menu {
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signed-in-dropdown {
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-toolbar {
|
.admin-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -4442,6 +4466,149 @@ button:hover:not(:disabled) {
|
|||||||
grid-template-columns: minmax(360px, 1.2fr) minmax(300px, 0.8fr);
|
grid-template-columns: minmax(360px, 1.2fr) minmax(300px, 0.8fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-header .lede {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(150px, 200px) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-row .label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #9ea7b6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-row__value {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-row__value strong {
|
||||||
|
color: #eef2f7;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-row__value span {
|
||||||
|
color: #b3bcc8;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-tabbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-tabbar .admin-segmented {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-tab-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-layout {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-label {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-label > span {
|
||||||
|
color: #e6ebf2;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-label > small {
|
||||||
|
color: #9ea7b6;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-control {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-grid > label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-grid > label > span {
|
||||||
|
color: #9ea7b6;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-control textarea,
|
||||||
|
.invite-form-row-control input,
|
||||||
|
.invite-form-row-control select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-control--stacked {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-panel > h2 + .lede {
|
.admin-panel > h2 + .lede {
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
}
|
}
|
||||||
@@ -4462,6 +4629,14 @@ button:hover:not(:disabled) {
|
|||||||
.invite-admin-bulk-grid {
|
.invite-admin-bulk-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-form-row-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
@@ -4499,4 +4674,21 @@ button:hover:not(:disabled) {
|
|||||||
.invite-admin-summary-grid {
|
.invite-admin-summary-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-summary-row__value {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-tabbar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-admin-tab-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,24 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
|
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
|
||||||
|
|
||||||
|
const normalizeRecentResults = (items: any[]) =>
|
||||||
|
items
|
||||||
|
.filter((item: any) => item?.id)
|
||||||
|
.map((item: any) => {
|
||||||
|
const id = item.id
|
||||||
|
const rawTitle = item.title
|
||||||
|
const placeholder =
|
||||||
|
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
||||||
|
year: item.year,
|
||||||
|
statusLabel: item.statusLabel,
|
||||||
|
artwork: item.artwork,
|
||||||
|
createdAt: item.createdAt ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
@@ -33,6 +51,7 @@ export default function HomePage() {
|
|||||||
const [servicesError, setServicesError] = useState<string | null>(null)
|
const [servicesError, setServicesError] = useState<string | null>(null)
|
||||||
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
|
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
|
||||||
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
|
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
|
||||||
|
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||||||
|
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -137,25 +156,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (Array.isArray(data?.results)) {
|
if (Array.isArray(data?.results)) {
|
||||||
setRecent(
|
setRecent(normalizeRecentResults(data.results))
|
||||||
data.results
|
|
||||||
.filter((item: any) => item?.id)
|
|
||||||
.map((item: any) => {
|
|
||||||
const id = item.id
|
|
||||||
const rawTitle = item.title
|
|
||||||
const placeholder =
|
|
||||||
typeof rawTitle === 'string' &&
|
|
||||||
rawTitle.trim().toLowerCase() === `request ${id}`
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
|
||||||
year: item.year,
|
|
||||||
statusLabel: item.statusLabel,
|
|
||||||
artwork: item.artwork,
|
|
||||||
createdAt: item.createdAt ?? null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -196,10 +197,79 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load()
|
void load()
|
||||||
|
if (liveStreamConnected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const timer = setInterval(load, 30000)
|
const timer = setInterval(load, 30000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [authReady, router])
|
}, [authReady, liveStreamConnected, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authReady) {
|
||||||
|
setLiveStreamConnected(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
setLiveStreamConnected(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const streamUrl = `${baseUrl}/events/stream?access_token=${encodeURIComponent(token)}&recent_days=${encodeURIComponent(String(recentDays))}`
|
||||||
|
let closed = false
|
||||||
|
const source = new EventSource(streamUrl)
|
||||||
|
|
||||||
|
source.onopen = () => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(true)
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data)
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (payload.type === 'home_recent') {
|
||||||
|
if (Array.isArray(payload.results)) {
|
||||||
|
setRecent(normalizeRecentResults(payload.results))
|
||||||
|
setRecentError(null)
|
||||||
|
setRecentLoading(false)
|
||||||
|
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||||
|
setRecentError('Recent requests are not available right now.')
|
||||||
|
setRecentLoading(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (payload.type === 'home_services') {
|
||||||
|
if (payload.status && typeof payload.status === 'object') {
|
||||||
|
setServicesStatus(payload.status)
|
||||||
|
setServicesError(null)
|
||||||
|
setServicesLoading(false)
|
||||||
|
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||||
|
setServicesError('Service status is not available right now.')
|
||||||
|
setServicesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true
|
||||||
|
setLiveStreamConnected(false)
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}, [authReady, recentDays])
|
||||||
|
|
||||||
const runSearch = async (term: string) => {
|
const runSearch = async (term: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user