Compare commits

..
Author SHA1 Message Date
Assclaw 06d944c9d9 Add admin user email management
Magent CI/CD / verify (push) Successful in 10m59s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 20s
2026-09-01 22:09:20 +12:00
Assclaw 0ac53b7f59 Add quality-aware release picker modal
Magent CI/CD / verify (push) Canceled after 7m6s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 22:02:22 +12:00
Assclaw 2dbe11e6bc Rework invite creation as guided flow
Magent CI/CD / verify (push) Canceled after 8m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:53:27 +12:00
Assclaw 3aac40ba0f Keep issue dialog above site navigation
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-09-01 21:33:02 +12:00
Assclaw b6c48a0be7 Clarify completed repair handoff
Magent CI/CD / verify (push) Canceled after 9m53s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:22:55 +12:00
Assclaw 3fc52f70c7 Show live repair progress on requests
Magent CI/CD / verify (push) Canceled after 4m53s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 21:17:50 +12:00
Assclaw 6391fbfd81 Monitor media before issue repair searches
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-09-01 20:50:16 +12:00
Assclaw 0ed22dd315 Separate invites from profile navigation
Magent CI/CD / verify (push) Successful in 10m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 19:51:02 +12:00
Assclaw 7ed0f4b103 Add global admin user-view preview
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 17:04:25 +12:00
Assclaw b3c41f6dea Add safe user preview for invites
Magent CI/CD / verify (push) Successful in 10m52s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-01 16:51:32 +12:00
Assclaw 976d24217b Fix top-level invites navigation
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 15:28:28 +12:00
Assclaw c49a149cfd Refresh invite operations workspace
Magent CI/CD / verify (push) Successful in 10m26s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
2026-09-01 15:12:37 +12:00
Assclaw 5de14b1cb7 Add admin issue deletion workflow
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-09-01 14:58:58 +12:00
Assclaw 16876e1cf0 Advance issue status from repair actions
Magent CI/CD / verify (push) Successful in 10m30s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Canceled after 5s
2026-09-01 14:48:08 +12:00
Assclaw c7a56f2525 Add issue workflow progress tracker
Magent CI/CD / verify (push) Canceled after 3m55s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-01 14:44:17 +12:00
Assclaw 87a4aae246 Move issue history into sidebar modal
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-09-01 14:28:27 +12:00
Assclaw e58614305e Streamline issue reporting and automate repairs
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-09-01 13:44:13 +12:00
Assclaw 2adbed7259 Add issue resolution confirmation workflow
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 12s
2026-09-01 11:40:43 +12:00
Assclaw 393b8c2a88 Add prominent watch button to ready requests
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-09-01 11:16:43 +12:00
Assclaw b0eff9ffcf Clarify and live-refresh media indexing state
Magent CI/CD / verify (push) Successful in 10m43s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 26s
2026-09-01 09:41:15 +12:00
Assclaw ae6cee5d0b Explain remote service responses in plain English
Magent CI/CD / verify (push) Successful in 10m20s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-08-31 22:28:56 +12:00
Assclaw 906a777b95 Fix issue search result card layout
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s
2026-08-31 22:14:33 +12:00
Assclaw ec8145a58a Add exact media replacement from issues
Magent CI/CD / verify (push) Canceled after 6m9s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 22:05:11 +12:00
Assclaw f8770cb44a Build guided media issue workflow
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-31 21:46:43 +12:00
Assclaw 0e04d219a0 Reorganize admin configuration sections
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-08-31 16:32:24 +12:00
Assclaw 3402e53c31 Protect advanced request diagnostics for non-admins
Magent CI/CD / verify (push) Successful in 11m8s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-31 16:09:17 +12:00
Assclaw ecf9b230c1 Separate new requests from request tracking
Magent CI/CD / verify (push) Canceled after 7m35s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 16:02:00 +12:00
Assclaw 8f810e0f36 Isolate sparse request recheck regression test
Magent CI/CD / verify (push) Successful in 10m11s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-31 15:23:49 +12:00
Assclaw 82d87d968e Fix sparse request metadata hydration 2026-08-31 15:21:47 +12:00
Assclaw 372f4a1bfc Fix Seerr transport payload forwarding
Magent CI/CD / verify (push) Canceled after 9m15s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:14:32 +12:00
Assclaw 547ed754e6 Handle Seerr CSRF on request creation
Magent CI/CD / verify (push) Canceled after 2m30s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:11:57 +12:00
Assclaw a55369190b Move request service icons beside card content
Magent CI/CD / verify (push) Canceled after 6m44s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 15:05:01 +12:00
Assclaw ee81749b43 Add Sonarr and Radarr request icons
Magent CI/CD / verify (push) Canceled after 5m33s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 14:59:23 +12:00
Assclaw 9dfea25d56 Redesign the request portal flow
Magent CI/CD / verify (push) Canceled after 5m59s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-31 14:53:15 +12:00
Assclaw 963506d098 Show live feedback for remote request actions
Magent CI/CD / verify (push) Successful in 10m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-08-30 21:23:20 +12:00
Assclaw 02245d365e Add live request pipeline recheck
Magent CI/CD / verify (push) Successful in 11m6s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m56s
2026-08-30 19:50:09 +12:00
Assclaw 2cbd9fe73f Hide landing page live update indicator
Magent CI/CD / verify (push) Successful in 11m5s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s
2026-08-30 19:24:56 +12:00
Assclaw 9db32481bd Resolve Arr metadata before adding media
Magent CI/CD / verify (push) Successful in 11m12s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-30 16:32:14 +12:00
Assclaw 3815dfea60 Move fleet health into admin settings and tidy landing page
Magent CI/CD / verify (push) Successful in 10m46s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 18s
2026-08-29 22:53:48 +12:00
Assclaw c073581639 Mark available downloads complete
Magent CI/CD / verify (push) Successful in 10m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 14s
2026-08-29 22:41:50 +12:00
Assclaw 06a000bb06 Balance manual results across missing seasons
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-08-29 22:17:39 +12:00
Assclaw 391cd41d71 Route manual downloads through Arr collectors
Magent CI/CD / verify (push) Canceled after 3m43s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-08-29 22:13:46 +12:00
Assclaw 96fc43365f Add live download progress updates
Magent CI/CD / verify (push) Successful in 12m15s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 21s
2026-08-29 20:52:49 +12:00
Assclaw 655e2f8158 Start Magent beta overhaul
Magent CI/CD / verify (push) Successful in 13m40s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s
2026-08-29 20:27:17 +12:00
60 changed files with 16188 additions and 2064 deletions
+31
View File
@@ -71,3 +71,34 @@ jobs:
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
run: bash scripts/deploy_ams_dev01.sh
deploy-beta:
if: github.ref_name == 'beta'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
fi
- name: Deploy beta to AMS-DEV01
env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
run: bash scripts/deploy_beta_ams_dev01.sh
+313 -10
View File
@@ -4,6 +4,249 @@ import time
import httpx
from ..logging_config import sanitize_headers, sanitize_value
from ..services.operation_progress import finish_remote_call, start_remote_call
_SERVICE_NAMES = {
"JellyseerrClient": "Seerr",
"SonarrClient": "Sonarr",
"RadarrClient": "Radarr",
"BazarrClient": "Bazarr",
"ProwlarrClient": "Prowlarr",
"JellyfinClient": "Jellyfin",
"QBittorrentClient": "qBittorrent",
}
def _result_items(result: Any, *keys: str) -> list[Any]:
if isinstance(result, list):
return result
if not isinstance(result, dict):
return []
for key in keys:
value = result.get(key)
if isinstance(value, list):
return value
return []
def _result_title(result: Any, payload: Optional[Dict[str, Any]] = None) -> Optional[str]:
candidates = result if isinstance(result, list) else [result]
for candidate in candidates:
if not isinstance(candidate, dict):
continue
title = str(candidate.get("title") or candidate.get("name") or "").strip()
if title:
return title
if isinstance(payload, dict):
title = str(payload.get("title") or payload.get("name") or "").strip()
if title:
return title
return None
def _count_message(count: int, singular: str, plural: Optional[str] = None) -> str:
noun = singular if count == 1 else (plural or f"{singular}s")
return f"{count} {noun}"
def _queue_result_message(service: str, result: Any) -> str:
records = _result_items(result, "records", "items")
total = result.get("totalRecords") if isinstance(result, dict) else None
count = int(total) if isinstance(total, int) else len(records)
if count == 0:
return f"{service} has no matching downloads in its queue."
first = next((item for item in records if isinstance(item, dict)), None)
progress_text = ""
if first:
size = first.get("size")
size_left = first.get("sizeleft")
if isinstance(size, (int, float)) and size > 0 and isinstance(size_left, (int, float)):
progress = max(0, min(100, round((1 - (size_left / size)) * 100)))
progress_text = f" The first is {progress}% complete."
return f"{service} found {_count_message(count, 'matching download')} in its queue.{progress_text}"
def _command_name(payload: Optional[Dict[str, Any]]) -> str:
raw_name = str((payload or {}).get("name") or "").strip()
names = {
"MoviesSearch": "movie search",
"SeriesSearch": "series search",
"EpisodeSearch": "episode search",
"DownloadRelease": "release download",
"RefreshMovie": "movie refresh",
"RescanMovie": "movie rescan",
"RefreshSeries": "series refresh",
"RescanSeries": "series rescan",
}
return names.get(raw_name, "command")
def _operation_result_message(
service: str,
method: str,
path: str,
result: Any,
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> str:
normalized_path = path.lower().split("?", 1)[0].rstrip("/")
normalized_method = method.upper()
title = _result_title(result, payload)
title_text = f' "{title}"' if title else ""
if service == "Seerr":
if normalized_path.endswith("/request") and normalized_method == "POST":
request_id = result.get("id") if isinstance(result, dict) else None
suffix = f" #{request_id}" if isinstance(request_id, int) else ""
return f"Seerr created the request{suffix} and passed it into the collection workflow."
if "/request/" in normalized_path and normalized_method == "GET":
status_names = {1: "waiting for approval", 2: "approved", 3: "declined"}
status = result.get("status") if isinstance(result, dict) else None
status_text = status_names.get(status)
return (
f"Seerr found the request; it is currently {status_text}."
if status_text
else "Seerr found the request and returned its current status."
)
if service in {"Radarr", "Sonarr"}:
media_name = "movie" if service == "Radarr" else "series"
media_path = "/movie" if service == "Radarr" else "/series"
if "/queue" in normalized_path and normalized_method == "GET":
return _queue_result_message(service, result)
if "/command" in normalized_path and normalized_method == "POST":
return f"{service} accepted the {_command_name(payload)} and queued it for processing."
if "/release" in normalized_path:
if normalized_method == "GET":
count = len(_result_items(result, "records", "items"))
return (
f"{service} found {_count_message(count, 'download option')}."
if count
else f"{service} could not find a suitable download option."
)
return f"{service} accepted the selected release and sent it to the download client."
if "/qualityprofile" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'available quality profile')}."
if "/rootfolder" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'configured library location')}."
if "/indexer" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} reports {_count_message(count, 'configured search source')}."
if service == "Sonarr" and "/episodefile" in normalized_path:
if normalized_method == "DELETE":
return "Sonarr removed the existing episode file so it can be replaced."
count = len(_result_items(result))
return f"Sonarr found {_count_message(count, 'downloaded episode file')}."
if service == "Sonarr" and normalized_path.endswith("/episode/monitor") and normalized_method == "PUT":
return "Sonarr marked the selected episodes as wanted."
if service == "Sonarr" and "/episode" in normalized_path and normalized_method == "GET":
episodes = _result_items(result)
available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True)
return f"Sonarr reports {available} of {len(episodes)} episodes downloaded."
if service == "Radarr" and "/moviefile/" in normalized_path and normalized_method == "DELETE":
return "Radarr removed the existing movie file so it can be replaced."
is_media_endpoint = normalized_path.endswith(media_path) or f"{media_path}/" in normalized_path
if is_media_endpoint:
if normalized_method == "GET":
found = bool(result) if not isinstance(result, list) else len(result) > 0
return (
f"{service} found{title_text} in its library list."
if found
else f"This {media_name} is not currently in {service}."
)
if normalized_method == "POST":
search_key = "searchForMovie" if service == "Radarr" else "searchForMissingEpisodes"
search_requested = bool(((payload or {}).get("addOptions") or {}).get(search_key))
search_text = " and started looking for a download" if search_requested else ""
subject = title_text or f" the {media_name}"
return f"{service} added{subject}{search_text}."
if normalized_method == "PUT":
return f"{service} saved the updated settings for{title_text or f' the {media_name}'}."
if "/system/status" in normalized_path:
version = str(result.get("version") or "").strip() if isinstance(result, dict) else ""
return f"Connected to {service}{f' version {version}' if version else ''}."
if service == "Prowlarr":
if "/health" in normalized_path:
issues = _result_items(result)
if not issues:
return "Prowlarr reports that all configured indexers are healthy."
first = next((item for item in issues if isinstance(item, dict)), {})
detail = str(first.get("message") or first.get("source") or "").strip()
suffix = f" First issue: {detail}" if detail else ""
return f"Prowlarr reports {_count_message(len(issues), 'indexer issue')}.{suffix}"
if "/search" in normalized_path:
results = _result_items(result, "results", "records")
return (
f"Prowlarr found {_count_message(len(results), 'possible release')}."
if results
else "Prowlarr did not find any possible releases."
)
if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path:
target = "movie" if "/movies/" in normalized_path else "selected episode"
language = str((params or {}).get("language") or "the requested language").upper()
return f"Bazarr accepted a fresh {language} subtitle search for the {target}."
if normalized_method == "GET":
return f"{service} completed the check successfully."
if normalized_method == "POST":
return f"{service} accepted the request and started processing it."
if normalized_method == "PUT":
return f"{service} saved the requested changes."
if normalized_method == "DELETE":
return f"{service} confirmed the item was removed."
return f"{service} completed the request successfully."
def _operation_error_message(service: str, status_code: Optional[int]) -> str:
explanations = {
400: "rejected the request because some details were invalid",
401: "rejected Magent's login details",
403: "refused permission for this action",
404: "could not find the requested item",
409: "reported a conflict, usually because the item already exists",
422: "could not use the details Magent supplied",
429: "is busy and asked Magent to try again later",
500: "encountered an internal error while processing the request",
502: "could not reach one of its own dependent services",
503: "is temporarily unavailable",
504: "did not finish before the request timed out",
}
explanation = explanations.get(status_code)
if explanation:
return f"{service} {explanation}."
if status_code:
return f"{service} could not complete the request (response code {status_code})."
return f"Magent could not get a usable response from {service}."
def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]:
normalized_path = path.lower()
normalized_method = method.upper()
if service == "Seerr" and "/request/" in normalized_path and normalized_method == "GET":
return "Reading the request from Seerr…", "Seerr returned the current request record"
if service == "Radarr" and normalized_path.endswith("/movie") and normalized_method == "GET":
return "Checking Radarr for the movie…", "Radarr returned the movie record"
if service == "Sonarr" and normalized_path.endswith("/series") and normalized_method == "GET":
return "Checking Sonarr for the series…", "Sonarr returned the series record"
if service in {"Radarr", "Sonarr"} and "/queue" in normalized_path:
return f"Checking {service}'s download queue…", f"{service} returned its queue state"
if service == "Sonarr" and "/episode" in normalized_path:
return "Checking episode availability in Sonarr…", "Sonarr returned episode availability"
if service in {"Radarr", "Sonarr"} and "/release" in normalized_path:
return f"Checking releases through {service}", f"{service} returned release information"
if service in {"Radarr", "Sonarr"} and "/command" in normalized_path:
return f"Sending a command to {service}", f"{service} accepted the command"
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
if service == "Prowlarr" and "/health" in normalized_path:
return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health"
return f"Contacting {service}", f"{service} responded"
class ApiClient:
@@ -29,6 +272,24 @@ class ApiClient:
return f"{payload[:500]}..."
return payload
async def _send_request(
self,
client: httpx.AsyncClient,
method: str,
url: str,
*,
headers: Dict[str, str],
params: Optional[Dict[str, Any]],
payload: Optional[Dict[str, Any]],
) -> httpx.Response:
return await client.request(
method,
url,
headers=headers,
params=params,
json=payload,
)
async def _request(
self,
method: str,
@@ -36,12 +297,16 @@ class ApiClient:
*,
params: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 10.0,
) -> Optional[Any]:
if not self.base_url:
self.logger.warning("client request skipped method=%s path=%s reason=not-configured", method, path)
return None
url = f"{self.base_url}{path}"
started_at = time.perf_counter()
service_name = _SERVICE_NAMES.get(self.__class__.__name__, self.__class__.__name__.removesuffix("Client"))
active_message, _ = _operation_messages(service_name, method, path)
operation_event_id = start_remote_call(service_name, active_message)
self.logger.debug(
"outbound request started method=%s url=%s params=%s payload=%s headers=%s",
method,
@@ -51,13 +316,14 @@ class ApiClient:
sanitize_headers(self.headers()),
)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.request(
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await self._send_request(
client,
method,
url,
headers=self.headers(),
params=params,
json=payload,
payload=payload,
)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
@@ -68,9 +334,21 @@ class ApiClient:
response.status_code,
duration_ms,
)
if not response.content:
return None
return response.json()
result = response.json() if response.content else None
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_operation_result_message(
service_name,
method,
path,
result,
params=params,
payload=payload,
),
)
return result
except httpx.HTTPStatusError as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
response = exc.response
@@ -84,6 +362,15 @@ class ApiClient:
duration_ms,
self._response_summary(response),
)
finish_remote_call(
operation_event_id,
success=False,
status_code=status if isinstance(status, int) else None,
message=_operation_error_message(
service_name,
status if isinstance(status, int) else None,
),
)
raise
except Exception:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
@@ -93,10 +380,22 @@ class ApiClient:
url,
duration_ms,
)
finish_remote_call(
operation_event_id,
success=False,
message=_operation_error_message(service_name, None),
)
raise
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("GET", path, params=params)
async def get(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 10.0,
) -> Optional[Any]:
return await self._request(
"GET", path, params=params, timeout_seconds=timeout_seconds
)
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("POST", path, payload=payload)
@@ -104,5 +403,9 @@ class ApiClient:
async def put(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
return await self._request("PUT", path, payload=payload)
async def delete(self, path: str) -> Optional[Any]:
return await self._request("DELETE", path)
async def delete(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Any]:
return await self._request("DELETE", path, params=params)
+48
View File
@@ -0,0 +1,48 @@
from typing import Any, Optional
from .base import ApiClient
class BazarrClient(ApiClient):
async def get_system_status(self) -> Optional[Any]:
return await self._request("GET", "/api/system/status")
async def search_movie_subtitles(
self,
radarr_id: int,
*,
language: str,
forced: bool = False,
) -> Optional[Any]:
return await self._request(
"PATCH",
"/api/movies/subtitles",
params={
"radarrid": radarr_id,
"language": language,
"forced": str(forced).lower(),
"hi": "false",
},
timeout_seconds=90.0,
)
async def search_episode_subtitles(
self,
series_id: int,
episode_id: int,
*,
language: str,
forced: bool = False,
) -> Optional[Any]:
return await self._request(
"PATCH",
"/api/episodes/subtitles",
params={
"seriesid": series_id,
"episodeid": episode_id,
"language": language,
"forced": str(forced).lower(),
"hi": "false",
},
timeout_seconds=90.0,
)
+72 -2
View File
@@ -1,6 +1,24 @@
from typing import Any, Dict, Optional
import httpx
from .base import ApiClient
import time
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
def _availability_message(result: Any) -> str:
if not isinstance(result, dict):
return "Jellyfin did not return any matching library items."
total = result.get("TotalRecordCount")
items = result.get("Items")
available = (
(isinstance(total, int) and total > 0)
or (isinstance(items, list) and len(items) > 0)
)
return (
"The title is available to watch in Jellyfin."
if available
else "The title is not currently available in Jellyfin."
)
class JellyfinClient(ApiClient):
@@ -167,6 +185,8 @@ class JellyfinClient(ApiClient):
) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
url = f"{self.base_url}/Items"
params = {
"SearchTerm": term,
@@ -175,10 +195,29 @@ class JellyfinClient(ApiClient):
"Limit": limit,
}
headers = self._emby_headers()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
return response.json()
result = response.json()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_availability_message(result),
)
return result
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("Jellyfin", status_code),
)
raise
async def get_system_info(self) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
@@ -190,12 +229,43 @@ class JellyfinClient(ApiClient):
response.raise_for_status()
return response.json()
async def get_sessions(self) -> Optional[list[Dict[str, Any]]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Sessions"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, list) else []
async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
url = f"{self.base_url}/Library/Refresh"
headers = self._emby_headers()
params = {"Recursive": "true" if recursive else "false"}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message="Jellyfin accepted the library refresh and is scanning for new media.",
)
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("Jellyfin", status_code),
)
raise
+53 -7
View File
@@ -1,9 +1,44 @@
from typing import Any, Dict, Optional
from urllib.parse import quote, unquote, urlsplit
import httpx
from .base import ApiClient
class JellyseerrClient(ApiClient):
async def _send_request(
self,
client: httpx.AsyncClient,
method: str,
url: str,
*,
headers: Dict[str, str],
params: Optional[Dict[str, Any]],
payload: Optional[Dict[str, Any]],
) -> httpx.Response:
request_headers = dict(headers)
if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url:
# Seerr's optional CSRF protection also applies to API-key writes.
# Seed its secret/token cookie pair, then echo the readable token in
# the header Seerr's own web client uses.
csrf_response = await client.get(
f"{self.base_url}/api/v1/auth/me",
headers=self.headers(),
)
csrf_response.raise_for_status()
csrf_token = client.cookies.get("XSRF-TOKEN")
if csrf_token:
request_headers["XSRF-TOKEN"] = unquote(csrf_token)
parsed_base = urlsplit(self.base_url)
request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}"
return await super()._send_request(
client,
method,
url,
headers=request_headers,
params=params,
payload=payload,
)
async def get_status(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v1/status")
@@ -26,13 +61,15 @@ class JellyseerrClient(ApiClient):
return await self.get(f"/api/v1/tv/{tmdb_id}")
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/search",
params={
"query": query,
"page": page,
},
)
# Seerr rejects the `+` encoding that standard query builders use for
# spaces. Build this query explicitly so multi-word titles are sent as
# percent-encoded values.
encoded_query = quote(query, safe="")
return await self.get(f"/api/v1/search?query={encoded_query}&page={page}")
async def get_service_settings(self, media_type: str) -> Optional[Any]:
service = "sonarr" if media_type == "tv" else "radarr"
return await self.get(f"/api/v1/settings/{service}")
async def create_request(
self,
@@ -41,6 +78,9 @@ class JellyseerrClient(ApiClient):
media_id: int,
seasons: Optional[list[int]] = None,
is_4k: Optional[bool] = None,
server_id: Optional[int] = None,
profile_id: Optional[int] = None,
root_folder: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {
"mediaType": media_type,
@@ -50,6 +90,12 @@ class JellyseerrClient(ApiClient):
payload["seasons"] = seasons
if isinstance(is_4k, bool):
payload["is4k"] = is_4k
if isinstance(server_id, int):
payload["serverId"] = server_id
if isinstance(profile_id, int):
payload["profileId"] = profile_id
if isinstance(root_folder, str) and root_folder.strip():
payload["rootFolder"] = root_folder.strip()
return await self.post("/api/v1/request", payload=payload)
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
+128 -5
View File
@@ -1,7 +1,59 @@
from typing import Any, Dict, Optional
import httpx
import logging
from .base import ApiClient
import time
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
def _torrent_state_text(state: Any) -> str:
normalized = str(state or "").strip().lower()
if "pause" in normalized:
return "paused"
if "stall" in normalized:
return "stalled"
if normalized.startswith("queued"):
return "waiting in the queue"
if "downloading" in normalized or normalized in {"forcedl", "metadl", "checkingdl"}:
return "downloading"
if "upload" in normalized or normalized in {"stalledup", "forcedup"}:
return "finished and seeding"
if normalized in {"completed", "missingfiles"}:
return "finished" if normalized == "completed" else "missing files"
if "error" in normalized:
return "in an error state"
return "present"
def _torrent_result_message(result: Any) -> str:
torrents = result if isinstance(result, list) else []
if not torrents:
return "qBittorrent found no matching downloads."
first = next((item for item in torrents if isinstance(item, dict)), {})
if len(torrents) == 1:
name = str(first.get("name") or "the matching download").strip()
progress = first.get("progress")
progress_text = (
f" and {max(0, min(100, round(progress * 100)))}% complete"
if isinstance(progress, (int, float))
else ""
)
return f'qBittorrent found "{name}"; it is {_torrent_state_text(first.get("state"))}{progress_text}.'
active = sum(
1
for item in torrents
if isinstance(item, dict) and _torrent_state_text(item.get("state")) == "downloading"
)
return f"qBittorrent found {len(torrents)} matching downloads; {active} are actively downloading."
def _torrent_action_message(path: str) -> str:
normalized_path = path.lower()
if normalized_path.endswith("/resume") or normalized_path.endswith("/start"):
return "qBittorrent accepted the request to resume the download."
if normalized_path.endswith("/add"):
return "qBittorrent accepted the release and added it to the download queue."
return "qBittorrent accepted the requested download action."
class QBittorrentClient(ApiClient):
@@ -23,34 +75,98 @@ class QBittorrentClient(ApiClient):
headers={"Referer": self.base_url},
)
response.raise_for_status()
if response.text.strip().lower() != "ok.":
text = response.text.strip().lower()
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
raise RuntimeError("qBittorrent login failed")
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
return response.json()
result = response.json()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_torrent_result_message(result),
)
return result
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("qBittorrent", status_code),
)
raise
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not self.base_url:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
return response.text.strip()
result = response.text.strip()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=f"Connected to qBittorrent{f' version {result}' if result else ''}.",
)
return result
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("qBittorrent", status_code),
)
raise
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
if not self.base_url:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
status_code=response.status_code,
message=_torrent_action_message(path),
)
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
success=False,
status_code=status_code,
message=_operation_error_message("qBittorrent", status_code),
)
raise
async def is_webui_reachable(self) -> bool:
if not self.base_url:
@@ -72,6 +188,9 @@ class QBittorrentClient(ApiClient):
async def get_torrents_by_category(self, category: str) -> Optional[Any]:
return await self._get("/api/v2/torrents/info", params={"category": category})
async def get_torrents_by_tag(self, tag: str) -> Optional[Any]:
return await self._get("/api/v2/torrents/info", params={"tag": tag})
async def get_app_version(self) -> Optional[Any]:
return await self._get_text("/api/v2/app/version")
@@ -84,7 +203,9 @@ class QBittorrentClient(ApiClient):
return
raise
async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None:
async def add_torrent_url(
self, url: str, category: Optional[str] = None, tags: Optional[str] = None
) -> None:
url_host = None
if isinstance(url, str) and "://" in url:
url_host = url.split("://", 1)[-1].split("/", 1)[0]
@@ -96,4 +217,6 @@ class QBittorrentClient(ApiClient):
data: Dict[str, Any] = {"urls": url}
if category:
data["category"] = category
if tags:
data["tags"] = tags
await self._post_form("/api/v2/torrents/add", data=data)
+30
View File
@@ -9,6 +9,10 @@ class RadarrClient(ApiClient):
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
async def lookup_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
result = await self.get("/api/v3/movie/lookup/tmdb", params={"tmdbId": tmdb_id})
return result if isinstance(result, dict) else None
async def get_movie(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/movie/{movie_id}")
@@ -24,12 +28,32 @@ class RadarrClient(ApiClient):
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id})
async def search_releases(self, movie_id: int) -> Optional[Any]:
return await self.get(
"/api/v3/release", params={"movieId": movie_id}, timeout_seconds=90.0
)
async def get_indexers(self) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/indexer")
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
async def monitor_movie(
self, movie_id: int, monitored: bool = True
) -> Optional[Dict[str, Any]]:
movie = await self.get_movie(movie_id)
if not isinstance(movie, dict):
raise ValueError("Radarr did not return the movie before updating its monitored state")
movie["monitored"] = monitored
return await self.update_movie(movie)
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/moviefile/{movie_file_id}",
params={"deleteFromClient": "true"},
)
async def add_movie(
self,
tmdb_id: int,
@@ -37,9 +61,15 @@ class RadarrClient(ApiClient):
root_folder: str,
monitored: bool = True,
search_for_movie: bool = True,
title: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
lookup = await self.lookup_movie_by_tmdb_id(tmdb_id)
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
if not resolved_title:
raise ValueError("Radarr could not resolve a title for this TMDB ID")
payload = {
"tmdbId": tmdb_id,
"title": resolved_title,
"qualityProfileId": quality_profile_id,
"rootFolderPath": root_folder,
"monitored": monitored,
+43 -2
View File
@@ -9,6 +9,20 @@ class SonarrClient(ApiClient):
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
async def lookup_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
result = await self.get("/api/v3/series/lookup", params={"term": f"tvdb:{tvdb_id}"})
if not isinstance(result, list):
return None
for item in result:
if not isinstance(item, dict):
continue
try:
if int(item.get("tvdbId")) == tvdb_id:
return item
except (TypeError, ValueError):
continue
return next((item for item in result if isinstance(item, dict)), None)
async def get_series(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get(f"/api/v3/series/{series_id}")
@@ -27,12 +41,36 @@ class SonarrClient(ApiClient):
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episode", params={"seriesId": series_id})
async def get_episode_files(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/episodefile", params={"seriesId": series_id})
async def search_releases(self, series_id: int, season_number: int) -> Optional[Any]:
return await self.get(
"/api/v3/release",
params={"seriesId": series_id, "seasonNumber": season_number},
timeout_seconds=90.0,
)
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
async def monitor_episodes(
self, episode_ids: list[int], monitored: bool = True
) -> Optional[Dict[str, Any]]:
return await self.put(
"/api/v3/episode/monitor",
payload={"episodeIds": episode_ids, "monitored": monitored},
)
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
return await self.delete(
f"/api/v3/episodefile/{episode_file_id}",
params={"deleteFromClient": "true"},
)
async def add_series(
self,
tvdb_id: int,
@@ -42,16 +80,19 @@ class SonarrClient(ApiClient):
title: Optional[str] = None,
search_missing: bool = True,
) -> Optional[Dict[str, Any]]:
lookup = await self.lookup_series_by_tvdb_id(tvdb_id)
resolved_title = str((lookup or {}).get("title") or "").strip() or (title or "").strip()
if not resolved_title:
raise ValueError("Sonarr could not resolve a title for this TVDB ID")
payload = {
"tvdbId": tvdb_id,
"title": resolved_title,
"qualityProfileId": quality_profile_id,
"rootFolderPath": root_folder,
"monitored": monitored,
"seasonFolder": True,
"addOptions": {"searchForMissingEpisodes": search_missing},
}
if title:
payload["title"] = title
return await self.post("/api/v3/series", payload=payload)
async def update_series(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+22
View File
@@ -85,6 +85,15 @@ class Settings(BaseSettings):
requests_data_source: str = Field(
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
)
issue_confirmation_contact_attempts: int = Field(
default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
)
issue_confirmation_interval_value: int = Field(
default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
)
issue_confirmation_interval_unit: str = Field(
default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
)
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
@@ -110,6 +119,9 @@ class Settings(BaseSettings):
site_login_show_signup_link: bool = Field(
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
)
site_nav_show_requests: bool = Field(
default=True, validation_alias=AliasChoices("SITE_NAV_SHOW_REQUESTS")
)
site_changelog: Optional[str] = Field(default=CHANGELOG)
magent_application_url: Optional[str] = Field(
@@ -293,6 +305,16 @@ class Settings(BaseSettings):
validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"),
)
bazarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("BAZARR_URL", "BAZARR_BASE_URL")
)
bazarr_api_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("BAZARR_API_KEY", "BAZARR_KEY")
)
bazarr_default_language: str = Field(
default="en", validation_alias=AliasChoices("BAZARR_DEFAULT_LANGUAGE")
)
prowlarr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL")
)
+193 -1
View File
@@ -400,6 +400,27 @@ def init_db() -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS portal_item_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
event_type TEXT NOT NULL,
actor_username TEXT NOT NULL,
actor_role TEXT NOT NULL,
message TEXT NOT NULL,
metadata_json TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_item_activity_item
ON portal_item_activity (item_id, created_at ASC, id ASC)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -691,6 +712,18 @@ def save_snapshot(snapshot: Snapshot) -> None:
payload = json.dumps(snapshot.model_dump(), ensure_ascii=True)
created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
latest = conn.execute(
"""
SELECT state, state_reason
FROM snapshots
WHERE request_id = ?
ORDER BY id DESC
LIMIT 1
""",
(snapshot.request_id,),
).fetchone()
if latest and latest[0] == snapshot.state.value and latest[1] == snapshot.state_reason:
return
conn.execute(
"""
INSERT INTO snapshots (request_id, state, state_reason, created_at, payload_json)
@@ -725,6 +758,7 @@ def save_action(
def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any]]:
bounded_limit = max(1, min(int(limit or 10), 100))
with _connect() as conn:
rows = conn.execute(
"""
@@ -734,10 +768,15 @@ def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any
ORDER BY id DESC
LIMIT ?
""",
(request_id, limit),
(request_id, min(bounded_limit * 20, 500)),
).fetchall()
results = []
previous_signature: tuple[str, Optional[str]] | None = None
for row in rows:
signature = (row[1], row[2])
if signature == previous_signature:
continue
previous_signature = signature
results.append(
{
"request_id": row[0],
@@ -747,6 +786,8 @@ def get_recent_snapshots(request_id: str, limit: int = 10) -> list[dict[str, Any
"payload": json.loads(row[4]),
}
)
if len(results) >= bounded_limit:
break
return results
@@ -777,6 +818,55 @@ def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]
return results
def get_request_download_evidence(request_id: str, limit: int = 100) -> Dict[str, Any]:
"""Return the most recent proof that this request reached qBittorrent.
A current qBittorrent API error is not proof that a download exists. Historical
snapshots are used so a torrent that has since been removed can still be
described honestly in the UI.
"""
with _connect() as conn:
rows = conn.execute(
"""
SELECT created_at, payload_json
FROM snapshots
WHERE request_id = ?
ORDER BY id DESC
LIMIT ?
""",
(request_id, max(1, min(int(limit or 100), 500))),
).fetchall()
for created_at, payload_json in rows:
try:
payload = json.loads(payload_json)
except (TypeError, ValueError):
continue
timeline = payload.get("timeline") if isinstance(payload, dict) else None
if not isinstance(timeline, list):
continue
for hop in timeline:
if not isinstance(hop, dict) or hop.get("service") != "qBittorrent":
continue
details = hop.get("details") if isinstance(hop.get("details"), dict) else {}
torrents = details.get("torrents")
if isinstance(torrents, list) and torrents:
return {
"observed": True,
"last_seen_at": created_at,
"state": hop.get("status"),
"summary": details.get("summary"),
"torrents": torrents,
}
return {
"observed": False,
"last_seen_at": None,
"state": None,
"summary": None,
"torrents": [],
}
def ensure_admin_user() -> None:
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
return
@@ -3429,6 +3519,23 @@ def update_portal_item(
return updated
def delete_portal_item(item_id: int) -> bool:
with _connect() as conn:
conn.execute(
"UPDATE portal_items SET related_item_id = NULL WHERE related_item_id = ?",
(item_id,),
)
conn.execute("DELETE FROM portal_comments WHERE item_id = ?", (item_id,))
conn.execute("DELETE FROM portal_item_activity WHERE item_id = ?", (item_id,))
deleted = conn.execute(
"DELETE FROM portal_items WHERE id = ?",
(item_id,),
).rowcount
if deleted:
logger.info("portal item deleted id=%s", item_id)
return bool(deleted)
def add_portal_comment(
item_id: int,
*,
@@ -3511,6 +3618,91 @@ def list_portal_comments(item_id: int, *, include_internal: bool = True, limit:
return [_portal_comment_from_row(row) for row in rows]
def add_portal_item_activity(
item_id: int,
*,
event_type: str,
actor_username: str,
actor_role: str,
message: str,
metadata_json: Optional[str] = None,
) -> Dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO portal_item_activity (
item_id,
event_type,
actor_username,
actor_role,
message,
metadata_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
item_id,
event_type,
actor_username,
actor_role,
message,
metadata_json,
now,
),
)
activity_id = cursor.lastrowid
row = conn.execute(
"""
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
FROM portal_item_activity
WHERE id = ?
""",
(activity_id,),
).fetchone()
if not row:
raise RuntimeError("Portal activity could not be loaded after insert.")
return {
"id": row[0],
"item_id": row[1],
"event_type": row[2],
"actor_username": row[3],
"actor_role": row[4],
"message": row[5],
"metadata_json": row[6],
"created_at": row[7],
}
def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[str, Any]]:
safe_limit = max(1, min(int(limit), 500))
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
FROM portal_item_activity
WHERE item_id = ?
ORDER BY created_at ASC, id ASC
LIMIT ?
""",
(item_id, safe_limit),
).fetchall()
return [
{
"id": row[0],
"item_id": row[1],
"event_type": row[2],
"actor_username": row[3],
"actor_role": row[4],
"message": row[5],
"metadata_json": row[6],
"created_at": row[7],
}
for row in rows
]
def get_portal_overview() -> Dict[str, Any]:
with _connect() as conn:
kind_rows = conn.execute(
+28
View File
@@ -25,7 +25,15 @@ from .routers.feedback import router as feedback_router
from .routers.site import router as site_router
from .routers.events import router as events_router
from .routers.portal import router as portal_router
from .routers.operations import router as operations_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop
from .services.operation_progress import (
begin_operation,
finish_operation,
normalize_operation_id,
reset_operation,
)
from .logging_config import (
bind_request_id,
configure_logging,
@@ -59,6 +67,14 @@ app.add_middleware(
async def log_requests_and_add_security_headers(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
token = bind_request_id(request_id)
operation_id = normalize_operation_id(request.headers.get("X-Magent-Operation-ID"))
operation_token = None
if operation_id and request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
operation_token = begin_operation(
operation_id,
label=request.headers.get("X-Magent-Operation-Label"),
path=request.url.path,
)
request.state.request_id = request_id
started_at = time.perf_counter()
body = await request.body()
@@ -101,6 +117,9 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
request.url.path,
duration_ms,
)
if operation_id and operation_token is not None:
finish_operation(operation_id, success=False, status_code=500)
reset_operation(operation_token)
reset_request_id(token)
raise
@@ -130,6 +149,13 @@ async def log_requests_and_add_security_headers(request: Request, call_next):
}
),
)
if operation_id and operation_token is not None:
finish_operation(
operation_id,
success=response.status_code < 400,
status_code=response.status_code,
)
reset_operation(operation_token)
reset_request_id(token)
return response
@@ -230,6 +256,7 @@ async def startup() -> None:
_launch_background_task("requests-delta-loop", run_requests_delta_loop)
_launch_background_task("requests-full-sync", run_daily_requests_full_sync)
_launch_background_task("db-cleanup", run_daily_db_cleanup)
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
logger.info("startup complete")
@@ -244,3 +271,4 @@ app.include_router(feedback_router)
app.include_router(site_router)
app.include_router(events_router)
app.include_router(portal_router)
app.include_router(operations_router)
+2
View File
@@ -35,6 +35,7 @@ class ActionOption(BaseModel):
id: str
label: str
risk: str
description: Optional[str] = None
requires_confirmation: bool = True
@@ -48,6 +49,7 @@ class Snapshot(BaseModel):
timeline: List[TimelineHop] = Field(default_factory=list)
actions: List[ActionOption] = Field(default_factory=list)
artwork: Dict[str, Any] = Field(default_factory=dict)
presentation: Dict[str, Any] = Field(default_factory=dict)
raw: Dict[str, Any] = Field(default_factory=dict)
+108 -3
View File
@@ -122,6 +122,15 @@ def _require_recipient_email(value: object) -> str:
detail="recipient_email is required and must be a valid email address",
)
def _optional_recipient_email(value: object) -> Optional[str]:
if value is None or (isinstance(value, str) and not value.strip()):
return None
normalized = normalize_delivery_email(value)
if normalized:
return normalized
raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
SENSITIVE_KEYS = {
"magent_ssl_certificate_pem",
"magent_ssl_private_key_pem",
@@ -135,6 +144,7 @@ SENSITIVE_KEYS = {
"jellyfin_api_key",
"sonarr_api_key",
"radarr_api_key",
"bazarr_api_key",
"prowlarr_api_key",
"qbittorrent_password",
}
@@ -150,6 +160,7 @@ URL_SETTING_KEYS = {
"jellyfin_public_url",
"sonarr_base_url",
"radarr_base_url",
"bazarr_base_url",
"prowlarr_base_url",
"qbittorrent_base_url",
}
@@ -216,6 +227,9 @@ SETTING_KEYS: List[str] = [
"radarr_quality_profile_id",
"radarr_root_folder",
"radarr_qbittorrent_category",
"bazarr_base_url",
"bazarr_api_key",
"bazarr_default_language",
"prowlarr_base_url",
"prowlarr_api_key",
"qbittorrent_base_url",
@@ -234,6 +248,9 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time",
"requests_cleanup_days",
"requests_data_source",
"issue_confirmation_contact_attempts",
"issue_confirmation_interval_value",
"issue_confirmation_interval_unit",
"site_banner_enabled",
"site_banner_message",
"site_banner_tone",
@@ -241,6 +258,7 @@ SETTING_KEYS: List[str] = [
"site_login_show_local_login",
"site_login_show_forgot_password",
"site_login_show_signup_link",
"site_nav_show_requests",
]
@@ -660,6 +678,26 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
changed_keys.append(key)
continue
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key == "issue_confirmation_contact_attempts":
try:
attempts = int(value_to_store)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Confirmation contacts must be a whole number from 0 to 10") from exc
if attempts < 0 or attempts > 10:
raise HTTPException(status_code=400, detail="Confirmation contacts must be from 0 to 10")
value_to_store = str(attempts)
if key == "issue_confirmation_interval_value":
try:
interval_value = int(value_to_store)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Confirmation interval must be a whole number") from exc
if interval_value < 1 or interval_value > 365:
raise HTTPException(status_code=400, detail="Confirmation interval must be from 1 to 365")
value_to_store = str(interval_value)
if key == "issue_confirmation_interval_unit":
value_to_store = value_to_store.lower()
if value_to_store not in {"days", "weeks", "months"}:
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
if key in URL_SETTING_KEYS and value_to_store:
try:
value_to_store = _normalize_service_url(value_to_store)
@@ -1320,6 +1358,35 @@ async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str,
return {"status": "ok", "username": username, "role": role}
@router.post("/users/{username}/email")
async def update_user_email(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
email = _optional_recipient_email(payload.get("email"))
if email:
duplicate = next(
(
candidate
for candidate in get_all_users()
if str(candidate.get("username") or "").casefold() != username.casefold()
and str(candidate.get("email") or "").strip().casefold() == email.casefold()
),
None,
)
if duplicate:
raise HTTPException(status_code=409, detail="That email address is already assigned to another user")
if not set_user_email(username, email):
raise HTTPException(status_code=404, detail="User not found")
refreshed = get_user_by_username(username)
logger.info("Admin updated user contact email: username=%s email_set=%s", username, bool(email))
return {"status": "ok", "user": refreshed, "email": email}
@router.post("/users/{username}/auto-search")
async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None
@@ -1666,9 +1733,34 @@ async def get_invites() -> Dict[str, Any]:
results = []
for invite in invites:
profile = profiles.get(invite.get("profile_id"))
if not invite.get("enabled"):
operational_state = "disabled"
state_label = "Disabled"
attention_reason = "This invite has been switched off."
elif invite.get("is_expired"):
operational_state = "expired"
state_label = "Expired"
attention_reason = "The invite has passed its expiry date."
elif invite.get("remaining_uses") == 0:
operational_state = "exhausted"
state_label = "Fully used"
attention_reason = "Every permitted sign-up has been used."
elif invite.get("profile_id") is not None and (
profile is None or profile.get("is_active") is False
):
operational_state = "profile_unavailable"
state_label = "Profile unavailable"
attention_reason = "The assigned profile is missing or disabled."
else:
operational_state = "ready"
state_label = "Ready to use"
attention_reason = None
results.append(
{
**invite,
"operational_state": operational_state,
"state_label": state_label,
"attention_reason": attention_reason,
"profile": (
{
"id": profile.get("id"),
@@ -1679,7 +1771,16 @@ async def get_invites() -> Dict[str, Any]:
),
}
)
return {"invites": results}
return {
"invites": results,
"summary": {
"total": len(results),
"ready": sum(1 for invite in results if invite["operational_state"] == "ready"),
"attention": sum(1 for invite in results if invite["operational_state"] != "ready"),
"used_signups": sum(int(invite.get("use_count") or 0) for invite in results),
"with_recipient": sum(1 for invite in results if invite.get("recipient_email")),
},
}
@router.get("/invites/policy")
@@ -1864,8 +1965,10 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
recipient_email = _require_recipient_email(payload.get("recipient_email"))
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
send_email = bool(payload.get("send_email"))
if send_email and not recipient_email:
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
delivery_message = _normalize_optional_text(payload.get("message"))
try:
invite = create_signup_invite(
@@ -1935,8 +2038,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
send_email = bool(payload.get("send_email"))
if send_email and not recipient_email:
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
delivery_message = _normalize_optional_text(payload.get("message"))
try:
invite = update_signup_invite(
-24
View File
@@ -11,7 +11,6 @@ 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"])
@@ -85,9 +84,7 @@ async def events_stream(
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:
@@ -129,27 +126,6 @@ async def events_stream(
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:
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..services.operation_progress import get_operation
router = APIRouter(
prefix="/operations",
tags=["operations"],
dependencies=[Depends(get_current_user)],
)
@router.get("/{operation_id}")
async def operation_status(operation_id: str) -> dict:
operation = get_operation(operation_id)
if not operation:
raise HTTPException(status_code=404, detail="Operation not found")
return operation
+459 -3
View File
@@ -1,23 +1,35 @@
from __future__ import annotations
import logging
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import get_current_user
from ..clients.jellyfin import JellyfinClient
from ..db import (
add_portal_item_activity,
add_portal_comment,
count_portal_items,
create_portal_item,
delete_portal_item,
get_portal_item,
get_portal_overview,
list_portal_comments,
list_portal_item_activity,
list_portal_items,
update_portal_item,
)
from ..services.issue_resolution import (
begin_issue_confirmation,
issue_resolution_state,
respond_to_issue_confirmation,
)
from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
logger = logging.getLogger(__name__)
@@ -33,6 +45,7 @@ PORTAL_STATUSES = {
"done",
"declined",
"closed",
"awaiting_confirmation",
# Seerr-style request pipeline statuses
"pending",
"approved",
@@ -55,6 +68,10 @@ PORTAL_MEDIA_STATUSES = {
PORTAL_ISSUE_TYPES = {
"general",
"playback",
"transcode",
"service_unavailable",
"broken_media",
"audio",
"subtitle",
"quality",
"metadata",
@@ -62,6 +79,9 @@ PORTAL_ISSUE_TYPES = {
"other",
}
_MEDIA_STATUS_CACHE: Dict[str, Any] = {"expires_at": 0.0, "payload": None}
_MEDIA_STATUS_CACHE_SECONDS = 15.0
REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = {
"pending": {"pending", "approved", "declined"},
"approved": {"approved", "declined"},
@@ -239,6 +259,97 @@ def _stage_label_for_workflow(request_status: str, media_status: str) -> str:
return "Approved"
ISSUE_WORKFLOW_STAGES = (
("reported", "Reported"),
("review", "Under review"),
("planned", "Fix planned"),
("repair", "Fix underway"),
("confirmation", "Confirm fix"),
("resolved", "Resolved"),
)
ISSUE_STATUS_TO_STAGE: Dict[str, Tuple[int, str, str, str]] = {
"new": (
0,
"Issue received",
"Your report has been logged and is waiting for the support team to review it.",
"active",
),
"triaging": (
1,
"Being investigated",
"The support team is checking the report and identifying the right fix.",
"active",
),
"planned": (
2,
"Fix ready to begin",
"The problem has been reviewed and the next action has been selected.",
"active",
),
"in_progress": (
3,
"Fix in progress",
"Work is underway on the affected content or service.",
"active",
),
"blocked": (
3,
"Fix needs attention",
"Work has paused because the support team needs another service, resource, or decision before continuing.",
"attention",
),
"awaiting_confirmation": (
4,
"Waiting for confirmation",
"A fix has been applied. Magent is waiting for the reporter to confirm that the problem is gone.",
"active",
),
"done": (
5,
"Issue resolved",
"The reported problem has been fixed and the issue is complete.",
"complete",
),
"closed": (
5,
"Issue resolved",
"The reported problem has been fixed and the issue is closed.",
"complete",
),
}
def _issue_workflow_payload(status: Any) -> Dict[str, Any]:
normalized_status = str(status or "new").strip().lower()
stage_index, headline, message, state = ISSUE_STATUS_TO_STAGE.get(
normalized_status,
ISSUE_STATUS_TO_STAGE["new"],
)
steps = []
for index, (key, label) in enumerate(ISSUE_WORKFLOW_STAGES):
step_state = (
"complete"
if index < stage_index or (index == stage_index and state == "complete")
else "active"
if index == stage_index
else "waiting"
)
if index == stage_index and state == "attention":
step_state = "attention"
steps.append({"key": key, "label": label, "state": step_state})
return {
"current_step": stage_index + 1,
"total_steps": len(ISSUE_WORKFLOW_STAGES),
"stage": ISSUE_WORKFLOW_STAGES[stage_index][0],
"stage_label": ISSUE_WORKFLOW_STAGES[stage_index][1],
"headline": headline,
"message": message,
"state": state,
"steps": steps,
}
def _normalize_request_pipeline(
request_status: Optional[str],
media_status: Optional[str],
@@ -339,6 +450,36 @@ def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool:
return str(user.get("username") or "") == str(item.get("created_by_username") or "")
def _public_media_status_payload(
*,
status: str,
headline: str,
message: str,
latency_ms: Optional[int] = None,
version: Optional[str] = None,
restart_pending: Optional[bool] = None,
active_streams: Optional[int] = None,
transcoding_streams: Optional[int] = None,
session_check_available: bool = False,
) -> Dict[str, Any]:
return {
"checked_at": datetime.now(timezone.utc).isoformat(),
"status": status,
"headline": headline,
"message": message,
"latency_ms": latency_ms,
"server": {
"version": version,
"restart_pending": restart_pending,
},
"activity": {
"active_streams": active_streams,
"transcoding_streams": transcoding_streams,
"available": session_check_available,
},
}
def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
is_admin = _is_admin(user)
is_owner = _is_owner(user, item)
@@ -347,7 +488,13 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
"can_edit": is_admin or is_owner,
"can_comment": True,
"can_moderate": is_admin,
"can_delete": is_admin and str(item.get("kind") or "").lower() == "issue",
"can_raise_issue": str(item.get("kind") or "") == "request",
"can_confirm_resolution": (
str(item.get("kind") or "").lower() == "issue"
and str(item.get("status") or "").lower() == "awaiting_confirmation"
and (is_admin or is_owner)
),
}
kind = str(item.get("kind") or "").strip().lower()
if kind == "request":
@@ -359,15 +506,85 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
"is_terminal": media_status in {"available", "failed"} or request_status == "declined",
}
elif kind == "issue":
resolution = issue_resolution_state(item)
serialized["issue"] = {
"issue_type": _clean_text(item.get("issue_type")) or "general",
"related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
"is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
"resolved_at": _clean_text(item.get("issue_resolved_at")),
"workflow": _issue_workflow_payload(item.get("status")),
"confirmation": {
"status": resolution.get("status"),
"attempts_sent": int(resolution.get("attemptsSent") or 0),
"maximum_attempts": int(resolution.get("maximumAttempts") or 0),
"last_contact_at": resolution.get("lastContactAt"),
"next_contact_at": resolution.get("nextContactAt"),
"interval_value": resolution.get("intervalValue"),
"interval_unit": resolution.get("intervalUnit"),
"last_delivery_succeeded": resolution.get("lastDeliverySucceeded"),
},
}
return serialized
def _activity_payload(item: Dict[str, Any], *, include_internal: bool = False) -> list[Dict[str, Any]]:
activity = [
entry
for entry in list_portal_item_activity(int(item["id"]), limit=300)
if include_internal or entry.get("event_type") != "internal_note_added"
]
if not any(entry.get("event_type") == "item_created" for entry in activity):
activity.insert(
0,
{
"id": f"created-{item['id']}",
"item_id": item["id"],
"event_type": "item_created",
"actor_username": item.get("created_by_username") or "unknown",
"actor_role": "user",
"message": (
"Issue raised and added to the support queue."
if str(item.get("kind") or "").lower() == "issue"
else "Portal item created."
),
"metadata_json": None,
"created_at": item.get("created_at"),
},
)
if include_internal:
return activity
public_activity: list[Dict[str, Any]] = []
for entry in activity:
public_entry = {key: value for key, value in entry.items() if key != "metadata_json"}
actor_role = str(entry.get("actor_role") or "user").lower()
public_entry["actor_username"] = (
"Magent"
if actor_role == "system"
else "Support team"
if actor_role == "admin"
else "Reporter"
)
public_entry["actor_role"] = "system" if actor_role == "system" else "support" if actor_role == "admin" else "user"
public_activity.append(public_entry)
return public_activity
def _record_activity(
item_id: int,
*,
event_type: str,
message: str,
user: Dict[str, Any],
) -> None:
add_portal_item_activity(
item_id,
event_type=event_type,
actor_username=str(user.get("username") or "unknown"),
actor_role=str(user.get("role") or "user"),
message=message,
)
async def _notify(
*,
event_type: str,
@@ -406,6 +623,122 @@ async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_use
}
@router.get("/issues/media-status")
async def portal_media_status() -> Dict[str, Any]:
"""Return a short, privacy-safe Jellyfin health check for guided issue reporting."""
now = time.monotonic()
cached_payload = _MEDIA_STATUS_CACHE.get("payload")
if isinstance(cached_payload, dict) and now < float(_MEDIA_STATUS_CACHE.get("expires_at") or 0):
return cached_payload
runtime = get_runtime_settings()
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not jellyfin.configured():
payload = _public_media_status_payload(
status="not_configured",
headline="Media server status is unavailable",
message="Magent cannot run a playback check right now. Your report can still be submitted.",
)
_MEDIA_STATUS_CACHE.update(
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
payload=payload,
)
return payload
started_at = time.perf_counter()
try:
system_info = await jellyfin.get_system_info()
except (httpx.HTTPError, RuntimeError, ValueError):
latency_ms = round((time.perf_counter() - started_at) * 1000)
payload = _public_media_status_payload(
status="down",
headline="The media server is not responding",
message=(
"This looks broader than one title. The report will include the failed server check "
"so an administrator can investigate the service first."
),
latency_ms=latency_ms,
)
_MEDIA_STATUS_CACHE.update(
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
payload=payload,
)
return payload
except Exception:
logger.exception("guided issue Jellyfin system check failed")
latency_ms = round((time.perf_counter() - started_at) * 1000)
payload = _public_media_status_payload(
status="down",
headline="The media server check failed",
message="Your report can still be submitted and will include this failed service check.",
latency_ms=latency_ms,
)
_MEDIA_STATUS_CACHE.update(
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
payload=payload,
)
return payload
latency_ms = round((time.perf_counter() - started_at) * 1000)
info = system_info if isinstance(system_info, dict) else {}
version_value = info.get("Version")
version = str(version_value).strip() if version_value is not None else None
restart_pending = bool(info.get("HasPendingRestart"))
session_check_available = False
active_streams: Optional[int] = None
transcoding_streams: Optional[int] = None
try:
sessions = await jellyfin.get_sessions()
if isinstance(sessions, list):
session_check_available = True
active_streams = sum(
1 for session in sessions if isinstance(session, dict) and session.get("NowPlayingItem")
)
transcoding_streams = sum(
1
for session in sessions
if isinstance(session, dict)
and session.get("NowPlayingItem")
and session.get("TranscodingInfo")
)
except Exception:
logger.warning("guided issue Jellyfin session check unavailable", exc_info=True)
if restart_pending:
status = "degraded"
headline = "Media server is online but needs attention"
message = "Jellyfin is responding, but it reports that a restart is pending."
elif session_check_available and active_streams:
status = "up"
headline = "Media server is online and actively streaming"
message = (
"Other playback is currently working, so this is more likely specific to the title, "
"audio track, subtitle, client, or transcode path."
)
else:
status = "up"
headline = "Media server is online"
message = "Jellyfin responded normally. Continue with the report if playback is still failing."
payload = _public_media_status_payload(
status=status,
headline=headline,
message=message,
latency_ms=latency_ms,
version=version,
restart_pending=restart_pending,
active_streams=active_streams,
transcoding_streams=transcoding_streams,
session_check_available=session_check_available,
)
_MEDIA_STATUS_CACHE.update(
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
payload=payload,
)
return payload
@router.get("/items")
async def portal_list_items(
kind: Optional[str] = None,
@@ -621,6 +954,16 @@ async def portal_create_item(
priority=priority or "normal",
assignee_username=assignee_username,
)
_record_activity(
int(created["id"]),
event_type="item_created",
message=(
"Issue raised and added to the support queue."
if created.get("kind") == "issue"
else f"{str(created.get('kind') or 'Portal item').capitalize()} created."
),
user=current_user,
)
initial_comment = _clean_text(payload.get("comment"))
if initial_comment:
add_portal_comment(
@@ -640,6 +983,7 @@ async def portal_create_item(
return {
"item": _serialize_item(created, current_user),
"comments": comments,
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
}
@@ -692,6 +1036,12 @@ async def portal_create_issue_for_request(
priority=priority or "normal",
assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None,
)
_record_activity(
int(created["id"]),
event_type="item_created",
message=f"Issue raised and linked to collection request #{item_id}.",
user=current_user,
)
initial_comment = _clean_text(payload.get("comment"))
if initial_comment:
add_portal_comment(
@@ -711,6 +1061,7 @@ async def portal_create_issue_for_request(
return {
"item": _serialize_item(created, current_user),
"comments": comments,
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
"linked_request_id": item_id,
}
@@ -823,9 +1174,33 @@ async def portal_get_item(
return {
"item": _serialize_item(item, current_user),
"comments": comments,
"activity": _activity_payload(item, include_internal=_is_admin(current_user)),
}
@router.delete("/items/{item_id}")
async def portal_delete_item(
item_id: int,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
if not _is_admin(current_user):
raise HTTPException(status_code=403, detail="Admin access required")
item = get_portal_item(item_id)
if not item:
raise HTTPException(status_code=404, detail="Portal item not found")
if str(item.get("kind") or "").lower() != "issue":
raise HTTPException(status_code=400, detail="Only issues can be deleted here")
if not delete_portal_item(item_id):
raise HTTPException(status_code=404, detail="Issue not found")
logger.info(
"portal issue deleted id=%s title=%s actor=%s",
item_id,
item.get("title"),
current_user.get("username"),
)
return {"status": "deleted", "item_id": item_id}
@router.patch("/items/{item_id}")
async def portal_update_item(
item_id: int,
@@ -837,6 +1212,7 @@ async def portal_update_item(
raise HTTPException(status_code=404, detail="Portal item not found")
is_admin = _is_admin(current_user)
is_owner = _is_owner(current_user, item)
item_kind = str(item.get("kind") or "").lower()
if not (is_admin or is_owner):
raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item")
@@ -886,7 +1262,7 @@ async def portal_update_item(
if "external_ref" in payload:
updates["external_ref"] = _clean_text(payload.get("external_ref"))
if is_admin:
kind = str(item.get("kind") or "").lower()
kind = item_kind
if "priority" in payload:
updates["priority"] = _normalize_choice(
payload.get("priority"),
@@ -976,9 +1352,9 @@ async def portal_update_item(
updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at"))
if "status" in payload:
next_status = str(updates.get("status") or item.get("status") or "").lower()
if next_status in {"done", "closed"}:
if next_status == "closed":
updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat())
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked"}:
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked", "done", "awaiting_confirmation"}:
updates.setdefault("issue_resolved_at", None)
if not updates:
@@ -986,14 +1362,47 @@ async def portal_update_item(
return {
"item": _serialize_item(item, current_user),
"comments": comments,
"activity": _activity_payload(item, include_internal=is_admin),
}
updated = update_portal_item(item_id, **updates)
if not updated:
raise HTTPException(status_code=404, detail="Portal item not found")
requested_issue_status = str(updates.get("status") or "").lower()
should_start_confirmation = item_kind == "issue" and (
(requested_issue_status == "done" and str(item.get("status") or "").lower() != "done")
or (
requested_issue_status == "awaiting_confirmation"
and str(item.get("status") or "").lower() != "awaiting_confirmation"
)
)
if should_start_confirmation:
try:
updated = await begin_issue_confirmation(
item_id,
actor_username=str(current_user.get("username") or "unknown"),
actor_role=str(current_user.get("role") or "admin"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)]
if changed_fields:
if item_kind == "issue" and not should_start_confirmation:
old_status = str(item.get("status") or "unknown").replace("_", " ")
new_status = str(updated.get("status") or "unknown").replace("_", " ")
activity_message = (
f"Status changed from {old_status} to {new_status}."
if item.get("status") != updated.get("status")
else f"Issue details updated: {', '.join(sorted(changed_fields))}."
)
_record_activity(
item_id,
event_type="status_changed" if item.get("status") != updated.get("status") else "issue_updated",
message=activity_message,
user=current_user,
)
await _notify(
event_type="portal_item_updated",
item=updated,
@@ -1004,6 +1413,42 @@ async def portal_update_item(
return {
"item": _serialize_item(updated, current_user),
"comments": comments,
"activity": _activity_payload(updated, include_internal=is_admin),
}
@router.post("/issues/{item_id}/resolution-response")
async def portal_issue_resolution_response(
item_id: int,
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise HTTPException(status_code=404, detail="Issue not found")
if not (_is_admin(current_user) or _is_owner(current_user, item)):
raise HTTPException(status_code=403, detail="Only the reporter or an admin can confirm this resolution")
if not isinstance(payload.get("resolved"), bool):
raise HTTPException(status_code=400, detail="resolved must be true or false")
try:
updated = respond_to_issue_confirmation(
item_id,
resolved=payload["resolved"],
actor_username=str(current_user.get("username") or "unknown"),
actor_role=str(current_user.get("role") or "user"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await _notify(
event_type="portal_issue_resolution_confirmed" if payload["resolved"] else "portal_issue_resolution_rejected",
item=updated,
user=current_user,
note="resolved=true" if payload["resolved"] else "resolved=false",
)
return {
"item": _serialize_item(updated, current_user),
"comments": list_portal_comments(item_id, include_internal=_is_admin(current_user)),
"activity": _activity_payload(updated, include_internal=_is_admin(current_user)),
}
@@ -1045,6 +1490,17 @@ async def portal_create_comment(
message=message,
is_internal=is_internal,
)
if str(item.get("kind") or "").lower() == "issue":
_record_activity(
item_id,
event_type="internal_note_added" if is_internal else "comment_added",
message=(
f"Internal troubleshooting note: {message[:240]}"
if is_internal
else f"Support update: {message[:240]}"
),
user=current_user,
)
updated_item = get_portal_item(item_id)
if updated_item:
await _notify(
File diff suppressed because it is too large Load Diff
+3
View File
@@ -30,6 +30,9 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
"showSignupLink": bool(runtime.site_login_show_signup_link),
},
"navigation": {
"showRequests": bool(runtime.site_nav_show_requests),
},
}
if include_changelog:
info["changelog"] = (CHANGELOG or "").strip()
+17 -2
View File
@@ -2,16 +2,17 @@ from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..auth import require_admin
from ..runtime import get_runtime_settings
from ..clients.jellyseerr import JellyseerrClient
from ..clients.sonarr import SonarrClient
from ..clients.radarr import RadarrClient
from ..clients.bazarr import BazarrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)])
router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)])
async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
@@ -61,6 +62,7 @@ async def services_status() -> Dict[str, Any]:
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
@@ -89,6 +91,13 @@ async def services_status() -> Dict[str, Any]:
radarr.get_system_status,
)
)
services.append(
await _check(
"Bazarr",
bazarr.configured() and bool(runtime.bazarr_api_key),
bazarr.get_system_status,
)
)
prowlarr_status = await _check(
"Prowlarr",
prowlarr.configured(),
@@ -124,6 +133,7 @@ async def test_service(service: str) -> Dict[str, Any]:
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
bazarr = BazarrClient(runtime.bazarr_base_url, runtime.bazarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
@@ -144,6 +154,11 @@ async def test_service(service: str) -> Dict[str, Any]:
),
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
"bazarr": (
"Bazarr",
bazarr.configured() and bool(runtime.bazarr_api_key),
bazarr.get_system_status,
),
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
}
+3
View File
@@ -19,6 +19,8 @@ _INT_FIELDS = {
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
"requests_cleanup_days",
"issue_confirmation_contact_attempts",
"issue_confirmation_interval_value",
"magent_notify_email_smtp_port",
}
_BOOL_FIELDS = {
@@ -39,6 +41,7 @@ _BOOL_FIELDS = {
"site_login_show_local_login",
"site_login_show_forgot_password",
"site_login_show_signup_link",
"site_nav_show_requests",
}
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
+378
View File
@@ -0,0 +1,378 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from html import escape
import json
import logging
from typing import Any, Dict, Optional
from ..config import settings as env_settings
from ..db import (
add_portal_item_activity,
get_portal_item,
get_user_by_username,
list_portal_items,
update_portal_item,
)
from ..runtime import get_runtime_settings
from .invite_email import resolve_user_delivery_email, send_generic_email
logger = logging.getLogger(__name__)
_SYSTEM_USER = "Magent"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _parse_datetime(value: Any) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
raw = item.get("metadata_json")
if not isinstance(raw, str) or not raw.strip():
return {}
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
state = _metadata(item).get("resolutionConfirmation")
return dict(state) if isinstance(state, dict) else {}
def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
metadata = _metadata(item)
metadata["resolutionConfirmation"] = state
return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
def _interval_delta(value: int, unit: str) -> timedelta:
safe_value = max(1, min(int(value), 365))
normalized_unit = str(unit or "days").strip().lower()
if normalized_unit == "weeks":
return timedelta(weeks=safe_value)
if normalized_unit == "months":
return timedelta(days=30 * safe_value)
return timedelta(days=safe_value)
def _workflow_settings() -> tuple[int, int, str]:
runtime = get_runtime_settings()
attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
if interval_unit not in {"days", "weeks", "months"}:
interval_unit = "days"
return attempts, interval_value, interval_unit
def _app_url() -> str:
runtime = get_runtime_settings()
for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
candidate = str(value or "").strip()
if candidate:
return candidate.rstrip("/")
return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
def _issue_url(item_id: int) -> str:
return f"{_app_url()}/portal/issues?item={item_id}"
def _activity(
item_id: int,
event_type: str,
message: str,
*,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
add_portal_item_activity(
item_id,
event_type=event_type,
actor_username=actor_username,
actor_role=actor_role,
message=message,
metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
)
def _close_issue(
item: Dict[str, Any],
*,
reason: str,
confirmed: bool,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
) -> Dict[str, Any]:
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "confirmed" if confirmed else "auto_closed",
"confirmedAt": now if confirmed else state.get("confirmedAt"),
"closedAt": now,
"nextContactAt": None,
"closedReason": reason,
}
)
updated = update_portal_item(
int(item["id"]),
status="closed",
issue_resolved_at=now,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be closed")
_activity(
int(item["id"]),
"resolution_confirmed" if confirmed else "issue_auto_closed",
reason,
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
maximum, interval_value, interval_unit = _workflow_settings()
state = issue_resolution_state(item)
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0:
return _close_issue(
item,
reason="Issue closed automatically because reporter confirmation emails are disabled.",
confirmed=False,
)
if attempts >= maximum:
return _close_issue(
item,
reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.",
confirmed=False,
)
attempt_number = attempts + 1
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
recipient = resolve_user_delivery_email(reporter)
issue_url = _issue_url(int(item["id"]))
sent = False
delivery_error: Optional[str] = None
if recipient:
subject = f"Is your issue fixed? #{item['id']} {item.get('title') or ''}".strip()
body_text = (
f"We have marked issue #{item['id']} as fixed and need your confirmation.\n\n"
f"Issue: {item.get('title') or 'Untitled issue'}\n"
f"Confirmation request: {attempt_number} of {maximum}\n\n"
f"Open the issue and choose whether it is fixed or still happening:\n{issue_url}\n\n"
"If you do not respond, Magent will close the issue automatically after the configured confirmation period."
)
body_html = (
'<div style="font-family:Segoe UI,Arial,sans-serif;color:#132033;">'
'<h2 style="margin:0 0 12px;">Is your issue fixed?</h2>'
f'<p style="line-height:1.6;">We have marked issue <strong>#{int(item["id"])}</strong> as fixed and need your confirmation.</p>'
f'<p style="line-height:1.6;"><strong>{escape(str(item.get("title") or "Untitled issue"))}</strong><br>'
f'Confirmation request {attempt_number} of {maximum}</p>'
f'<a href="{escape(issue_url)}" style="display:inline-block;padding:11px 18px;border-radius:8px;background:#1c6bff;color:#fff;text-decoration:none;font-weight:700;">Confirm the outcome</a>'
'<p style="margin-top:18px;color:#64748b;line-height:1.6;">If you do not respond, Magent will close the issue automatically after the configured confirmation period.</p>'
'</div>'
)
try:
await send_generic_email(
recipient_email=recipient,
subject=subject,
body_text=body_text,
body_html=body_html,
)
sent = True
except Exception as exc:
delivery_error = str(exc)
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
else:
delivery_error = "No email address is stored for the reporter."
now = _now()
state.update(
{
"status": "awaiting_confirmation",
"attemptsSent": attempt_number,
"maximumAttempts": maximum,
"lastContactAt": now.isoformat(),
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"lastDeliverySucceeded": sent,
"lastDeliveryError": delivery_error,
}
)
updated = update_portal_item(
int(item["id"]),
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation schedule could not be saved")
if sent:
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
else:
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
_activity(
int(item["id"]),
"confirmation_email_sent" if sent else "confirmation_email_failed",
message,
metadata={
"attempt": attempt_number,
"maximum": maximum,
"nextContactAt": state["nextContactAt"],
"deliveryError": delivery_error,
},
)
return updated
async def begin_issue_confirmation(
item_id: int,
*,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
now = _now().isoformat()
maximum, interval_value, interval_unit = _workflow_settings()
state = {
"status": "awaiting_confirmation",
"startedAt": now,
"attemptsSent": 0,
"maximumAttempts": maximum,
"lastContactAt": None,
"nextContactAt": now,
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"confirmedAt": None,
"closedAt": None,
}
updated = update_portal_item(
item_id,
status="awaiting_confirmation",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation workflow could not be started")
_activity(
item_id,
"resolution_proposed",
"The issue was marked fixed and sent to the reporter for confirmation.",
actor_username=actor_username,
actor_role=actor_role,
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
)
return await _contact_reporter(updated)
def respond_to_issue_confirmation(
item_id: int,
*,
resolved: bool,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
if str(item.get("status") or "").lower() != "awaiting_confirmation":
raise ValueError("This issue is not waiting for resolution confirmation")
if resolved:
return _close_issue(
item,
reason="The reporter confirmed that the issue is fixed.",
confirmed=True,
actor_username=actor_username,
actor_role=actor_role,
)
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "reported_still_broken",
"reporterResponseAt": now,
"nextContactAt": None,
"closedAt": None,
}
)
updated = update_portal_item(
item_id,
status="in_progress",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be reopened")
_activity(
item_id,
"resolution_rejected",
"The reporter said the issue is still happening. The issue was returned to In progress.",
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
current = (now or _now()).astimezone(timezone.utc)
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
maximum, _, _ = _workflow_settings()
for item in items:
state = issue_resolution_state(item)
due_at = _parse_datetime(state.get("nextContactAt"))
if due_at and due_at > current:
continue
try:
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0 or attempts >= maximum:
_close_issue(
item,
reason=(
"Issue closed automatically because reporter confirmation emails are disabled."
if maximum <= 0
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
),
confirmed=False,
)
result["closed"] += 1
else:
await _contact_reporter(item)
result["contacted"] += 1
except Exception:
result["failed"] += 1
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
return result
async def run_issue_confirmation_loop() -> None:
while True:
try:
result = await process_due_issue_confirmations()
if result["contacted"] or result["closed"] or result["failed"]:
logger.info("issue confirmation sweep complete result=%s", result)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("issue confirmation sweep failed")
await asyncio.sleep(15 * 60)
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from copy import deepcopy
from datetime import datetime, timezone
import re
import threading
import time
import uuid
from typing import Any, Dict, Optional
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
_OPERATION_TTL_SECONDS = 15 * 60
_MAX_OPERATIONS = 500
_MAX_EVENTS = 60
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
"magent_operation_id", default=None
)
_operations: Dict[str, Dict[str, Any]] = {}
_lock = threading.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
def _prune_locked(now_monotonic: float) -> None:
expired = [
operation_id
for operation_id, operation in _operations.items()
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
]
for operation_id in expired:
_operations.pop(operation_id, None)
if len(_operations) <= _MAX_OPERATIONS:
return
oldest = sorted(
_operations,
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
)
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
_operations.pop(operation_id, None)
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
now_monotonic = time.monotonic()
now_iso = _now_iso()
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
with _lock:
_prune_locked(now_monotonic)
_operations[operation_id] = {
"id": operation_id,
"label": normalized_label,
"path": path,
"status": "running",
"started_at": now_iso,
"updated_at": now_iso,
"updated_monotonic": now_monotonic,
"duration_ms": None,
"events": [
{
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete",
"message": "Magent received the action.",
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
"status_code": None,
}
],
}
return _current_operation_id.set(operation_id)
def reset_operation(token: Token) -> None:
_current_operation_id.reset(token)
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
operation_id = _current_operation_id.get()
if not operation_id:
return None
event_id = uuid.uuid4().hex
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return None
operation["events"].append(
{
"id": event_id,
"service": service,
"state": "active",
"message": message or f"Contacting {service}",
"started_at": now_iso,
"finished_at": None,
"duration_ms": None,
"status_code": None,
"started_monotonic": now_monotonic,
}
)
operation["events"] = operation["events"][-_MAX_EVENTS:]
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
return event_id
def finish_remote_call(
event_id: Optional[str],
*,
success: bool,
status_code: Optional[int] = None,
message: Optional[str] = None,
) -> None:
operation_id = _current_operation_id.get()
if not operation_id or not event_id:
return
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return
event = next(
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
None,
)
if not event:
return
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
event["state"] = "complete" if success else "error"
event["finished_at"] = now_iso
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
event["status_code"] = status_code
event["message"] = message or (
f"{event['service']} responded successfully."
if success
else f"{event['service']} returned an error."
)
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return
for event in operation["events"]:
if event.get("state") == "active":
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
event["state"] = "error"
event["finished_at"] = now_iso
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
started = datetime.fromisoformat(str(operation["started_at"]))
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
operation["status"] = "complete" if success else "error"
operation["status_code"] = status_code
operation["duration_ms"] = round(duration_ms, 1)
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
operation["events"].append(
{
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete" if success else "error",
"message": (
"Magent finished processing the action."
if success
else "Magent could not complete the action."
),
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
"status_code": status_code,
}
)
operation["events"] = operation["events"][-_MAX_EVENTS:]
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
normalized = normalize_operation_id(operation_id)
if not normalized:
return None
with _lock:
operation = _operations.get(normalized)
if not operation:
return None
result = deepcopy(operation)
result.pop("updated_monotonic", None)
for event in result.get("events", []):
event.pop("started_monotonic", None)
return result
+735 -29
View File
@@ -15,8 +15,10 @@ from ..clients.qbittorrent import QBittorrentClient
from ..runtime import get_runtime_settings
from ..db import (
save_snapshot,
get_recent_actions,
get_request_cache_payload,
get_request_cache_by_id,
get_request_download_evidence,
get_recent_snapshots,
get_setting,
set_setting,
@@ -30,6 +32,8 @@ logger = logging.getLogger(__name__)
JELLYFIN_SCAN_COOLDOWN_SECONDS = 300
_jellyfin_scan_key = "jellyfin_scan_last_at"
REPAIR_ACTIVITY_MAX_AGE = 7 * 24 * 60 * 60
REPAIR_ACTION_IDS = {"replace_media", "search_missing", "repair_subtitles"}
STATUS_LABELS = {
@@ -58,6 +62,22 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
return None
def _apply_arr_identity(snapshot: Snapshot, arr_item: Any) -> None:
"""Use the collector's authoritative identity when cached Seerr metadata is sparse."""
if not isinstance(arr_item, dict):
return
if snapshot.title in {None, "", "Unknown"}:
title = arr_item.get("title") or arr_item.get("seriesTitle")
if isinstance(title, str) and title.strip():
snapshot.title = title.strip()
if not snapshot.year:
year = arr_item.get("year")
try:
snapshot.year = int(year) if year else snapshot.year
except (TypeError, ValueError):
pass
def _normalize_media_title(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
@@ -206,7 +226,20 @@ async def _get_seerr_media_details(
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
collector_item = snapshot.raw.get("arr", {}).get("item") if isinstance(snapshot.raw, dict) else None
collector_stats = collector_item.get("statistics") if isinstance(collector_item, dict) else None
collector_has_file = bool(
isinstance(collector_item, dict)
and (
collector_item.get("hasFile")
or snapshot.request_type == RequestType.tv
and isinstance(collector_stats, dict)
and collector_stats.get("episodeFileCount")
)
)
if snapshot.state not in {NormalizedState.available, NormalizedState.completed} and not (
snapshot.state == NormalizedState.importing and collector_has_file
):
return
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
@@ -222,8 +255,9 @@ async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
pass
previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1)
if previous:
prev_state = previous[0].get("state")
if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}:
previous_payload = previous[0].get("payload") or {}
previous_jellyfin = (previous_payload.get("raw") or {}).get("jellyfin") or {}
if previous_jellyfin.get("found"):
return
try:
await client.refresh_library()
@@ -300,6 +334,43 @@ def _missing_episode_numbers_by_season(episodes: Any) -> Dict[int, List[int]]:
return grouped
def _episode_availability(episodes: Any) -> Dict[str, Any]:
if not isinstance(episodes, list):
return {"available": 0, "missing": 0, "total": 0, "seasons": []}
now = datetime.now(timezone.utc)
season_rows: Dict[int, Dict[str, Any]] = {}
for episode in episodes:
if not isinstance(episode, dict) or not episode.get("monitored", True):
continue
air_date = episode.get("airDateUtc")
if isinstance(air_date, str):
try:
aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00"))
except ValueError:
aired_at = None
if aired_at and aired_at > now:
continue
season_number = episode.get("seasonNumber")
if not isinstance(season_number, int):
continue
row = season_rows.setdefault(
season_number,
{"seasonNumber": season_number, "available": 0, "missing": 0, "total": 0},
)
row["total"] += 1
if episode.get("hasFile"):
row["available"] += 1
else:
row["missing"] += 1
seasons = [season_rows[key] for key in sorted(season_rows)]
return {
"available": sum(int(row["available"]) for row in seasons),
"missing": sum(int(row["missing"]) for row in seasons),
"total": sum(int(row["total"]) for row in seasons),
"seasons": seasons,
}
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
if not torrents:
return {"state": "idle", "message": "0 active downloads."}
@@ -344,6 +415,546 @@ def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[st
return f"https://image.tmdb.org/t/p/{size}{path}"
def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]:
progress = torrent.get("progress")
try:
numeric = float(progress)
except (TypeError, ValueError):
numeric = -1
if 0 <= numeric <= 1:
return round(numeric * 100, 1)
try:
size = float(torrent.get("size"))
amount_left = float(torrent.get("amount_left"))
except (TypeError, ValueError):
return None
if size <= 0:
return None
return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1)))
def _parse_action_time(value: Any) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _latest_repair_action(request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]:
current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
for action in get_recent_actions(request_id, 25):
if action.get("action_id") not in REPAIR_ACTION_IDS:
continue
created_at = _parse_action_time(action.get("created_at"))
if created_at is None:
continue
age_seconds = (current_time - created_at).total_seconds()
if 0 <= age_seconds <= REPAIR_ACTIVITY_MAX_AGE:
return action
return None
def _build_repair_activity(
snapshot: Snapshot,
*,
action: Optional[Dict[str, Any]],
arr_state: str,
arr_details: Dict[str, Any],
download: Dict[str, Any],
jellyfin_found: bool,
) -> Optional[Dict[str, Any]]:
if not action:
return None
action_id = str(action.get("action_id") or "")
collector = (
"Bazarr"
if action_id == "repair_subtitles"
else ("Sonarr" if snapshot.request_type == RequestType.tv else "Radarr")
)
action_ok = str(action.get("status") or "").lower() == "ok"
action_message = str(action.get("message") or "The repair action was recorded.")
download_state = str(download.get("state") or "not_started")
download_visible = bool(download.get("visible"))
availability = arr_details.get("availability")
if not isinstance(availability, dict):
availability = {}
missing = int(availability.get("missing") or 0)
total = int(availability.get("total") or 0)
collection_complete = arr_state == "available" and (
snapshot.request_type == RequestType.movie or (total > 0 and missing == 0)
)
submitted_step = {
"id": "submitted",
"label": "Repair requested",
"state": "complete",
"detail": "Magent recorded the issue and started the selected repair.",
}
if not action_ok:
return {
"visible": True,
"actionId": action_id,
"state": "attention",
"headline": "Repair needs attention",
"message": action_message,
"service": collector,
"updatedAt": action.get("created_at"),
"steps": [
submitted_step,
{
"id": "collector",
"label": f"{collector} hand-off",
"state": "attention",
"detail": action_message,
},
],
}
if action_id == "repair_subtitles":
return {
"visible": True,
"actionId": action_id,
"state": "searching",
"headline": "Subtitle repair is running",
"message": (
f"{action_message} Bazarr is checking the configured subtitle providers; "
"the issue can be confirmed once the replacement track is available."
),
"service": collector,
"updatedAt": action.get("created_at"),
"steps": [
submitted_step,
{
"id": "collector",
"label": "Bazarr accepted the search",
"state": "complete",
"detail": action_message,
},
{
"id": "result",
"label": "Subtitle result",
"state": "active",
"detail": "Waiting for Bazarr to find and apply a suitable subtitle track.",
},
],
}
if collection_complete:
headline = "Repair collected"
message = (
f"{collector} now reports the replacement file as collected. "
+ (
"It is also available in Grizzlyflix."
if jellyfin_found
else "Grizzlyflix is indexing the updated file now."
)
)
state = "complete" if jellyfin_found else "indexing"
download_step_state = "complete"
download_step_detail = f"{collector} reports the replacement file as collected and imported."
available_step_state = "complete" if jellyfin_found else "active"
elif download_visible and download_state in {"downloading", "paused", "completed", "error", "missing"}:
state = {
"downloading": "downloading",
"completed": "importing",
"paused": "attention",
"error": "attention",
"missing": "attention",
}[download_state]
headline = {
"downloading": "Replacement download in progress",
"completed": "Replacement downloaded — waiting for import",
"paused": "Replacement download paused",
"error": "Replacement download cannot be checked",
"missing": "Replacement hand-off needs checking",
}[download_state]
message = {
"downloading": "The replacement is downloading now.",
"paused": "The replacement download is paused and needs attention.",
"completed": f"The download has finished and is waiting for {collector} to import it.",
"error": "Magent cannot currently read the replacement download from qBittorrent.",
"missing": "The collector reported a download, but it is not currently visible in qBittorrent.",
}[download_state]
download_step_state = "active" if download_state == "downloading" else (
"complete" if download_state == "completed" else "attention"
)
download_step_detail = str(
download.get("summary") or "Magent found the replacement download in qBittorrent."
)
available_step_state = "waiting"
else:
state = "searching"
headline = "Replacement search in progress"
message = (
f"{action_message} {collector} has accepted the search, but no replacement download "
"has been selected yet. Magent will keep checking."
)
download_step_state = "waiting"
download_step_detail = "Waiting for a suitable release to be selected."
available_step_state = "waiting"
return {
"visible": True,
"actionId": action_id,
"state": state,
"headline": headline,
"message": message,
"service": collector,
"updatedAt": action.get("created_at"),
"steps": [
submitted_step,
{
"id": "collector",
"label": f"{collector} accepted the search",
"state": "complete",
"detail": action_message,
},
{
"id": "download",
"label": "Replacement download",
"state": download_step_state,
"detail": download_step_detail,
},
{
"id": "available",
"label": "Updated media available",
"state": available_step_state,
"detail": (
"The repaired title is available in Grizzlyflix."
if jellyfin_found and collection_complete
else (
"The media server is indexing the replacement."
if collection_complete
else "Waiting for download and import to finish."
)
),
},
],
}
def _build_presentation(
snapshot: Snapshot,
*,
approved: bool,
arr_state: str,
arr_details: Dict[str, Any],
prowlarr_state: str,
download: Dict[str, Any],
jellyfin_found: bool,
jellyfin_link: Optional[str],
) -> Dict[str, Any]:
collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr"
noun = "episode" if snapshot.request_type == RequestType.tv else "movie"
availability = arr_details.get("availability")
if not isinstance(availability, dict):
availability = {"available": 0, "missing": 0, "total": 0, "seasons": []}
available = int(availability.get("available") or 0)
missing = int(availability.get("missing") or 0)
total = int(availability.get("total") or 0)
partial = available > 0 and missing > 0
jellyfin_partial = bool(
jellyfin_found and snapshot.request_type == RequestType.tv and missing > 0
)
fully_available = bool(jellyfin_found and not jellyfin_partial)
download_visible = bool(download.get("visible"))
download_state = str(download.get("state") or "not_started")
if snapshot.state == NormalizedState.requested:
status_label = "Waiting for approval"
meaning = "This request has been received, but it must be approved before collection can begin."
elif snapshot.state == NormalizedState.needs_add:
status_label = "Approved, but not yet in the library queue"
meaning = (
f"The request was approved, but it has not reached the {collector} collector yet. "
"Adding it to the library queue is the next step."
)
elif jellyfin_partial:
status_label = f"Partially available — {available} of {total} episodes collected"
meaning = (
f"Some of this request is ready to watch. {collector} is still looking for "
f"{missing} missing episode{'s' if missing != 1 else ''}."
)
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
status_label = "Available to watch"
meaning = "Collection is complete and the title is available on the media server."
elif download_visible and download_state == "paused":
status_label = "Download paused"
meaning = "A release was collected, but its qBittorrent download is paused and needs to be resumed."
elif download_visible and download_state == "missing":
status_label = "Download attempt is no longer visible"
meaning = (
"A download was previously queued for this request, but qBittorrent no longer reports it. "
"A fresh release search may be required."
)
elif download_visible and download_state == "error":
status_label = "Unable to read the current download"
meaning = (
"A download attempt exists, but Magent cannot currently read its progress from qBittorrent."
)
elif snapshot.state == NormalizedState.downloading:
status_label = "Download in progress"
meaning = "A release has been collected and is currently downloading."
elif snapshot.state == NormalizedState.importing:
if arr_state == "available" and not jellyfin_found:
status_label = "Collected — waiting for the media server"
meaning = (
f"{collector} has collected and imported this title, but it is not visible on "
"the media server yet."
)
else:
status_label = "Downloaded — waiting for library import"
meaning = f"The download has finished and {collector} is preparing it for the media server."
elif arr_state == "error":
status_label = "Unable to read the library queue"
meaning = (
f"The request is approved, but Magent could not read its current state from {collector}. "
"The service may be temporarily unavailable."
)
elif arr_state in {"added", "searching"} and snapshot.request_type == RequestType.tv and total:
if partial:
status_label = f"Partially collected — {missing} episode{'s' if missing != 1 else ''} still missing"
meaning = (
f"The request was approved and sent to {collector}. {available} of {total} aired "
f"episodes have been collected; {missing} still need a matching release."
)
elif missing:
status_label = f"Added to library queue — waiting for {missing} episode{'s' if missing != 1 else ''}"
meaning = (
f"The request was approved and sent to the {collector} collector, but none of the "
f"{total} aired episodes have been collected yet."
)
else:
status_label = "Added to library queue"
meaning = f"The request was approved and sent to the {collector} collector."
elif arr_state in {"added", "searching"}:
status_label = "Added to library queue — waiting for a matching release"
meaning = (
f"The request was approved and sent to the {collector} collector, but a usable release "
"has not been collected yet."
)
elif snapshot.state == NormalizedState.failed:
status_label = "This request needs attention"
meaning = snapshot.state_reason or "Magent could not determine the next stage for this request."
else:
status_label = "Approved — preparing collection" if approved else "Request received"
meaning = snapshot.state_reason or "Magent is checking where this request is in the collection process."
action_ids = [action.id for action in snapshot.actions]
if fully_available:
next_title = "Ready to watch"
next_description = "Collection is complete. Open the title on the media server when you are ready."
recommended = []
elif "resume_torrent" in action_ids:
next_title = "Resume the interrupted download"
next_description = "The download exists but is not currently progressing. Resume it to continue collection."
recommended = ["resume_torrent"]
elif "readd_to_arr" in action_ids:
next_title = "Add this request to the library queue"
next_description = f"Send the approved request to {collector} so collection can begin."
recommended = ["readd_to_arr"]
elif "search_auto" in action_ids or "search_releases" in action_ids:
if snapshot.request_type == RequestType.tv and missing:
target = f"the {missing} missing episode{'s' if missing != 1 else ''}"
else:
target = f"a matching {noun} release"
next_title = f"Search for {target}"
next_description = (
"Run an automatic search, or review the available releases and choose one manually."
)
recommended = [action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids]
elif download_state == "downloading":
next_title = "Let the current download finish"
next_description = "Magent is tracking the active download; no action is needed right now."
recommended = []
elif snapshot.state == NormalizedState.importing and arr_state == "available":
next_title = "Wait for the media server to index this title"
next_description = (
f"{collector} has completed its work. Use Recheck request to see whether the title "
"has appeared on the media server."
)
recommended = []
elif snapshot.state in {NormalizedState.completed, NormalizedState.available}:
next_title = "Ready to watch"
next_description = "Collection is complete. Open the title on the media server when you are ready."
recommended = []
elif snapshot.state == NormalizedState.requested:
next_title = "Wait for approval"
next_description = "An administrator must approve this request before collection can start."
recommended = []
else:
next_title = "Magent is checking the next step"
next_description = "No safe action is available until the current service state is known."
recommended = []
requested_stage = {
"id": "requested",
"label": "Requested",
"state": "complete",
"summary": "Request received",
}
approved_stage = {
"id": "approved",
"label": "Approved",
"state": "complete" if approved else "active",
"summary": "Approved for collection" if approved else "Waiting for approval",
}
if arr_state == "missing":
library_state, library_summary = "attention", "Not yet added to the collector"
elif arr_state == "error":
library_state, library_summary = "attention", f"Unable to read {collector}"
elif partial:
library_state, library_summary = "partial", f"{available} of {total} episodes collected"
elif arr_state == "available":
library_state, library_summary = "complete", "Collection complete"
elif arr_state in {"added", "searching"}:
library_state = "active" if missing or not available else "complete"
library_summary = (
f"{missing} episode{'s' if missing != 1 else ''} still missing"
if snapshot.request_type == RequestType.tv and missing
else "In the library queue"
)
else:
library_state, library_summary = "waiting", "Waiting for collector information"
if fully_available:
search_state, search_summary = "complete", "No further search needed"
elif arr_state == "available":
search_state, search_summary = "complete", "A release was collected"
elif download_visible:
search_state = "complete"
search_summary = "A release was found"
elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie):
search_state = "active" if prowlarr_state == "ok" else "attention"
search_summary = (
f"Ready to search for {missing} missing episode{'s' if missing != 1 else ''}"
if snapshot.request_type == RequestType.tv and missing
else "Ready to search for a release"
)
else:
search_state, search_summary = "waiting", "Search has not started"
completed_download_summary = (
"The requested content has been collected and is available to watch. "
"No further action is needed."
)
if fully_available:
download_stage_state, download_summary = "complete", completed_download_summary
pipeline_download_visible = False
pipeline_torrents: List[Dict[str, Any]] = []
elif arr_state == "available":
download_stage_state = "complete"
download_summary = f"{collector} has imported the collected file"
pipeline_download_visible = False
pipeline_torrents = []
elif download_visible:
download_stage_state = {
"downloading": "active",
"paused": "attention",
"completed": "complete",
"missing": "attention",
"error": "attention",
}.get(download_state, "waiting")
download_summary = str(download.get("summary") or "A prior download attempt was found")
pipeline_download_visible = True
pipeline_torrents = download.get("torrents") or []
else:
download_stage_state, download_summary = "waiting", "No download attempt yet"
pipeline_download_visible = False
pipeline_torrents = []
if jellyfin_partial:
available_label = "Partially available"
available_state = "partial"
available_state_label = "Partly ready"
available_summary = f"{available} of {total} episodes are ready to watch in Grizzlyflix."
elif jellyfin_found:
available_label = "Available to watch"
available_state = "complete"
available_state_label = "Ready"
available_summary = "This title is ready to watch in Grizzlyflix."
elif arr_state == "available":
available_label = "Adding to Grizzlyflix"
available_state = "active"
available_state_label = "Indexing"
available_summary = "The download is complete. Grizzlyflix is indexing this title now."
else:
available_label = "Media server"
available_state = "waiting"
available_state_label = "Waiting"
available_summary = "This title has not reached Grizzlyflix yet."
display_download = dict(download)
if fully_available:
display_download.update(
{
"visible": False,
"state": "completed",
"summary": completed_download_summary,
"torrents": [],
}
)
return {
"status": {"label": status_label, "meaning": meaning},
"download": display_download,
"nextStep": {
"title": next_title,
"description": next_description,
"actionIds": recommended,
},
"pipeline": [
requested_stage,
approved_stage,
{
"id": "library",
"label": "Library collection",
"state": library_state,
"summary": library_summary,
"available": available,
"missing": missing,
"total": total,
"seasons": availability.get("seasons") or [],
"missingEpisodes": arr_details.get("missingEpisodes") or {},
},
{
"id": "search",
"label": "Release search",
"state": search_state,
"summary": search_summary,
"actionIds": [] if fully_available else [
action_id
for action_id in ("search_auto", "search_releases")
if action_id in action_ids
],
},
{
"id": "download",
"label": "Download complete" if fully_available else "Download",
"state": download_stage_state,
"summary": download_summary,
"visible": pipeline_download_visible,
"torrents": pipeline_torrents,
},
{
"id": "available",
"label": available_label,
"state": available_state,
"stateLabel": available_state_label,
"summary": available_summary,
"link": jellyfin_link,
},
],
}
async def build_snapshot(request_id: str) -> Snapshot:
timeline = []
runtime = get_runtime_settings()
@@ -449,7 +1060,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
poster_path = media.get("posterPath") or media.get("poster_path")
backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
if snapshot.title in {None, "", "Unknown"} and allow_remote:
if snapshot.title in {None, "", "Unknown"} and jellyseerr.configured():
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
@@ -533,6 +1144,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv)
arr_details["queue"] = arr_queue
episodes = await sonarr.get_episodes(series_id)
arr_details["availability"] = _episode_availability(episodes)
missing_by_season = _missing_episode_numbers_by_season(episodes)
if missing_by_season:
arr_details["missingEpisodes"] = missing_by_season
@@ -581,6 +1193,12 @@ async def build_snapshot(request_id: str) -> Snapshot:
arr_state = "added"
else:
arr_state = "missing"
arr_details["availability"] = {
"available": 1 if arr_item and arr_item.get("hasFile") else 0,
"missing": 0 if arr_item and arr_item.get("hasFile") else 1,
"total": 1,
"seasons": [],
}
if arr_item and isinstance(arr_item.get("id"), int):
arr_queue = await radarr.get_queue(int(arr_item["id"]))
arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie)
@@ -592,15 +1210,20 @@ async def build_snapshot(request_id: str) -> Snapshot:
if arr_state is None:
arr_state = "unknown"
_apply_arr_identity(snapshot, arr_item)
timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details))
prowlarr_state = "unknown"
try:
prowlarr_health = await prowlarr.get_health()
if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0:
prowlarr_state = "issues"
timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health}))
else:
prowlarr_state = "ok"
timeline.append(TimelineHop(service="Prowlarr", status="ok"))
except Exception as exc:
prowlarr_state = "error"
timeline.append(TimelineHop(service="Prowlarr", status="error", details={"error": str(exc)}))
jellyfin_available = False
@@ -668,34 +1291,66 @@ async def build_snapshot(request_id: str) -> Snapshot:
except Exception:
pass
qbit_state = None
qbit_message = None
try:
qbit_state = "not_started"
qbit_message = "No download attempt has been observed."
download_ids = _download_ids(_queue_records(arr_queue))
download_history = await asyncio.to_thread(get_request_download_evidence, request_id, 100)
torrent_list: List[Dict[str, Any]] = []
download_visible = bool(download_ids) or bool(download_history.get("observed"))
qbit_error = None
try:
if qbittorrent.configured():
if download_ids:
torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids))
torrent_list = torrents if isinstance(torrents, list) else []
else:
category = f"magent-{request_id}"
torrents = await qbittorrent.get_torrents_by_category(category)
request_tag = f"magent-{request_id}"
torrents = await qbittorrent.get_torrents_by_tag(request_tag)
torrent_list = torrents if isinstance(torrents, list) else []
for torrent in torrent_list:
if isinstance(torrent, dict):
torrent["progressPercent"] = _torrent_progress(torrent)
if torrent_list:
download_visible = True
summary = _summarize_qbit(torrent_list)
qbit_state = summary.get("state")
qbit_message = summary.get("message")
qbit_state = str(summary.get("state") or "idle")
qbit_message = str(summary.get("message") or "Download found in qBittorrent.")
elif download_ids:
qbit_state = "missing"
qbit_message = (
"The collector queued a download, but it is no longer visible in qBittorrent."
)
elif download_history.get("observed"):
qbit_state = "missing"
qbit_message = (
"A previous download was observed, but it is not currently visible in qBittorrent."
)
except Exception as exc:
qbit_error = str(exc)
if download_visible:
qbit_state = "error"
qbit_message = (
"A download attempt exists, but Magent cannot currently read its state from qBittorrent."
)
download_presentation = {
"visible": download_visible,
"observed": download_visible,
"state": qbit_state,
"summary": qbit_message,
"torrents": torrent_list,
"lastSeenAt": download_history.get("last_seen_at"),
}
timeline.append(
TimelineHop(
service="qBittorrent",
status=summary["state"],
status=qbit_state,
details={
"summary": summary["message"],
"torrents": torrent_list,
**download_presentation,
"error": qbit_error,
},
)
)
except Exception as exc:
timeline.append(TimelineHop(service="qBittorrent", status="error", details={"error": str(exc)}))
status_code = None
try:
@@ -720,8 +1375,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
snapshot.state_reason = qbit_message
elif qbit_state == "completed":
if arr_state == "available":
snapshot.state = NormalizedState.completed
snapshot.state_reason = "In your library and ready to watch."
snapshot.state = NormalizedState.importing
snapshot.state_reason = "The collector imported the file. Waiting for the media server to index it."
else:
snapshot.state = NormalizedState.importing
snapshot.state_reason = "Download finished. Waiting for library import."
@@ -737,8 +1392,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
snapshot.state = NormalizedState.searching
snapshot.state_reason = "Searching for a matching release."
elif arr_state == "available":
snapshot.state = NormalizedState.completed
snapshot.state_reason = "In your library and ready to watch."
snapshot.state = NormalizedState.importing
snapshot.state_reason = "Collected by Sonarr/Radarr and waiting for the media server to index it."
elif arr_state == "added" and snapshot.state == NormalizedState.approved:
snapshot.state = NormalizedState.added_to_arr
snapshot.state_reason = "Item is present in Sonarr/Radarr"
@@ -766,23 +1421,32 @@ async def build_snapshot(request_id: str) -> Snapshot:
actions.append(
ActionOption(
id="readd_to_arr",
label="Push to Sonarr/Radarr",
label=f"Add to {'Sonarr' if snapshot.request_type == RequestType.tv else 'Radarr'}",
risk="medium",
description="Send this approved request to the library collector.",
)
)
elif arr_item and arr_state != "available":
elif arr_item and arr_state != "available" and qbit_state not in {"downloading", "completed"}:
missing_count = int((arr_details.get("availability") or {}).get("missing") or 0)
automatic_label = (
f"Search automatically for {missing_count} missing episode{'s' if missing_count != 1 else ''}"
if snapshot.request_type == RequestType.tv and missing_count
else "Search automatically for a release"
)
actions.append(
ActionOption(
id="search_auto",
label="Search and auto-download",
label=automatic_label,
risk="low",
description="Ask the library collector to find and download the best permitted match.",
)
)
actions.append(
ActionOption(
id="search_releases",
label="Search and choose a download",
label="Review available releases",
risk="low",
description="Search the configured indexers and choose a release yourself.",
)
)
@@ -793,18 +1457,28 @@ async def build_snapshot(request_id: str) -> Snapshot:
id="resume_torrent",
label="Resume the download",
risk="low",
description="Resume the existing qBittorrent job if it is paused or stalled.",
)
)
snapshot.actions = actions
jellyfin_link = None
if runtime.jellyfin_public_url and snapshot.state in {
NormalizedState.available,
NormalizedState.completed,
}:
if runtime.jellyfin_public_url and jellyfin_available:
base_url = runtime.jellyfin_public_url.rstrip("/")
jellyfin_item_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
if jellyfin_item_id:
jellyfin_link = f"{base_url}/web/index.html#!/details?id={quote(str(jellyfin_item_id))}"
else:
query = quote(snapshot.title or "")
jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}"
availability = arr_details.get("availability") or {}
is_partial = bool(
jellyfin_available
and snapshot.request_type == RequestType.tv
and int(availability.get("missing") or 0) > 0
)
if jellyfin_available and not is_partial:
snapshot.actions = []
snapshot.raw = {
"jellyseerr": jelly_request,
"arr": {
@@ -813,15 +1487,47 @@ async def build_snapshot(request_id: str) -> Snapshot:
},
"jellyfin": {
"publicUrl": runtime.jellyfin_public_url,
"available": snapshot.state in {
"found": jellyfin_available,
"available": jellyfin_available and snapshot.state in {
NormalizedState.available,
NormalizedState.completed,
},
"partial": is_partial,
"link": jellyfin_link,
"item": jellyfin_item,
},
"qbittorrent": {
**download_presentation,
"downloadIds": download_ids,
"error": qbit_error,
},
}
snapshot.presentation = _build_presentation(
snapshot,
approved=derived_approved,
arr_state=arr_state,
arr_details=arr_details,
prowlarr_state=prowlarr_state,
download=download_presentation,
jellyfin_found=jellyfin_available,
jellyfin_link=jellyfin_link,
)
repair_action = await asyncio.to_thread(_latest_repair_action, request_id)
repair_activity = _build_repair_activity(
snapshot,
action=repair_action,
arr_state=arr_state,
arr_details=arr_details,
download=download_presentation,
jellyfin_found=jellyfin_available,
)
if repair_activity:
snapshot.presentation["repairActivity"] = repair_activity
status_presentation = snapshot.presentation.get("status")
if isinstance(status_presentation, dict) and status_presentation.get("meaning"):
snapshot.state_reason = str(status_presentation["meaning"])
await _maybe_refresh_jellyfin(snapshot)
await asyncio.to_thread(save_snapshot, snapshot)
return snapshot
+4 -4
View File
@@ -2,8 +2,8 @@ fastapi==0.134.0
uvicorn==0.41.0
httpx==0.28.1
pydantic==2.12.5
pydantic-settings==2.13.1
PyJWT==2.11.0
pydantic-settings==2.14.2
PyJWT==2.13.0
passlib==1.7.4
python-multipart==0.0.22
Pillow==12.1.1
python-multipart==0.0.31
Pillow==12.3.0
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
name: magent-beta
services:
magent:
build:
context: .
dockerfile: Dockerfile
env_file:
- ./.env
environment:
APP_NAME: Magent Beta
CORS_ALLOW_ORIGIN: https://beta.grizzlyflix.co.nz
MAGENT_APPLICATION_URL: https://beta.grizzlyflix.co.nz
MAGENT_API_URL: https://beta.grizzlyflix.co.nz/api
AUTH_COOKIE_NAME: magent_beta_auth
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
AUTH_COOKIE_DOMAIN: beta.grizzlyflix.co.nz
SQLITE_PATH: /app/data/magent.db
LOG_FILE: /app/data/magent.log
SITE_BANNER_ENABLED: "true"
SITE_BANNER_MESSAGE: "Beta environment"
SITE_BANNER_TONE: warning
ports:
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
- "127.0.0.1:8100:8000"
volumes:
- ./data:/app/data
restart: unless-stopped
+449 -142
View File
@@ -1,10 +1,10 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../lib/auth'
import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel'
import AdminShell from '../ui/AdminShell'
type AdminSetting = {
key: string
@@ -19,6 +19,12 @@ type ServiceOptions = {
qualityProfiles: { id: number; name: string; label: string }[]
}
type ServiceStatus = {
name: string
status: string
message?: string
}
const SECTION_LABELS: Record<string, string> = {
magent: 'Magent',
general: 'General',
@@ -27,14 +33,17 @@ const SECTION_LABELS: Record<string, string> = {
jellyseerr: 'Seerr',
jellyfin: 'Jellyfin',
artwork: 'Artwork cache',
cache: 'Cache Control',
cache: 'Request cache',
sonarr: 'Sonarr',
radarr: 'Radarr',
bazarr: 'Bazarr',
prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent',
log: 'Activity log',
requests: 'Request sync',
site: 'Site',
logs: 'Activity log',
maintenance: 'Maintenance',
requests: 'Request pipeline',
'issue-workflow': 'Issue workflow',
site: 'Site & login',
}
const BOOL_SETTINGS = new Set([
@@ -44,6 +53,7 @@ const BOOL_SETTINGS = new Set([
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
'site_nav_show_requests',
'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled',
@@ -74,6 +84,7 @@ const URL_SETTINGS = new Set([
'jellyfin_public_url',
'sonarr_base_url',
'radarr_base_url',
'bazarr_base_url',
'prowlarr_base_url',
'qbittorrent_base_url',
])
@@ -87,6 +98,8 @@ const NUMBER_SETTINGS = new Set([
'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes',
'requests_cleanup_days',
'issue_confirmation_contact_attempts',
'issue_confirmation_interval_value',
])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
@@ -99,15 +112,18 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
'Notification providers and delivery channel settings used by Magent messaging features.',
seerr: 'Connect Seerr where users submit content requests.',
jellyseerr: 'Connect Seerr where users submit content requests.',
jellyfin: 'Control Jellyfin login and availability checks.',
jellyfin: 'Jellyfin connection, public playback links, user sync, and availability checks.',
artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'TV automation settings.',
radarr: 'Movie automation settings.',
prowlarr: 'Indexer search settings.',
qbittorrent: 'Downloader connection settings.',
sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
radarr: 'Radarr connection and the default profile and library location for movie requests.',
bazarr: 'Bazarr connection used to find and replace movie and episode subtitles.',
prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.',
'issue-workflow': 'Control reporter confirmation, reminder timing, and automatic issue closure.',
logs: 'Control log output and inspect recent application activity for troubleshooting.',
maintenance: 'Repair cached data, clean historical records, and run recovery operations.',
site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
}
@@ -121,9 +137,11 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
artwork: null,
sonarr: 'sonarr',
radarr: 'radarr',
bazarr: 'bazarr',
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
requests: 'requests',
'issue-workflow': 'issue',
cache: null,
logs: 'log',
maintenance: null,
@@ -226,10 +244,14 @@ const MAGENT_SECTION_GROUPS: Array<{
'magent_notify_push_token',
'magent_notify_push_user_key',
'magent_notify_push_device',
'magent_notify_webhook_enabled',
'magent_notify_webhook_url',
],
},
{
key: 'magent-notify-webhook',
title: 'Generic Webhook',
description: 'Send notifications to a custom automation or integration endpoint.',
keys: ['magent_notify_webhook_enabled', 'magent_notify_webhook_url'],
},
]
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
@@ -240,6 +262,7 @@ const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
'magent-notify-discord',
'magent-notify-telegram',
'magent-notify-push',
'magent-notify-webhook',
]),
}
@@ -266,9 +289,165 @@ const SITE_SECTION_GROUPS: Array<{
'site_login_show_signup_link',
],
},
{
key: 'site-navigation',
title: 'Beta Navigation',
description: 'Temporarily show or hide beta navigation entries while new request pipelines are built.',
keys: ['site_nav_show_requests'],
},
]
const STANDARD_SECTION_GROUPS: Record<
string,
Array<{ key: string; title: string; description: string; keys: string[] }>
> = {
seerr: [
{
key: 'seerr-connection',
title: 'Connection',
description: 'The Seerr endpoint and API credential Magent uses for request discovery and status.',
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
},
],
jellyseerr: [
{
key: 'seerr-connection',
title: 'Connection',
description: 'The Seerr endpoint and API credential Magent uses for request discovery and status.',
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
},
],
jellyfin: [
{
key: 'jellyfin-connection',
title: 'Connection',
description: 'Internal Jellyfin endpoint and administrator API credential used for lookups and user sync.',
keys: ['jellyfin_base_url', 'jellyfin_api_key'],
},
{
key: 'jellyfin-playback',
title: 'Playback Links',
description: 'Public address used when a viewer opens an available title from Magent.',
keys: ['jellyfin_public_url'],
},
{
key: 'jellyfin-users',
title: 'Library and User Sync',
description: 'Control cross-service library reconciliation and manually import Jellyfin users.',
keys: ['jellyfin_sync_to_arr'],
},
],
sonarr: [
{
key: 'sonarr-connection',
title: 'Connection',
description: 'Sonarr endpoint and API credential used for TV collection operations.',
keys: ['sonarr_base_url', 'sonarr_api_key'],
},
{
key: 'sonarr-library',
title: 'TV Collection Defaults',
description: 'Default quality profile and destination folder used for TV requests.',
keys: ['sonarr_quality_profile_id', 'sonarr_root_folder'],
},
],
radarr: [
{
key: 'radarr-connection',
title: 'Connection',
description: 'Radarr endpoint and API credential used for movie collection operations.',
keys: ['radarr_base_url', 'radarr_api_key'],
},
{
key: 'radarr-library',
title: 'Movie Collection Defaults',
description: 'Default quality profile and destination folder used for movie requests.',
keys: ['radarr_quality_profile_id', 'radarr_root_folder'],
},
],
bazarr: [
{
key: 'bazarr-connection',
title: 'Connection',
description: 'Bazarr endpoint, API credential, and default language used by subtitle issue repairs.',
keys: ['bazarr_base_url', 'bazarr_api_key', 'bazarr_default_language'],
},
],
prowlarr: [
{
key: 'prowlarr-connection',
title: 'Connection',
description: 'Prowlarr endpoint and API credential used for indexer health and release discovery.',
keys: ['prowlarr_base_url', 'prowlarr_api_key'],
},
],
qbittorrent: [
{
key: 'qbittorrent-connection',
title: 'Connection and Sign-in',
description: 'qBittorrent Web UI endpoint and credentials used for live download progress and recovery.',
keys: ['qbittorrent_base_url', 'qbittorrent_username', 'qbittorrent_password'],
},
],
requests: [
{
key: 'requests-sync',
title: 'Synchronization Schedule',
description: 'Control incremental checks and the scheduled full request-cache rebuild.',
keys: [
'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes',
'requests_full_sync_time',
],
},
{
key: 'requests-retention',
title: 'History Retention',
description: 'Choose when old status history is cleaned up and how long it is retained.',
keys: ['requests_cleanup_time', 'requests_cleanup_days'],
},
],
'issue-workflow': [
{
key: 'issues-resolution-confirmation',
title: 'Resolution confirmation',
description: 'Choose how often Magent asks a reporter to confirm a fix before the issue is closed automatically.',
keys: [
'issue_confirmation_contact_attempts',
'issue_confirmation_interval_value',
'issue_confirmation_interval_unit',
],
},
],
logs: [
{
key: 'logs-output',
title: 'Log Output',
description: 'Set the default application verbosity and the active log-file destination.',
keys: ['log_level', 'log_file'],
},
{
key: 'logs-rotation',
title: 'File Rotation',
description: 'Limit log-file growth and choose how many historical files remain on disk.',
keys: ['log_file_max_bytes', 'log_file_backup_count'],
},
{
key: 'logs-components',
title: 'Component Verbosity',
description: 'Tune noisy outbound-service and scheduled-background messages independently.',
keys: ['log_http_client_level', 'log_background_sync_level'],
},
],
}
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
bazarr_base_url: 'Bazarr base URL',
bazarr_api_key: 'Bazarr API key',
bazarr_default_language: 'Default subtitle language',
issue_confirmation_contact_attempts: 'Confirmation emails before auto-close',
issue_confirmation_interval_value: 'Confirmation interval',
issue_confirmation_interval_unit: 'Interval unit',
jellyseerr_base_url: 'Seerr base URL',
jellyseerr_api_key: 'Seerr API key',
magent_application_url: 'Application URL',
@@ -309,10 +488,38 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_notify_push_device: 'Device / target',
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
magent_notify_webhook_url: 'Generic webhook URL',
jellyfin_base_url: 'Internal server URL',
jellyfin_api_key: 'Administrator API key',
jellyfin_public_url: 'Public playback URL',
jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr',
sonarr_base_url: 'Sonarr server URL',
sonarr_api_key: 'Sonarr API key',
sonarr_quality_profile_id: 'Default TV quality profile',
sonarr_root_folder: 'Default TV root folder',
radarr_base_url: 'Radarr server URL',
radarr_api_key: 'Radarr API key',
radarr_quality_profile_id: 'Default movie quality profile',
radarr_root_folder: 'Default movie root folder',
prowlarr_base_url: 'Prowlarr server URL',
prowlarr_api_key: 'Prowlarr API key',
qbittorrent_base_url: 'Web UI URL',
qbittorrent_username: 'Web UI username',
qbittorrent_password: 'Web UI password',
requests_sync_ttl_minutes: 'Request cache freshness (minutes)',
requests_poll_interval_seconds: 'Full-sync eligibility check (seconds)',
requests_delta_sync_interval_minutes: 'Recent-change sync interval (minutes)',
requests_full_sync_time: 'Daily full-sync time',
requests_cleanup_time: 'Daily history cleanup time',
requests_cleanup_days: 'History retention (days)',
requests_data_source: 'Request read source',
artwork_cache_mode: 'Artwork delivery mode',
log_level: 'Application log level',
log_file: 'Active log file',
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
site_login_show_local_login: 'Login page: local Magent sign-in',
site_login_show_forgot_password: 'Login page: forgot password',
site_login_show_signup_link: 'Login page: invite signup link',
site_nav_show_requests: 'Top navigation: New Requests',
log_file_max_bytes: 'Log file max size (bytes)',
log_file_backup_count: 'Rotated log files to keep',
log_http_client_level: 'Service HTTP log level',
@@ -343,6 +550,7 @@ const labelFromKey = (key: string) =>
.replace('site banner enabled', 'Sitewide banner enabled')
.replace('site banner message', 'Sitewide banner message')
.replace('site banner tone', 'Sitewide banner tone')
.replace('site nav show requests', 'Top navigation: New Requests')
.replace('site changelog', 'Changelog text')
const formatBytes = (value?: number | null) => {
@@ -375,12 +583,12 @@ type SectionFeedback = {
}
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
jellyseerr: 'seerr',
jellyfin: 'jellyfin',
sonarr: 'sonarr',
radarr: 'radarr',
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
'seerr-connection': 'seerr',
'jellyfin-connection': 'jellyfin',
'sonarr-connection': 'sonarr',
'radarr-connection': 'radarr',
'prowlarr-connection': 'prowlarr',
'qbittorrent-connection': 'qbittorrent',
}
export default function SettingsPage({ section }: SettingsPageProps) {
@@ -414,6 +622,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([])
const [serviceStatusCheckedAt, setServiceStatusCheckedAt] = useState<string | null>(null)
const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null)
const computeProgressPercent = (
@@ -506,7 +716,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not load artwork stats.'
setArtworkSummaryStatus(message)
}
@@ -543,6 +753,21 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
}, [])
const loadServiceStatuses = useCallback(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services`)
if (!response.ok) {
return
}
const data = await response.json()
setServiceStatuses(Array.isArray(data?.services) ? data.services : [])
setServiceStatusCheckedAt(new Date().toISOString())
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
const load = async () => {
if (!getToken()) {
@@ -550,7 +775,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return
}
try {
await loadSettings()
await Promise.all([loadSettings(), loadServiceStatuses()])
if (section === 'cache' || section === 'artwork') {
await loadArtworkPrefetchStatus()
await loadArtworkSummary()
@@ -570,7 +795,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'radarr') {
void loadOptions('radarr')
}
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section])
const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {}
@@ -583,45 +808,87 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [settings])
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const statusNamesBySection: Record<string, string[]> = {
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyfin: ['Jellyfin'],
sonarr: ['Sonarr'],
radarr: ['Radarr'],
prowlarr: ['Prowlarr'],
qbittorrent: ['qBittorrent', 'Qbittorrent'],
}
const statusNames = statusNamesBySection[section] ?? statusNamesBySection[settingsSection ?? ''] ?? []
const currentServiceStatus = serviceStatuses.find((service) =>
statusNames.some((name) => name.toLowerCase() === service.name.toLowerCase())
)
const currentServiceConfigured = currentServiceStatus
? currentServiceStatus.status !== 'not_configured'
: null
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
const isSiteGroupedSection = section === 'site'
const visibleSections = settingsSection ? [settingsSection] : []
const isCacheSection = section === 'cache'
const isArtworkSection = section === 'artwork'
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
const artworkSettingKeys = new Set(['artwork_cache_mode'])
const generatedSettingKeys = new Set(['site_changelog'])
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
const requestSettingOrder = [
'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes',
'requests_full_sync_time',
'requests_cleanup_time',
'requests_cleanup_days',
]
const siteSettingOrder = [
'site_banner_enabled',
'site_banner_message',
'site_banner_tone',
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
]
const sortByOrder = (items: AdminSetting[], order: string[]) => {
const position = new Map(order.map((key, index) => [key, index]))
return [...items].sort((a, b) => {
const aIndex = position.get(a.key) ?? Number.POSITIVE_INFINITY
const bIndex = position.get(b.key) ?? Number.POSITIVE_INFINITY
if (aIndex !== bIndex) return aIndex - bIndex
return a.key.localeCompare(b.key)
})
}
const obsoleteSettingKeys = new Set([
'sonarr_qbittorrent_category',
'radarr_qbittorrent_category',
])
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
const buildDefinedSections = (
definitions: Array<{ key: string; title: string; description: string; keys: string[] }>,
sourceItems: AdminSetting[],
includeUnassigned = true,
): SettingsSectionGroup[] => {
const byKey = new Map(sourceItems.map((item) => [item.key, item]))
const assignedKeys = new Set(definitions.flatMap((group) => group.keys))
const groups = definitions.map((group) => ({
key: group.key,
title: group.title,
description: group.description,
items: group.keys
.map((key) => byKey.get(key))
.filter((item): item is AdminSetting => Boolean(item)),
}))
if (includeUnassigned) {
const unassigned = sourceItems.filter((item) => !assignedKeys.has(item.key))
if (unassigned.length > 0) {
groups.push({
key: `${section}-additional`,
title: 'Additional Settings',
description: 'Settings returned by Magent that do not yet belong to a dedicated subsection.',
items: unassigned.sort((a, b) => a.key.localeCompare(b.key)),
})
}
}
return groups
}
const standardDefinitions = STANDARD_SECTION_GROUPS[section]
const standardItems = settingsSection
? (groupedSettings[settingsSection] ?? []).filter(
(setting) => !obsoleteSettingKeys.has(setting.key) && !hiddenSettingKeys.has(setting.key),
)
: []
const settingsSections: SettingsSectionGroup[] = isCacheSection
? [
{ key: 'cache', title: 'Cache control', items: cacheSettings },
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
{
key: 'cache',
title: 'Request Cache Strategy',
description: 'Choose where request pages read from and how long cached request records remain fresh.',
items: cacheSettings,
},
]
: isArtworkSection
? [
{
key: 'artwork',
title: 'Artwork Delivery and Storage',
description: 'Choose how posters and backdrops are delivered, then inspect or rebuild the local artwork cache.',
items: artworkSettings,
},
]
: isMagentGroupedSection
? (() => {
@@ -629,69 +896,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return []
}
const magentItems = groupedSettings.magent ?? []
const byKey = new Map(magentItems.map((item) => [item.key, item]))
const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
allowedGroupKeys.has(group.key),
).map((group) => {
const items = group.keys
.map((key) => byKey.get(key))
.filter((item): item is AdminSetting => Boolean(item))
return {
key: group.key,
title: group.title,
description: group.description,
items,
}
})
return groups
const definitions = MAGENT_SECTION_GROUPS.filter((group) => allowedGroupKeys.has(group.key))
return buildDefinedSections(definitions, magentItems, false)
})()
: isSiteGroupedSection
? (() => {
const siteItems = groupedSettings.site ?? []
const byKey = new Map(siteItems.map((item) => [item.key, item]))
return SITE_SECTION_GROUPS.map((group) => {
const items = group.keys
.map((key) => byKey.get(key))
.filter((item): item is AdminSetting => Boolean(item))
return {
key: group.key,
title: group.title,
description: group.description,
items,
}
})
})()
: visibleSections.map((sectionKey) => ({
key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey,
items: (() => {
const sectionItems = groupedSettings[sectionKey] ?? []
const filtered =
sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site'
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
: sectionItems
if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder)
}
if (sectionKey === 'site') {
return sortByOrder(filtered, siteSettingOrder)
}
return filtered
})(),
}))
? buildDefinedSections(
SITE_SECTION_GROUPS,
(groupedSettings.site ?? []).filter((setting) => !hiddenSettingKeys.has(setting.key)),
)
: standardDefinitions
? buildDefinedSections(standardDefinitions, standardItems)
: []
const showLogs = section === 'logs'
const showMaintenance = section === 'maintenance'
const showRequestsExtras = section === 'requests'
const showArtworkExtras = section === 'cache'
const showArtworkExtras = section === 'artwork'
const showCacheExtras = section === 'cache'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
if (showCacheExtras && sectionGroup.key === 'cache') return true
if (showRequestsExtras && sectionGroup.key === 'requests') return true
if (showRequestsExtras && sectionGroup.key === 'requests-sync') return true
return false
}
const renderedSettingsSections = settingsSections.filter(shouldRenderSection)
useEffect(() => {
requestsSyncRef.current = requestsSync
@@ -702,6 +931,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [artworkPrefetch])
const settingDescriptions: Record<string, string> = {
issue_confirmation_contact_attempts:
'Number of confirmation emails sent after an issue is marked fixed. Set 0 to send none and close immediately.',
issue_confirmation_interval_value:
'Amount of time between confirmation emails, and the final waiting period before automatic closure.',
issue_confirmation_interval_unit:
'Unit used for the confirmation interval: days, weeks, or months.',
magent_application_url:
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
magent_application_port:
@@ -773,14 +1008,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.',
bazarr_base_url: 'Bazarr server URL used for movie and episode subtitle repairs. Scheme is optional.',
bazarr_api_key: 'API key used to ask Bazarr for fresh subtitles.',
bazarr_default_language: 'Language code Bazarr should search for by default, such as en.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.',
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
prowlarr_base_url:
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
prowlarr_api_key: 'API key for Prowlarr.',
@@ -814,6 +1050,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
site_login_show_local_login: 'Show the local Magent login button on the login page.',
site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
site_login_show_signup_link: 'Show the invite signup link on the login page.',
site_nav_show_requests:
'Show the New Requests item in the top navigation. Disable it while request creation is unavailable.',
site_changelog: 'One update per line for the public changelog.',
}
@@ -847,6 +1085,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767',
bazarr_default_language: 'en',
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
@@ -875,7 +1115,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const parseActionError = (err: unknown, fallback: string) => {
if (err instanceof Error && err.message) {
return err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
return err.message.replace(/^\\{"detail":"|"\\}$/g, '')
}
return fallback
}
@@ -1063,7 +1303,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not import Jellyfin users.'
setJellyfinSyncStatus(message)
}
@@ -1094,7 +1334,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not sync requests.'
setRequestsSyncStatus(message)
}
@@ -1125,7 +1365,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not run delta sync.'
setRequestsSyncStatus(message)
}
@@ -1155,7 +1395,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not cache artwork.'
setArtworkPrefetchStatus(message)
}
@@ -1186,7 +1426,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not cache missing artwork.'
setArtworkPrefetchStatus(message)
}
@@ -1231,7 +1471,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || payload.type !== 'admin_live_state') {
if (payload?.type !== 'admin_live_state') {
return
}
@@ -1401,7 +1641,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not load logs.'
setLogsStatus(message)
}
@@ -1443,7 +1683,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not load cache.'
setCacheStatus(message)
} finally {
@@ -1576,10 +1816,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<span>Maintenance job</span>
<strong>{maintenanceBusy ? 'Running' : 'Idle'}</strong>
</div>
<div className="cache-rail-metric">
<span>Live updates</span>
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
</div>
<div className="cache-rail-metric">
<span>Log lines in view</span>
<strong>{logsLines.length}</strong>
@@ -1593,7 +1829,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div>
) : undefined
const cacheRail = showCacheExtras ? (
<div className="admin-rail-stack">
<div className="admin-rail-card cache-rail-card">
<span className="admin-rail-eyebrow">Cache control</span>
<h2>Saved requests</h2>
@@ -1640,6 +1875,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</button>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
</div>
) : undefined
const artworkRail = showArtworkExtras ? (
<div className="admin-rail-card cache-rail-card">
<span className="admin-rail-eyebrow">Artwork</span>
<h2>Cache stats</h2>
@@ -1662,7 +1899,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div>
</div>
</div>
</div>
) : undefined
if (loading) {
@@ -1673,7 +1909,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<AdminShell
title={SECTION_LABELS[section] ?? 'Settings'}
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
rail={maintenanceRail ?? cacheRail}
rail={maintenanceRail ?? cacheRail ?? artworkRail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
@@ -1681,27 +1917,72 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
>
{status && <div className="error-banner">{status}</div>}
{settingsSections.length > 0 ? (
{currentServiceStatus ? (
<section className="admin-section admin-zone service-status-panel">
<div className="service-status-summary">
<span className={`system-dot system-dot-${currentServiceStatus.status}`} aria-hidden="true" />
<div>
<span className="section-kicker">Connection status</span>
<h2>{currentServiceStatus.name}</h2>
<p className="section-subtitle">
{currentServiceStatus.message ?? 'No service message was returned.'}
</p>
</div>
</div>
<div className="service-status-grid">
<div>
<span>Status</span>
<strong>{currentServiceStatus.status.replaceAll('_', ' ')}</strong>
</div>
<div>
<span>Configuration</span>
<strong>{currentServiceConfigured ? 'Configured' : 'Not configured'}</strong>
</div>
<div>
<span>Last checked</span>
<strong>
{serviceStatusCheckedAt ? new Date(serviceStatusCheckedAt).toLocaleString() : 'Not checked yet'}
</strong>
</div>
<button type="button" className="ghost-button" onClick={() => void loadServiceStatuses()}>
Refresh status
</button>
</div>
</section>
) : null}
{renderedSettingsSections.length > 1 ? (
<nav className="config-subsection-nav" aria-label={`${SECTION_LABELS[section] ?? 'Settings'} subsections`}>
<span>On this page</span>
<div>
{renderedSettingsSections.map((sectionGroup, index) => (
<a key={sectionGroup.key} href={`#config-${sectionGroup.key}`}>
<small>{String(index + 1).padStart(2, '0')}</small>
{sectionGroup.title}
</a>
))}
</div>
</nav>
) : null}
{renderedSettingsSections.length > 0 ? (
<div className="admin-form admin-zone-stack">
{settingsSections
.filter(shouldRenderSection)
.map((sectionGroup) => (
<section key={sectionGroup.key} className="admin-section admin-zone">
{renderedSettingsSections.map((sectionGroup, sectionIndex) => (
<section id={`config-${sectionGroup.key}`} key={sectionGroup.key} className="admin-section admin-zone config-subsection">
<div className="section-header">
<h2>
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
</h2>
{sectionGroup.key === 'sonarr' && (
<div className="config-subsection-heading">
<span className="section-kicker">Subsection {String(sectionIndex + 1).padStart(2, '0')}</span>
<h2>{sectionGroup.title}</h2>
</div>
{sectionGroup.key === 'sonarr-library' && (
<button type="button" onClick={() => loadOptions('sonarr')}>
Refresh Sonarr options
</button>
)}
{sectionGroup.key === 'radarr' && (
{sectionGroup.key === 'radarr-library' && (
<button type="button" onClick={() => loadOptions('radarr')}>
Refresh Radarr options
</button>
)}
{sectionGroup.key === 'jellyfin' && (
{sectionGroup.key === 'jellyfin-users' && (
<button type="button" onClick={syncJellyfinUsers}>
Import Jellyfin users
</button>
@@ -1720,7 +2001,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</button>
</div>
) : null}
{showRequestsExtras && sectionGroup.key === 'requests' && (
{showRequestsExtras && sectionGroup.key === 'requests-sync' && (
<div className="sync-actions-block">
<div className="sync-actions">
<button type="button" onClick={syncRequests}>
@@ -1737,8 +2018,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div>
)}
</div>
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
(!settingsSection || isMagentGroupedSection || isSiteGroupedSection) && (
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && (
<p className="section-subtitle">
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
</p>
@@ -1749,13 +2029,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
restarts may still be required before bind/port changes take effect.
</div>
)}
{sectionGroup.key === 'sonarr' && sonarrError && (
{sectionGroup.key === 'sonarr-library' && sonarrError && (
<div className="error-banner">{sonarrError}</div>
)}
{sectionGroup.key === 'radarr' && radarrError && (
{sectionGroup.key === 'radarr-library' && radarrError && (
<div className="error-banner">{radarrError}</div>
)}
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
{sectionGroup.key === 'jellyfin-users' && jellyfinSyncStatus && (
<div className="status-banner">{jellyfinSyncStatus}</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
@@ -1790,10 +2070,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div>
</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSyncStatus && (
<div className="status-banner">{requestsSyncStatus}</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && (
{showRequestsExtras && sectionGroup.key === 'requests-sync' && (
<div className="status-banner">
Full refresh checks only decide when to run a full refresh. The delta sync interval
polls for new or updated requests.
@@ -1825,7 +2105,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSync && (
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSync && (
<div className="sync-progress">
<div className="sync-meta">
<span>Status: {requestsSync.status}</span>
@@ -2170,7 +2450,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<input
name={setting.key}
type="number"
min={1}
min={setting.key === 'issue_confirmation_contact_attempts' ? 0 : 1}
max={setting.key === 'issue_confirmation_contact_attempts' ? 10 : setting.key === 'issue_confirmation_interval_value' ? 365 : undefined}
step={1}
value={value}
onChange={(event) =>
@@ -2183,6 +2464,32 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label>
)
}
if (setting.key === 'issue_confirmation_interval_unit') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'days'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
</select>
</label>
)
}
if (setting.key === 'requests_data_source') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
@@ -2314,7 +2621,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
onClick={() => void saveSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
{sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'}
{sectionSaving[sectionGroup.key] ? 'Saving...' : `Save ${sectionGroup.title}`}
</button>
</div>
</section>
+2
View File
@@ -8,9 +8,11 @@ const ALLOWED_SECTIONS = new Set([
'artwork',
'sonarr',
'radarr',
'bazarr',
'prowlarr',
'qbittorrent',
'requests',
'issue-workflow',
'cache',
'logs',
'maintenance',
+495 -186
View File
@@ -1,9 +1,9 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type AdminUserLite = {
id: number
@@ -46,10 +46,21 @@ type Invite = {
recipient_email?: string | null
is_expired?: boolean
is_usable?: boolean
operational_state?: 'ready' | 'disabled' | 'expired' | 'exhausted' | 'profile_unavailable'
state_label?: string
attention_reason?: string | null
created_at?: string | null
created_by?: string | null
}
type InviteSummary = {
total: number
ready: number
attention: number
used_signups: number
with_recipient: number
}
type InviteForm = {
code: string
label: string
@@ -60,10 +71,11 @@ type InviteForm = {
enabled: boolean
expires_at: string
recipient_email: string
send_email: boolean
message: string
}
type InviteDeliveryMethod = '' | 'manual' | 'email'
type ProfileForm = {
name: string
description: string
@@ -75,6 +87,7 @@ type ProfileForm = {
type InviteEmailTemplateKey = 'invited' | 'welcome' | 'warning' | 'banned'
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' | 'emails'
type InviteView = 'all' | 'ready' | 'attention' | 'used'
type InviteTraceScope = 'all' | 'invited' | 'direct'
type InviteTraceView = 'list' | 'graph'
@@ -117,17 +130,22 @@ type InvitePolicy = {
invite_access_enabled_users?: number
}
const defaultInviteForm = (): InviteForm => ({
const futureInviteExpiry = (days: number) => {
const expires = new Date()
expires.setDate(expires.getDate() + days)
return expires.toISOString()
}
const defaultInviteForm = (profileId = '', safeDefaults = false): InviteForm => ({
code: '',
label: '',
description: '',
profile_id: '',
profile_id: profileId,
role: '',
max_uses: '',
max_uses: safeDefaults ? '1' : '',
enabled: true,
expires_at: '',
expires_at: safeDefaults ? futureInviteExpiry(7) : '',
recipient_email: '',
send_email: false,
message: '',
})
@@ -161,6 +179,9 @@ const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.
const isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
const isInviteOperationallyReady = (invite: Invite) =>
invite.operational_state ? invite.operational_state === 'ready' : invite.is_usable !== false
export default function AdminInviteManagementPage() {
const router = useRouter()
const [invites, setInvites] = useState<Invite[]>([])
@@ -170,6 +191,7 @@ export default function AdminInviteManagementPage() {
const [loading, setLoading] = useState(true)
const [inviteSaving, setInviteSaving] = useState(false)
const [sendingInviteId, setSendingInviteId] = useState<number | null>(null)
const [profileSaving, setProfileSaving] = useState(false)
const [bulkProfileBusy, setBulkProfileBusy] = useState(false)
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
@@ -184,6 +206,12 @@ export default function AdminInviteManagementPage() {
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
const [inviteFlowStep, setInviteFlowStep] = useState(1)
const [useCustomInviteCode, setUseCustomInviteCode] = useState(false)
const [inviteDeliveryMethod, setInviteDeliveryMethod] = useState<InviteDeliveryMethod>('')
const [createdInvite, setCreatedInvite] = useState<Invite | null>(null)
const [inviteSummary, setInviteSummary] = useState<InviteSummary | null>(null)
const [inviteView, setInviteView] = useState<InviteView>('all')
const [profileEditingId, setProfileEditingId] = useState<number | null>(null)
const [profileForm, setProfileForm] = useState<ProfileForm>(defaultProfileForm())
@@ -192,7 +220,7 @@ export default function AdminInviteManagementPage() {
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
const [masterInviteSelection, setMasterInviteSelection] = useState('')
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [activeTab, setActiveTab] = useState<InviteManagementTab>('invites')
const [emailTemplates, setEmailTemplates] = useState<InviteEmailTemplate[]>([])
const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null)
const [selectedTemplateKey, setSelectedTemplateKey] = useState<InviteEmailTemplateKey>('invited')
@@ -286,7 +314,20 @@ export default function AdminInviteManagementPage() {
])
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
setInviteSummary((inviteData?.summary ?? null) as InviteSummary | null)
const nextProfiles = Array.isArray(profileData?.profiles) ? profileData.profiles : []
setProfiles(nextProfiles)
setInviteForm((current) => {
const isPristine =
!current.code &&
!current.label &&
!current.description &&
!current.profile_id &&
!current.recipient_email
if (!isPristine) return current
const defaultProfile = nextProfiles.find((profile: Profile) => profile.is_active !== false)
return defaultInviteForm(defaultProfile ? String(defaultProfile.id) : '', true)
})
setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
setInvitePolicy(nextPolicy)
setMasterInviteSelection(
@@ -324,7 +365,11 @@ export default function AdminInviteManagementPage() {
const resetInviteEditor = () => {
setInviteEditingId(null)
setInviteForm(defaultInviteForm())
const defaultProfile = profiles.find((profile) => profile.is_active !== false)
setInviteForm(defaultInviteForm(defaultProfile ? String(defaultProfile.id) : '', true))
setInviteFlowStep(1)
setUseCustomInviteCode(false)
setInviteDeliveryMethod('')
}
const editInvite = (invite: Invite) => {
@@ -342,22 +387,31 @@ export default function AdminInviteManagementPage() {
enabled: invite.enabled !== false,
expires_at: invite.expires_at ?? '',
recipient_email: invite.recipient_email ?? '',
send_email: false,
message: '',
})
setInviteFlowStep(4)
setUseCustomInviteCode(true)
setInviteDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
setCreatedInvite(null)
setStatus(null)
setError(null)
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
const recipientEmail = inviteForm.recipient_email.trim()
if (!recipientEmail) {
setError('Recipient email is required.')
const inviteName = inviteForm.label.trim()
if (!inviteName) {
setError('Give this invite a name so you can recognise it later.')
setStatus(null)
return
}
if (!isValidEmail(recipientEmail)) {
const recipientEmail = inviteForm.recipient_email.trim()
if (!inviteDeliveryMethod) {
setError('Choose whether to copy the invite link yourself or send it by email.')
setStatus(null)
return
}
if (inviteDeliveryMethod === 'email' && !isValidEmail(recipientEmail)) {
setError('Recipient email must be valid.')
setStatus(null)
return
@@ -368,16 +422,16 @@ export default function AdminInviteManagementPage() {
try {
const baseUrl = getApiBase()
const payload = {
code: inviteForm.code || null,
label: inviteForm.label || null,
code: useCustomInviteCode ? inviteForm.code || null : null,
label: inviteName,
description: inviteForm.description || null,
profile_id: inviteForm.profile_id || null,
role: inviteForm.role || null,
max_uses: inviteForm.max_uses || null,
enabled: inviteForm.enabled,
expires_at: inviteForm.expires_at || null,
recipient_email: recipientEmail,
send_email: inviteForm.send_email,
recipient_email: inviteDeliveryMethod === 'email' ? recipientEmail : null,
send_email: inviteEditingId == null && inviteDeliveryMethod === 'email',
message: inviteForm.message || null,
}
const url =
@@ -394,8 +448,10 @@ export default function AdminInviteManagementPage() {
const text = await response.text()
throw new Error(text || 'Save failed')
}
resetInviteEditor()
const data = await response.json()
const savedInvite = (data?.invite ?? null) as Invite | null
setCreatedInvite(savedInvite)
resetInviteEditor()
if (data?.email?.status === 'ok') {
setStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
@@ -405,7 +461,13 @@ export default function AdminInviteManagementPage() {
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
)
} else {
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
setStatus(
inviteEditingId == null
? inviteDeliveryMethod === 'manual'
? 'Invite created. Copy the link below when you are ready to share it.'
: 'Invite created.'
: 'Invite updated.'
)
}
await loadData()
} catch (err) {
@@ -472,6 +534,44 @@ export default function AdminInviteManagementPage() {
setError(null)
}
const sendSavedInvite = async (invite: Invite) => {
if (!invite.recipient_email) {
editInvite(invite)
setError('Add a recipient email before sending this invite.')
return
}
if (!emailConfigured?.configured) {
setActiveTab('emails')
setError(emailConfigured?.detail ?? 'Configure SMTP before sending invite emails.')
return
}
setSendingInviteId(invite.id)
setError(null)
setStatus(null)
try {
const response = await authFetch(`${getApiBase()}/admin/invites/email/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_key: 'invited',
invite_id: invite.id,
recipient_email: invite.recipient_email,
}),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const payload = await response.json().catch(() => null)
throw new Error(payload?.detail || `Email delivery failed (${response.status})`)
}
setStatus(`Invite ${invite.code} was sent to ${invite.recipient_email}.`)
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not send the invite email.')
} finally {
setSendingInviteId(null)
}
}
const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => {
setSelectedTemplateKey(templateKey)
loadTemplateEditor(templateKey, emailTemplates)
@@ -803,12 +903,25 @@ export default function AdminInviteManagementPage() {
const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length
const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length
const usableInvites = invites.filter((invite) => invite.is_usable !== false).length
const disabledInvites = invites.filter((invite) => invite.enabled === false).length
const invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || '').trim())).length
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
const masterInvite = invitePolicy?.master_invite ?? null
const selectedTemplate =
emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null
const inviteCodeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '')
const inviteIdentityReady = Boolean(
inviteForm.label.trim() && (!useCustomInviteCode || inviteCodeCharacters.length >= 6)
)
const createdInviteUrl = createdInvite
? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}`
: ''
const inviteAttentionCount = inviteSummary?.attention ?? invites.filter((invite) => !isInviteOperationallyReady(invite)).length
const filteredInvites = useMemo(() => {
if (inviteView === 'ready') return invites.filter(isInviteOperationallyReady)
if (inviteView === 'attention') return invites.filter((invite) => !isInviteOperationallyReady(invite))
if (inviteView === 'used') return invites.filter((invite) => invite.use_count > 0)
return invites
}, [inviteView, invites])
const inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<string, Invite>()
@@ -852,9 +965,9 @@ export default function AdminInviteManagementPage() {
childrenByInviter.set(key, bucket)
})
childrenByInviter.forEach((bucket) =>
childrenByInviter.forEach((bucket) => {
bucket.sort((a, b) => String(a.username || '').localeCompare(String(b.username || ''), undefined, { sensitivity: 'base' }))
)
})
const rows: Array<{
username: string
@@ -909,10 +1022,14 @@ export default function AdminInviteManagementPage() {
visited.add(userKey)
const nextPath = new Set(path)
nextPath.add(userKey)
;(childrenByInviter.get(userKey) ?? []).forEach((child) => walk(child, level + 1, nextPath))
;(childrenByInviter.get(userKey) ?? []).forEach((child) => {
walk(child, level + 1, nextPath)
})
}
;(childrenByInviter.get('__root__') ?? []).forEach((rootUser) => walk(rootUser, 0, new Set()))
;(childrenByInviter.get('__root__') ?? []).forEach((rootUser) => {
walk(rootUser, 0, new Set())
})
users.forEach((user) => {
const key = String(user.username || '').toLowerCase()
if (key && !visited.has(key)) {
@@ -988,7 +1105,7 @@ export default function AdminInviteManagementPage() {
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>{usableInvites} usable {disabledInvites} disabled</span>
<span>{inviteSummary?.ready ?? usableInvites} ready {inviteAttentionCount} need attention</span>
</div>
</div>
<div className="invite-admin-summary-row">
@@ -1048,29 +1165,77 @@ export default function AdminInviteManagementPage() {
</div>
</div>
</div>
<div className="admin-rail-card invite-automation-card">
<div>
<span className="admin-rail-eyebrow">Automatic setup</span>
<h2>Readiness checks</h2>
<p>Magent validates each link and applies safer defaults to new invitations.</p>
</div>
<div className="invite-readiness-list">
<button type="button" onClick={() => setActiveTab('invites')}>
<span>New invite defaults</span>
<strong>1 use · 7 days</strong>
</button>
<button type="button" onClick={() => setActiveTab('profiles')}>
<span>Active access profiles</span>
<strong>{activeProfiles > 0 ? `${activeProfiles} ready` : 'Needs setup'}</strong>
</button>
<button type="button" onClick={() => setActiveTab('bulk')}>
<span>Self-service policy</span>
<strong>{masterInvite ? 'Configured' : 'Not configured'}</strong>
</button>
<button type="button" onClick={() => setActiveTab('emails')}>
<span>Email delivery</span>
<strong>{emailConfigured?.configured ? 'Ready' : 'Needs setup'}</strong>
</button>
</div>
</div>
</div>
)
return (
<AdminShell
title="Invite management"
subtitle="Manage invite links, reusable profiles, and blanket invite-related defaults."
title="Invites"
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
rail={inviteManagementRail}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<section className="invite-operations-strip" aria-label="Invite operations overview">
<button type="button" onClick={() => { setInviteView('ready'); setActiveTab('invites') }}>
<span>Ready</span>
<strong>{inviteSummary?.ready ?? usableInvites}</strong>
<small>can be used now</small>
</button>
<button type="button" onClick={() => { setInviteView('attention'); setActiveTab('invites') }}>
<span>Needs attention</span>
<strong>{inviteAttentionCount}</strong>
<small>expired, used, disabled, or misconfigured</small>
</button>
<button type="button" onClick={() => { setInviteView('used'); setActiveTab('invites') }}>
<span>Successful sign-ups</span>
<strong>{inviteSummary?.used_signups ?? invites.reduce((total, invite) => total + invite.use_count, 0)}</strong>
<small>recorded from invite links</small>
</button>
<button type="button" onClick={() => setActiveTab('emails')}>
<span>Email delivery</span>
<strong>{emailConfigured?.configured ? 'Ready' : 'Setup'}</strong>
<small>{emailConfigured?.configured ? 'one-click sending available' : 'SMTP needs attention'}</small>
</button>
</section>
<div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
<button
type="button"
role="tab"
aria-selected={activeTab === 'bulk'}
className={activeTab === 'bulk' ? 'is-active' : ''}
onClick={() => setActiveTab('bulk')}
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
>
Blanket controls
Invite links
</button>
<button
type="button"
@@ -1084,20 +1249,11 @@ export default function AdminInviteManagementPage() {
<button
type="button"
role="tab"
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
aria-selected={activeTab === 'bulk'}
className={activeTab === 'bulk' ? 'is-active' : ''}
onClick={() => setActiveTab('bulk')}
>
Invites
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'trace'}
className={activeTab === 'trace' ? 'is-active' : ''}
onClick={() => setActiveTab('trace')}
>
Trace map
Automation
</button>
<button
type="button"
@@ -1106,7 +1262,16 @@ export default function AdminInviteManagementPage() {
className={activeTab === 'emails' ? 'is-active' : ''}
onClick={() => setActiveTab('emails')}
>
Email
Delivery
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'trace'}
className={activeTab === 'trace' ? 'is-active' : ''}
onClick={() => setActiveTab('trace')}
>
Lineage
</button>
</div>
<div className="admin-inline-actions invite-admin-tab-actions">
@@ -1117,6 +1282,7 @@ export default function AdminInviteManagementPage() {
type="button"
className="ghost-button"
onClick={() => {
setCreatedInvite(null)
resetInviteEditor()
setActiveTab('invites')
}}
@@ -1440,25 +1606,48 @@ export default function AdminInviteManagementPage() {
{activeTab === 'invites' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="invite-list-heading">
<div>
<h2>Invite links</h2>
<p className="lede">Copy and share invite links. Profiles can be applied per invite.</p>
<p className="lede">Magent checks availability, usage, expiry, and assigned profiles automatically.</p>
</div>
<fieldset className="invite-view-filter">
<legend>Filter invite links</legend>
{([
['all', `All ${invites.length}`],
['ready', `Ready ${inviteSummary?.ready ?? usableInvites}`],
['attention', `Attention ${inviteAttentionCount}`],
['used', `Used ${invites.filter((invite) => invite.use_count > 0).length}`],
] as Array<[InviteView, string]>).map(([value, label]) => (
<button
key={value}
type="button"
className={inviteView === value ? 'is-active' : ''}
onClick={() => setInviteView(value)}
>
{label}
</button>
))}
</fieldset>
</div>
{loading ? (
<div className="status-banner">Loading invites</div>
) : invites.length === 0 ? (
<div className="status-banner">No invites created yet.</div>
) : filteredInvites.length === 0 ? (
<div className="status-banner">No invite links match this view.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
{filteredInvites.map((invite) => (
<div key={invite.id} className={`admin-list-item invite-list-item is-${invite.operational_state ?? 'ready'}`}>
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
<strong>{invite.label || 'Unnamed invite'}</strong>
<span className={`small-pill invite-state-pill is-${invite.operational_state ?? 'ready'}`}>
{invite.state_label ?? (invite.is_usable ? 'Ready to use' : 'Unavailable')}
</span>
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</span>}
</div>
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
{invite.attention_reason ? <p className="invite-attention-reason">{invite.attention_reason}</p> : null}
<p className="admin-list-item-text"><code className="invite-code">{invite.code}</code></p>
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
{invite.description}
@@ -1479,8 +1668,16 @@ export default function AdminInviteManagementPage() {
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
Copy link
</button>
<button
type="button"
className="ghost-button"
disabled={sendingInviteId === invite.id || !isInviteOperationallyReady(invite)}
onClick={() => void sendSavedInvite(invite)}
>
{sendingInviteId === invite.id ? 'Sending…' : 'Send email'}
</button>
<button type="button" className="ghost-button" onClick={() => prepareInviteEmail(invite)}>
Email invite
Delivery options
</button>
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
Edit
@@ -1495,72 +1692,167 @@ export default function AdminInviteManagementPage() {
)}
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h2>
<p className="lede">
Link an invite to a profile to apply account defaults at sign-up.
</p>
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
<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 className="invite-flow-heading">
<div>
<span className="eyebrow">Invite flow</span>
<h2>{inviteEditingId == null ? 'Create an invite' : `Edit ${inviteForm.label || 'invite'}`}</h2>
<p className="lede">Set up the invite one decision at a time.</p>
</div>
<div className="invite-form-row-control invite-form-row-grid">
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>Cancel edit</button>
)}
</div>
{createdInvite && inviteEditingId == null ? (
<div className="invite-created-card" role="status">
<span className="eyebrow">Invite ready</span>
<h3>{createdInvite.label || 'Your invite'}</h3>
<p>
{createdInvite.recipient_email
? `The invitation was emailed to ${createdInvite.recipient_email}. You can also copy the link below.`
: 'Copy this link and send it to the person you are inviting.'}
</p>
<div className="invite-created-link">
<input value={createdInviteUrl} readOnly aria-label="Created invite link" />
<button type="button" onClick={() => void copyInviteLink(createdInvite)}>Copy link</button>
</div>
<button
type="button"
className="ghost-button"
onClick={() => {
setCreatedInvite(null)
resetInviteEditor()
}}
>
Create another invite
</button>
</div>
) : (
<form onSubmit={saveInvite} className="invite-flow-form">
<ol className="invite-flow-route" aria-label="Invite creation progress">
{['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => {
const step = index + 1
return (
<li
key={label}
className={step === inviteFlowStep ? 'is-active' : step < inviteFlowStep ? 'is-complete' : ''}
>
<span>{String(step).padStart(2, '0')}</span>
<strong>{label}</strong>
</li>
)
})}
</ol>
<section className={`invite-flow-step ${inviteFlowStep > 1 ? 'is-complete' : 'is-active'}`}>
<header>
<span className="invite-flow-number">01</span>
<div>
<span className="eyebrow">Identity</span>
<h3>Who is this invite for?</h3>
<p>Give it a name that will make sense when you return later.</p>
</div>
</header>
<div className="invite-flow-fields">
<label>
<span>Code (optional)</span>
<input
value={inviteForm.code}
onChange={(e) =>
setInviteForm((current) => ({ ...current, code: e.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
<span>Label</span>
<span>Invite name</span>
<input
value={inviteForm.label}
onChange={(e) =>
setInviteForm((current) => ({ ...current, label: e.target.value }))
}
placeholder="Staff invite batch"
onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))}
placeholder="Family, that guy from work, the neighbour"
/>
</label>
<label className="invite-flow-choice-line">
<input
type="checkbox"
checked={useCustomInviteCode}
onChange={(event) => {
setUseCustomInviteCode(event.target.checked)
if (!event.target.checked) {
setInviteForm((current) => ({ ...current, code: '' }))
}
}}
disabled={inviteEditingId != null}
/>
<span>
<strong>Choose a custom invite code</strong>
<small>The code appears at the end of the sign-up link. Leave this off and Magent will create a secure code for you.</small>
</span>
</label>
{useCustomInviteCode && (
<label>
<span>Custom code</span>
<input
value={inviteForm.code}
onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))}
placeholder="At least 6 letters or numbers"
disabled={inviteEditingId != null}
/>
<small>This becomes <code>/signup?code={inviteForm.code || 'YOUR-CODE'}</code>.</small>
</label>
)}
{inviteFlowStep === 1 && (
<div className="invite-flow-actions">
<button type="button" disabled={!inviteIdentityReady} onClick={() => setInviteFlowStep(2)}>
Continue to description
</button>
</div>
)}
</div>
</section>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
{inviteFlowStep >= 2 && (
<section className={`invite-flow-step ${inviteFlowStep > 2 ? 'is-complete' : 'is-active'}`}>
<header>
<span className="invite-flow-number">02</span>
<div>
<span className="eyebrow">Description</span>
<h3>Add a welcome note</h3>
<p>This optional message is shown to the person on the sign-up page.</p>
</div>
<div className="invite-form-row-control">
</header>
<div className="invite-flow-fields">
<label>
<span>Welcome note (optional)</span>
<textarea
rows={3}
value={inviteForm.description}
onChange={(e) =>
setInviteForm((current) => ({ ...current, description: e.target.value }))
}
placeholder="Optional note shown on the signup page"
onChange={(event) => setInviteForm((current) => ({ ...current, description: event.target.value }))}
placeholder="Welcome to Grizzlyflix. Use this link to create your account."
/>
</label>
{inviteFlowStep === 2 && (
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(1)}>Back</button>
<button type="button" className="ghost-button" onClick={() => {
setInviteForm((current) => ({ ...current, description: '' }))
setInviteFlowStep(3)
}}>Skip</button>
<button type="button" onClick={() => setInviteFlowStep(3)}>Continue</button>
</div>
)}
</div>
</section>
)}
<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>
{inviteFlowStep >= 3 && (
<section className={`invite-flow-step ${inviteFlowStep > 3 ? 'is-complete' : 'is-active'}`}>
<header>
<span className="invite-flow-number">03</span>
<div>
<span className="eyebrow">Access</span>
<h3>Choose their account access</h3>
<p>The selected profile controls their defaults when they sign up.</p>
</div>
<div className="invite-form-row-control invite-form-row-grid">
</header>
<div className="invite-flow-fields">
<div className="invite-flow-field-grid">
<label>
<span>Profile</span>
<select
value={inviteForm.profile_id}
onChange={(e) =>
setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
}
onChange={(event) => setInviteForm((current) => ({ ...current, profile_id: event.target.value }))}
>
<option value="">None</option>
<option value="">Use system defaults</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
@@ -1569,125 +1861,141 @@ export default function AdminInviteManagementPage() {
</select>
</label>
<label>
<span>Role override</span>
<span>Permission</span>
<select
value={inviteForm.role}
onChange={(e) =>
setInviteForm((current) => ({
onChange={(event) => setInviteForm((current) => ({
...current,
role: e.target.value as '' | 'user' | 'admin',
}))
}
role: event.target.value as '' | 'user' | 'admin',
}))}
>
<option value="">Use profile/default</option>
<option value="">Use profile default</option>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
</div>
<div className="invite-policy-note">
<strong>Invite limits are automatic</strong>
<span>Magent applies the configured usage and expiry rules. Those controls will live in Config.</span>
</div>
{inviteFlowStep === 3 && (
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(2)}>Back</button>
<button type="button" onClick={() => setInviteFlowStep(4)}>Continue to delivery</button>
</div>
)}
</div>
</section>
)}
{inviteFlowStep >= 4 && (
<section className="invite-flow-step is-active">
<header>
<span className="invite-flow-number">04</span>
<div>
<span className="eyebrow">Delivery</span>
<h3>How will they receive it?</h3>
<p>Copy the link yourself, or let Magent email it directly.</p>
</div>
</header>
<div className="invite-flow-fields">
<div className="invite-delivery-grid">
<button
type="button"
className={inviteDeliveryMethod === 'manual' ? 'is-selected' : ''}
onClick={() => {
setInviteDeliveryMethod('manual')
setInviteForm((current) => ({ ...current, recipient_email: '', message: '' }))
}}
>
<span className="eyebrow">Manual</span>
<strong>Give me a link</strong>
<small>Magent creates the URL. You copy and share it yourself.</small>
</button>
<button
type="button"
className={inviteDeliveryMethod === 'email' ? 'is-selected' : ''}
onClick={() => setInviteDeliveryMethod('email')}
>
<span className="eyebrow">Email</span>
<strong>Send it for me</strong>
<small>Magent emails the invitation and still gives you a copyable URL.</small>
</button>
</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>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(e) =>
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(e) =>
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
/>
</label>
</div>
{inviteDeliveryMethod === 'manual' && (
<div className="invite-delivery-summary">
<strong>Your link will appear as soon as the invite is created.</strong>
<span>No email address is required and Magent will not send a message.</span>
</div>
)}
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Recipient email is required. You can optionally send the invite immediately after saving.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
{inviteDeliveryMethod === 'email' && (
<div className="invite-flow-field-grid">
<label>
<span>Recipient email (required)</span>
<span>Recipient email</span>
<input
type="email"
required
value={inviteForm.recipient_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
}
placeholder="Required recipient email"
onChange={(event) => setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))}
placeholder="person@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<span>Email note (optional)</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(e) =>
setInviteForm((current) => ({ ...current, message: e.target.value }))
}
placeholder="Optional message appended to the invite email"
onChange={(event) => setInviteForm((current) => ({ ...current, message: event.target.value }))}
placeholder="A short personal message"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, send_email: e.target.checked }))
}
/>
Send You have been invited email after saving
</label>
</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">
{inviteEditingId != null && (
<label className="invite-status-control">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(e) =>
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
}
onChange={(event) => setInviteForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
Invite is enabled
<span>
<strong>{inviteForm.enabled ? 'Invite enabled' : 'Invite disabled'}</strong>
<small>Disable this existing invite to stop its link from accepting sign-ups.</small>
</span>
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
{inviteDeliveryMethod === 'email' && emailConfigured?.configured === false && (
<div className="status-banner">{emailConfigured.detail || 'Configure SMTP before sending an invite by email.'}</div>
)}
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(3)}>Back</button>
<button
type="submit"
disabled={
inviteSaving ||
!inviteDeliveryMethod ||
(inviteDeliveryMethod === 'email' && (!isValidEmail(inviteForm.recipient_email) || emailConfigured?.configured === false))
}
>
{inviteSaving
? 'Creating invite…'
: inviteEditingId != null
? 'Save invite'
: inviteDeliveryMethod === 'email'
? 'Create and email invite'
: 'Create invite link'}
</button>
</div>
</div>
</div>
</section>
)}
</form>
)}
</div>
</div>
)}
@@ -1970,7 +2278,8 @@ export default function AdminInviteManagementPage() {
<option value="direct">Direct / root only</option>
</select>
</label>
<div className="invite-trace-view-toggle" role="group" aria-label="Trace view mode">
<fieldset className="invite-trace-view-toggle">
<legend>Trace view mode</legend>
<button
type="button"
className={traceView === 'graph' ? 'is-active' : ''}
@@ -1985,7 +2294,7 @@ export default function AdminInviteManagementPage() {
>
List
</button>
</div>
</fieldset>
</div>
<div className="invite-trace-summary">
<span>{scopedInviteTraceRows.length} rows shown</span>
+5
View File
@@ -0,0 +1,5 @@
import PortalClient from '../../portal/PortalClient'
export default function AdminIssuesPage() {
return <PortalClient workspace="issue" />
}
+328 -7
View File
@@ -1,24 +1,345 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
type ServiceState = {
name: string
status: string
message?: string
}
type RecentRequest = {
id: number
title?: string | null
year?: number | null
statusLabel?: string | null
requestedBy?: string | null
createdAt?: string | null
}
type PortalOverview = {
overview?: {
total_items?: number
total_comments?: number
by_kind?: Record<string, number>
by_status?: Record<string, number>
}
my_items?: number
}
const formatDateTime = (value?: string | null) => {
if (!value) return 'Unknown'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const normalizeRecent = (items: any[]): RecentRequest[] =>
items
.filter((item) => item?.id)
.map((item) => ({
id: Number(item.id),
title: item.title ?? null,
year: item.year ?? null,
statusLabel: item.statusLabel ?? null,
requestedBy: item.requestedBy ?? null,
createdAt: item.createdAt ?? null,
}))
export default function AdminLandingPage() {
const router = useRouter()
const [services, setServices] = useState<ServiceState[]>([])
const [serviceOverall, setServiceOverall] = useState('unknown')
const [recent, setRecent] = useState<RecentRequest[]>([])
const [portalOverview, setPortalOverview] = useState<PortalOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string>>({})
const [serviceCheckedAt, setServiceCheckedAt] = useState<string | null>(null)
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const load = async () => {
setLoading(true)
setError(null)
try {
const baseUrl = getApiBase()
const [meResponse, serviceResponse, recentResponse, overviewResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/me`),
authFetch(`${baseUrl}/status/services`),
authFetch(`${baseUrl}/requests/recent?take=8&days=0`),
authFetch(`${baseUrl}/portal/overview`),
])
if (meResponse.status === 401) {
clearToken()
router.push('/login')
return
}
if (meResponse.status === 403) {
router.push('/')
return
}
const me = await meResponse.json()
if (me?.role !== 'admin') {
router.push('/')
return
}
if (serviceResponse.ok) {
const data = await serviceResponse.json()
setServiceOverall(data?.overall ?? 'unknown')
setServices(Array.isArray(data?.services) ? data.services : [])
setServiceCheckedAt(new Date().toISOString())
}
if (recentResponse.ok) {
const data = await recentResponse.json()
setRecent(Array.isArray(data?.results) ? normalizeRecent(data.results) : [])
}
if (overviewResponse.ok) {
const data = await overviewResponse.json()
setPortalOverview(data)
}
} catch (err) {
console.error(err)
setError('Unable to load the operations dashboard.')
} finally {
setLoading(false)
}
}
void load()
const refreshTimer = window.setInterval(async () => {
try {
const response = await authFetch(`${getApiBase()}/status/services`)
if (!response.ok) return
const data = await response.json()
setServiceOverall(data?.overall ?? 'unknown')
setServices(Array.isArray(data?.services) ? data.services : [])
setServiceCheckedAt(new Date().toISOString())
} catch (err) {
console.error(err)
}
}, 30_000)
return () => window.clearInterval(refreshTimer)
}, [router])
const testService = async (service: ServiceState) => {
const slug = service.name.toLowerCase().replace(/[^a-z0-9]/g, '')
setServiceTesting((current) => ({ ...current, [service.name]: true }))
setServiceTestResults((current) => {
const next = { ...current }
delete next[service.name]
return next
})
try {
const response = await authFetch(`${getApiBase()}/status/services/${slug}/test`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || `Service test failed: ${response.status}`)
}
const result = await response.json()
setServices((current) => current.map((item) =>
item.name === service.name
? { ...item, status: result?.status ?? item.status, message: result?.message ?? item.message }
: item
))
setServiceTestResults((current) => ({
...current,
[service.name]: result?.message || (result?.status === 'up' ? 'Connection test passed.' : 'Connection test completed.'),
}))
setServiceCheckedAt(new Date().toISOString())
} catch (err) {
console.error(err)
setServiceTestResults((current) => ({ ...current, [service.name]: 'Connection test failed.' }))
} finally {
setServiceTesting((current) => ({ ...current, [service.name]: false }))
}
}
const serviceCounts = useMemo(() => {
const up = services.filter((service) => service.status === 'up').length
const down = services.filter((service) => service.status === 'down').length
const degraded = services.filter((service) => service.status === 'degraded').length
const notConfigured = services.filter((service) => service.status === 'not_configured').length
return { up, down, degraded, notConfigured, total: services.length }
}, [services])
const issueCount = Number(portalOverview?.overview?.by_kind?.issue ?? 0)
const requestItemCount = Number(portalOverview?.overview?.by_kind?.request ?? 0)
const commentCount = Number(portalOverview?.overview?.total_comments ?? 0)
const rail = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Fleet summary</span>
<h2>{serviceCounts.up} of {serviceCounts.total || 0} online</h2>
<p>
{serviceCounts.down + serviceCounts.degraded > 0
? `${serviceCounts.down + serviceCounts.degraded} configured service${serviceCounts.down + serviceCounts.degraded === 1 ? '' : 's'} need attention.`
: 'No configured service is currently reporting a fault.'}
</p>
<a className="admin-rail-action" href="/admin/diagnostics">Open full diagnostics</a>
</div>
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Quick actions</span>
<div className="quick-action-grid">
<a href="/admin/requests-all">Review requests</a>
<a href="/admin/issues">Manage issues</a>
<a href="/users">User directory</a>
<a href="/admin/logs">Activity log</a>
</div>
</div>
</div>
)
return (
<AdminShell
title="Settings"
subtitle="Choose what you want to manage."
title="Admin overview"
subtitle="Service health, request movement, issue intake, and the controls that keep Magent running."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/')}>
Back to requests
<button type="button" onClick={() => router.push('/admin/diagnostics')}>
Run diagnostics
</button>
}
>
<section className="admin-section">
<div className="status-banner">
Pick a section from the left. Each page explains what it does and how it helps.
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
{error ? <div className="error-banner">{error}</div> : null}
<section className="ops-metric-grid">
<div className="ops-metric-card">
<span className="section-kicker">Services online</span>
<strong>
{serviceCounts.up}/{serviceCounts.total || 0}
</strong>
<p>{serviceOverall === 'up' ? 'All configured services are responding.' : 'Some services need review.'}</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Recent requests</span>
<strong>{recent.length}</strong>
<p>Loaded from the live request cache.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Open issue items</span>
<strong>{issueCount}</strong>
<p>{commentCount} portal comments recorded.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Portal requests</span>
<strong>{requestItemCount}</strong>
<p>Tracked in the dedicated request workflow.</p>
</div>
</section>
<section className="admin-zone fleet-status-panel">
<div className="section-header fleet-status-header">
<div>
<span className="section-kicker">Fleet service mesh</span>
<h2>System status</h2>
<p className="section-subtitle">
Admin-only connectivity status for the services used by Magent.
{serviceCheckedAt ? ` Last checked ${formatDateTime(serviceCheckedAt)}.` : ''}
</p>
</div>
<span className={`small-pill system-pill-${serviceOverall}`}>
{serviceOverall.replaceAll('_', ' ')}
</span>
</div>
{services.length === 0 ? (
<div className="status-banner">Service status is not available yet.</div>
) : (
<div className="fleet-service-grid">
{services.map((service) => {
const slug = service.name.toLowerCase().replace(/[^a-z0-9]/g, '')
const testing = Boolean(serviceTesting[service.name])
return (
<article className={`fleet-service-card system-${service.status}`} key={service.name}>
<div className="fleet-service-title">
<span className="system-dot" aria-hidden="true" />
<div>
<h3>{service.name}</h3>
<span className={`small-pill system-pill-${service.status}`}>
{service.status.replaceAll('_', ' ')}
</span>
</div>
</div>
<p>{serviceTestResults[service.name] ?? service.message ?? 'No recent detail was returned.'}</p>
<div className="fleet-service-actions">
<a href={`/admin/${slug}`}>Configure</a>
<button type="button" className="ghost-button" disabled={testing} onClick={() => void testService(service)}>
{testing ? 'Testing...' : 'Test connection'}
</button>
</div>
</article>
)
})}
</div>
)}
</section>
<section className="admin-zone">
<div className="section-header">
<div>
<h2>Recent activity</h2>
<p className="section-subtitle">Live request cache entries, newest first.</p>
</div>
</div>
{recent.length === 0 ? (
<div className="status-banner">No recent requests were returned.</div>
) : (
<div className="admin-table dashboard-activity-table">
<div className="admin-table-head">
<span>Request</span>
<span>Status</span>
<span>User</span>
<span>Created</span>
</div>
{recent.map((row) => (
<button
key={row.id}
type="button"
className="admin-table-row"
onClick={() => router.push(`/requests/${row.id}`)}
>
<span>
{row.title || `Request #${row.id}`}
{row.year ? ` (${row.year})` : ''}
</span>
<span>{row.statusLabel || 'Unknown'}</span>
<span>{row.requestedBy || 'Unknown'}</span>
<span>{formatDateTime(row.createdAt)}</span>
</button>
))}
</div>
)}
</section>
<section className="admin-zone">
<div className="section-header">
<div>
<h2>Attention states</h2>
<p className="section-subtitle">Service states that affect request processing.</p>
</div>
</div>
<div className="ops-status-strip">
<span>{serviceCounts.down} down</span>
<span>{serviceCounts.degraded} degraded</span>
<span>{serviceCounts.notConfigured} not configured</span>
</div>
</section>
</AdminShell>
+2 -2
View File
@@ -286,7 +286,7 @@ export default function AdminSystemGuidePage() {
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>Landing page</h3>
<p>Recent requests and service summaries refresh live for signed-in users.</p>
<p>Recent request activity refreshes live for signed-in users.</p>
</article>
<article className="system-guide-card">
<h3>Request pages</h3>
@@ -294,7 +294,7 @@ export default function AdminSystemGuidePage() {
</article>
<article className="system-guide-card">
<h3>Admin views</h3>
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
<p>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
</article>
</div>
</div>
+649
View File
@@ -181,6 +181,107 @@ body {
align-items: center;
}
.signed-in-context {
display: inline-flex;
align-items: center;
gap: 10px;
}
.user-view-toggle {
min-height: 34px;
padding: 7px 12px;
border: 1px solid rgba(126, 215, 255, 0.28);
border-radius: 999px;
background: rgba(14, 165, 233, 0.08);
color: #bfeaff;
box-shadow: none;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.user-view-toggle.is-active {
border-color: rgba(251, 191, 36, 0.5);
background: rgba(245, 158, 11, 0.13);
color: #fde68a;
}
.user-view-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 14px 0;
padding: 12px 16px;
border: 1px solid rgba(251, 191, 36, 0.38);
border-radius: 10px;
background: linear-gradient(90deg, rgba(245, 158, 11, 0.13), rgba(14, 165, 233, 0.06));
color: #f8e7b2;
}
.user-view-banner > div {
display: flex;
align-items: baseline;
gap: 12px;
}
.user-view-banner strong {
color: #fde68a;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.user-view-banner span {
color: var(--muted);
}
.user-view-banner button {
flex: 0 0 auto;
padding: 8px 12px;
border-color: rgba(251, 191, 36, 0.32);
background: rgba(245, 158, 11, 0.12);
color: #fde68a;
box-shadow: none;
}
.signed-in-header span {
display: block;
margin-top: 4px;
color: #fde68a;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
@media (max-width: 720px) {
.signed-in-context {
margin-left: auto;
}
.user-view-toggle {
min-height: 30px;
padding: 5px 9px;
font-size: 0.64rem;
}
.user-view-banner {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.user-view-banner > div {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.user-view-banner button {
width: 100%;
}
}
.avatar-button {
width: 44px;
height: 44px;
@@ -4557,6 +4658,51 @@ button:hover:not(:disabled) {
margin-bottom: 12px;
}
.invite-operations-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.invite-operations-strip > button {
display: grid;
gap: 5px;
min-height: 112px;
padding: 14px;
border: 1px solid rgba(126, 215, 255, 0.18);
border-radius: 8px;
background: linear-gradient(135deg, rgba(14, 165, 233, 0.055), rgba(255, 255, 255, 0.018));
text-align: left;
}
.invite-operations-strip > button:hover {
border-color: rgba(126, 215, 255, 0.42);
background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(255, 255, 255, 0.026));
}
.invite-operations-strip span,
.invite-operations-strip small {
color: #9ea7b6;
font-size: 0.7rem;
}
.invite-operations-strip span {
font-weight: 750;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.invite-operations-strip strong {
color: #eef7ff;
font-size: 1.55rem;
}
.invite-operations-strip small {
align-self: end;
line-height: 1.35;
}
.invite-admin-summary-tile {
min-height: 96px;
}
@@ -4685,6 +4831,128 @@ button:hover:not(:disabled) {
gap: 12px;
}
.invite-automation-card {
gap: 12px;
}
.invite-automation-card > div:first-child {
display: grid;
gap: 4px;
}
.invite-readiness-list {
display: grid;
gap: 6px;
}
.invite-readiness-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 9px 10px;
border: 1px solid rgba(255, 255, 255, 0.055);
background: rgba(255, 255, 255, 0.018);
text-align: left;
}
.invite-readiness-list span {
color: #9ea7b6;
font-size: 0.7rem;
}
.invite-readiness-list strong {
color: #dceafa;
font-size: 0.7rem;
white-space: nowrap;
}
.invite-list-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.invite-list-heading h2,
.invite-list-heading p {
margin: 0;
}
.invite-view-filter {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.invite-view-filter legend {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.invite-view-filter button {
padding: 7px 10px;
border-color: rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.02);
color: #aeb8c7;
font-size: 0.7rem;
}
.invite-view-filter button.is-active {
border-color: rgba(126, 215, 255, 0.38);
background: rgba(14, 165, 233, 0.1);
color: #eaf8ff;
}
.invite-list-item {
border-left: 3px solid rgba(126, 215, 255, 0.4);
}
.invite-list-item.is-ready {
border-left-color: #48e0b2;
}
.invite-list-item.is-expired,
.invite-list-item.is-exhausted,
.invite-list-item.is-profile_unavailable {
border-left-color: #ffc56d;
}
.invite-list-item.is-disabled {
border-left-color: #7e8999;
}
.invite-state-pill.is-ready {
border-color: rgba(72, 224, 178, 0.32);
color: #69edc5;
}
.invite-state-pill.is-expired,
.invite-state-pill.is-exhausted,
.invite-state-pill.is-profile_unavailable {
border-color: rgba(255, 197, 109, 0.34);
color: #ffd38a;
}
.invite-attention-reason {
margin: 5px 0 0;
color: #ffc56d;
font-size: 0.76rem;
}
.invite-admin-bulk-panel .user-bulk-groups {
display: grid;
gap: 10px;
@@ -4772,6 +5040,328 @@ button:hover:not(:disabled) {
gap: 10px;
}
.invite-expiry-presets {
display: grid;
gap: 7px;
}
.invite-expiry-presets > span {
color: #9ea7b6;
font-size: 0.76rem;
}
.invite-expiry-presets > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.invite-expiry-presets button {
padding: 7px 9px;
font-size: 0.7rem;
}
.invite-expiry-presets small {
color: #aeb8c7;
font-size: 0.7rem;
}
.invite-flow-heading {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
}
.invite-flow-heading h2,
.invite-created-card h3,
.invite-flow-step h3 {
margin: 0;
}
.invite-flow-heading .lede {
margin: 4px 0 0;
}
.invite-flow-form {
display: grid;
gap: 12px;
}
.invite-flow-route {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0 0 4px;
padding: 0;
list-style: none;
}
.invite-flow-route li {
position: relative;
display: flex;
gap: 9px;
align-items: center;
min-width: 0;
padding: 10px 12px;
border: 1px solid rgba(138, 155, 185, 0.18);
background: rgba(255, 255, 255, 0.015);
color: #77849a;
}
.invite-flow-route li span {
color: #718095;
font-family: var(--font-mono), monospace;
font-size: 0.68rem;
}
.invite-flow-route li strong {
overflow: hidden;
font-size: 0.76rem;
text-overflow: ellipsis;
}
.invite-flow-route li.is-active {
border-color: rgba(126, 215, 255, 0.5);
background: linear-gradient(135deg, rgba(14, 165, 233, 0.14), rgba(35, 74, 119, 0.11));
color: #edf8ff;
box-shadow: inset 0 2px 0 rgba(126, 215, 255, 0.75);
}
.invite-flow-route li.is-active span,
.invite-flow-route li.is-complete span {
color: #7ed7ff;
}
.invite-flow-route li.is-complete {
border-color: rgba(72, 224, 178, 0.28);
color: #c9d7e5;
}
.invite-flow-step {
overflow: hidden;
border: 1px solid rgba(138, 155, 185, 0.18);
background: linear-gradient(145deg, rgba(31, 43, 65, 0.82), rgba(20, 29, 45, 0.82));
}
.invite-flow-step.is-active {
border-color: rgba(126, 215, 255, 0.38);
box-shadow: inset 0 2px 0 rgba(126, 215, 255, 0.7);
}
.invite-flow-step.is-complete {
border-color: rgba(72, 224, 178, 0.24);
}
.invite-flow-step > header {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid rgba(138, 155, 185, 0.14);
}
.invite-flow-step > header > div {
display: grid;
gap: 3px;
}
.invite-flow-step > header p {
margin: 0;
color: #aeb8c7;
font-size: 0.78rem;
}
.invite-flow-number {
display: grid;
flex: 0 0 34px;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid rgba(126, 215, 255, 0.4);
border-radius: 50%;
color: #7ed7ff;
font-family: var(--font-mono), monospace;
font-size: 0.7rem;
font-weight: 700;
}
.invite-flow-fields {
display: grid;
gap: 12px;
padding: 16px;
}
.invite-flow-fields > label,
.invite-flow-field-grid > label {
display: grid;
gap: 6px;
}
.invite-flow-fields label > span:first-child,
.invite-flow-field-grid label > span:first-child {
color: #cbd5e3;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.035em;
text-transform: uppercase;
}
.invite-flow-fields input:not([type='checkbox']),
.invite-flow-fields select,
.invite-flow-fields textarea {
width: 100%;
}
.invite-flow-fields label > small {
color: #8f9caf;
font-size: 0.7rem;
line-height: 1.4;
}
.invite-flow-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.invite-flow-choice-line,
.invite-status-control {
grid-template-columns: auto minmax(0, 1fr);
gap: 10px !important;
align-items: flex-start;
padding: 12px;
border: 1px solid rgba(138, 155, 185, 0.2);
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
}
.invite-flow-choice-line > input,
.invite-status-control > input {
margin-top: 3px;
}
.invite-flow-choice-line > span,
.invite-status-control > span {
display: grid;
gap: 3px;
text-transform: none !important;
}
.invite-flow-choice-line strong,
.invite-status-control strong {
color: #e8edf5;
font-size: 0.8rem;
}
.invite-flow-choice-line small,
.invite-status-control small {
color: #9ba8ba;
font-size: 0.72rem;
font-weight: 400;
letter-spacing: normal;
line-height: 1.4;
}
.invite-flow-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
padding-top: 2px;
}
.invite-policy-note,
.invite-delivery-summary {
display: grid;
gap: 4px;
padding: 12px;
border: 1px solid rgba(72, 224, 178, 0.2);
background: rgba(72, 224, 178, 0.055);
}
.invite-policy-note strong,
.invite-delivery-summary strong {
color: #dffaf1;
font-size: 0.78rem;
}
.invite-policy-note span,
.invite-delivery-summary span {
color: #9fb4b5;
font-size: 0.72rem;
}
.invite-delivery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.invite-delivery-grid > button {
display: grid;
gap: 5px;
min-height: 112px;
padding: 14px;
border-color: rgba(138, 155, 185, 0.2);
background: rgba(255, 255, 255, 0.018);
color: #cbd5e3;
text-align: left;
}
.invite-delivery-grid > button strong {
color: #f2f6fb;
font-size: 0.92rem;
}
.invite-delivery-grid > button small {
color: #98a5b7;
font-size: 0.72rem;
font-weight: 400;
line-height: 1.4;
}
.invite-delivery-grid > button.is-selected {
border-color: rgba(126, 215, 255, 0.58);
background: linear-gradient(145deg, rgba(14, 165, 233, 0.16), rgba(35, 74, 119, 0.13));
box-shadow: inset 0 2px 0 #7ed7ff;
}
.invite-status-control {
border-color: rgba(255, 197, 109, 0.27);
background: rgba(255, 197, 109, 0.045);
}
.invite-created-card {
display: grid;
gap: 12px;
padding: 18px;
border: 1px solid rgba(72, 224, 178, 0.38);
background: linear-gradient(145deg, rgba(72, 224, 178, 0.1), rgba(20, 29, 45, 0.72));
box-shadow: inset 0 2px 0 rgba(72, 224, 178, 0.72);
}
.invite-created-card p {
margin: 0;
color: #b7c5d4;
}
.invite-created-card > .ghost-button {
justify-self: start;
}
.invite-created-link {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.invite-created-link input {
min-width: 0;
font-family: var(--font-mono), monospace;
}
.invite-email-template-picker {
display: flex;
flex-wrap: wrap;
@@ -4831,6 +5421,10 @@ button:hover:not(:disabled) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invite-operations-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invite-admin-bulk-grid {
grid-template-columns: 1fr;
}
@@ -4842,6 +5436,10 @@ button:hover:not(:disabled) {
.invite-form-row-grid {
grid-template-columns: 1fr;
}
.invite-flow-field-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 980px) {
@@ -4880,6 +5478,19 @@ button:hover:not(:disabled) {
grid-template-columns: 1fr;
}
.invite-operations-strip {
grid-template-columns: 1fr;
}
.invite-list-heading {
align-items: stretch;
flex-direction: column;
}
.invite-view-filter {
justify-content: flex-start;
}
.invite-admin-summary-row {
grid-template-columns: 1fr;
align-items: start;
@@ -4899,6 +5510,29 @@ button:hover:not(:disabled) {
}
}
@media (max-width: 680px) {
.invite-flow-heading {
display: grid;
}
.invite-flow-route {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invite-delivery-grid,
.invite-created-link {
grid-template-columns: 1fr;
}
.invite-flow-actions {
justify-content: stretch;
}
.invite-flow-actions button {
flex: 1 1 auto;
}
}
/* Enterprise UI tightening pass */
.admin-panel,
.user-detail-panel,
@@ -5009,6 +5643,21 @@ textarea {
background: rgba(255, 255, 255, 0.02);
border-radius: 5px;
overflow: hidden;
margin: 0;
min-inline-size: 0;
padding: 0;
}
.invite-trace-view-toggle legend {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.invite-trace-view-toggle button {
+7 -5
View File
@@ -1,11 +1,12 @@
import './globals.css'
import './ops-redesign.css'
import type { ReactNode } from 'react'
import HeaderActions from './ui/HeaderActions'
import HeaderIdentity from './ui/HeaderIdentity'
import ThemeToggle from './ui/ThemeToggle'
import BrandingFavicon from './ui/BrandingFavicon'
import BrandingLogo from './ui/BrandingLogo'
import HeaderActions from './ui/HeaderActions'
import HeaderIdentity from './ui/HeaderIdentity'
import SiteStatus from './ui/SiteStatus'
import UserViewBanner from './ui/UserViewBanner'
export const metadata = {
title: 'Magent',
@@ -24,18 +25,19 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<BrandingLogo className="brand-logo brand-logo--header" />
<div className="brand-stack">
<div className="brand">Magent</div>
<div className="tagline">Find and fix media requests fast.</div>
<div className="tagline">GrizzlyFlix media operations</div>
</div>
</a>
</div>
<div className="header-right">
<ThemeToggle />
<span className="beta-chip" title="Beta environment">Beta</span>
<HeaderIdentity />
</div>
<div className="header-nav">
<HeaderActions />
</div>
</header>
<UserViewBanner />
<SiteStatus />
{children}
</div>
+48
View File
@@ -0,0 +1,48 @@
'use client'
import { useEffect, useState } from 'react'
const USER_VIEW_STORAGE_KEY = 'magent_user_view_preview'
const USER_VIEW_EVENT = 'magent:user-view-change'
const readUserViewPreview = () => {
if (typeof window === 'undefined') return false
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === '1'
}
const applyDocumentMode = (enabled: boolean) => {
if (typeof document === 'undefined') return
document.documentElement.dataset.userView = enabled ? 'true' : 'false'
}
export const setUserViewPreview = (enabled: boolean) => {
if (typeof window === 'undefined') return
if (enabled) {
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, '1')
} else {
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY)
}
applyDocumentMode(enabled)
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }))
}
export const useUserViewPreview = () => {
const [enabled, setEnabled] = useState(false)
useEffect(() => {
const sync = () => {
const nextValue = readUserViewPreview()
applyDocumentMode(nextValue)
setEnabled(nextValue)
}
sync()
window.addEventListener(USER_VIEW_EVENT, sync)
window.addEventListener('storage', sync)
return () => {
window.removeEventListener(USER_VIEW_EVENT, sync)
window.removeEventListener('storage', sync)
}
}, [])
return enabled
}
+19 -6
View File
@@ -108,10 +108,17 @@ export default function LoginPage() {
})()
return (
<main className="card auth-card">
<main className="auth-screen">
<section className="auth-hero">
<div className="auth-mark">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Sign in</h1>
<p className="lede">{loginHelpText}</p>
</div>
<div className="auth-title-block">
<span className="section-kicker">Secure access</span>
<h1>Magent operational gateway</h1>
<p>{loginHelpText}</p>
</div>
</section>
<form
onSubmit={(event) => {
if (!primaryMode) {
@@ -121,23 +128,25 @@ export default function LoginPage() {
}
void submit(event, primaryMode)
}}
className="auth-form"
className="auth-form auth-panel"
>
<label>
Username
<span>Username</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder="Enter your username"
/>
</label>
<label>
Password
<span>Password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
placeholder="Enter your password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
@@ -171,6 +180,10 @@ export default function LoginPage() {
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
) : null}
<div className="auth-footnote">
<span className="live-dot" aria-hidden="true" />
Beta environment
</div>
</form>
</main>
)
@@ -0,0 +1,507 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type MediaType = 'movie' | 'tv'
type DiscoveryResult = {
title: string
year?: number | null
type: MediaType
tmdbId: number
requestId?: number | null
statusLabel?: string | null
overview?: string | null
posterPath?: string | null
backdropPath?: string | null
}
type RequestOptions = {
media: DiscoveryResult & {
seasons: Array<{
seasonNumber: number
name: string
episodeCount: number
airDate?: string | null
}>
existingRequestId?: number | null
}
destination: {
collector: 'Sonarr' | 'Radarr'
serverName: string
defaultProfileId: number
profiles: Array<{ id: number; name: string }>
}
}
type OperationEvent = {
id: string
service: string
state: 'active' | 'complete' | 'error'
message: string
duration_ms?: number | null
status_code?: number | null
}
type OperationProgress = {
status: 'running' | 'complete' | 'error'
duration_ms?: number | null
events: OperationEvent[]
}
const mediaChoices: Array<{
type: MediaType
eyebrow: string
title: string
description: string
collector: 'Radarr' | 'Sonarr'
icon: string
}> = [
{
type: 'movie',
eyebrow: 'Film',
title: 'Movie',
description: 'Find a film and send it through Seerr to Radarr.',
collector: 'Radarr',
icon: '/service-icons/radarr.svg',
},
{
type: 'tv',
eyebrow: 'Series',
title: 'TV show',
description: 'Choose a series, the seasons you want, and send it to Sonarr.',
collector: 'Sonarr',
icon: '/service-icons/sonarr.svg',
},
]
const artworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w342') => {
if (!path) return null
return `https://image.tmdb.org/t/p/${size}${path.startsWith('/') ? path : `/${path}`}`
}
const apiError = async (response: Response, fallback: string) => {
try {
const payload = await response.json()
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail
if (typeof payload?.message === 'string' && payload.message.trim()) return payload.message
} catch {
// The upstream response was not JSON. Use the friendly fallback below.
}
return fallback
}
const formatDuration = (milliseconds?: number | null) => {
if (milliseconds == null) return null
if (milliseconds < 1000) return `${Math.round(milliseconds)} ms`
return `${(milliseconds / 1000).toFixed(1)} s`
}
export default function NewRequestClient() {
const router = useRouter()
const searchSectionRef = useRef<HTMLElement | null>(null)
const resultsSectionRef = useRef<HTMLElement | null>(null)
const configureSectionRef = useRef<HTMLElement | null>(null)
const [mediaType, setMediaType] = useState<MediaType | null>(null)
const [query, setQuery] = useState('')
const [searching, setSearching] = useState(false)
const [searchAttempted, setSearchAttempted] = useState(false)
const [results, setResults] = useState<DiscoveryResult[]>([])
const [selected, setSelected] = useState<DiscoveryResult | null>(null)
const [options, setOptions] = useState<RequestOptions | null>(null)
const [loadingOptions, setLoadingOptions] = useState(false)
const [profileId, setProfileId] = useState<number | null>(null)
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
const [submitting, setSubmitting] = useState(false)
const [operation, setOperation] = useState<OperationProgress | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
useEffect(() => {
if (!getToken()) router.push('/login')
}, [router])
const resetAfterType = (nextType: MediaType) => {
setMediaType(nextType)
setQuery('')
setResults([])
setSearchAttempted(false)
setSelected(null)
setOptions(null)
setProfileId(null)
setSelectedSeasons([])
setOperation(null)
setError(null)
setSuccess(null)
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 80)
}
const runSearch = async (event: React.FormEvent) => {
event.preventDefault()
if (!mediaType) return
const term = query.trim()
if (!term) {
setError('Enter a title to search for.')
return
}
setSearching(true)
setSearchAttempted(true)
setSelected(null)
setOptions(null)
setOperation(null)
setError(null)
setSuccess(null)
try {
const baseUrl = getApiBase()
const params = new URLSearchParams({ query: term, media_type: mediaType })
const response = await authFetch(`${baseUrl}/requests/search?${params.toString()}`)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await apiError(response, `Search failed (${response.status}).`))
const payload = await response.json()
const mapped: DiscoveryResult[] = Array.isArray(payload?.results)
? payload.results
.filter((item: any) => item?.type === mediaType && Number(item?.tmdbId) > 0)
.map((item: any) => ({
title: String(item?.title || 'Untitled'),
year: typeof item?.year === 'number' ? item.year : null,
type: mediaType,
tmdbId: Number(item.tmdbId),
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
statusLabel: typeof item?.statusLabel === 'string' ? item.statusLabel : null,
overview: typeof item?.overview === 'string' ? item.overview : null,
posterPath: item?.posterPath ?? null,
backdropPath: item?.backdropPath ?? null,
}))
: []
setResults(mapped)
window.setTimeout(() => resultsSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
} catch (caught) {
setResults([])
setError(caught instanceof Error ? caught.message : 'Search is unavailable right now.')
} finally {
setSearching(false)
}
}
const selectResult = async (item: DiscoveryResult) => {
setSelected(item)
setOptions(null)
setProfileId(null)
setSelectedSeasons([])
setOperation(null)
setError(null)
setSuccess(null)
if (item.requestId) {
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
return
}
setLoadingOptions(true)
try {
const baseUrl = getApiBase()
const params = new URLSearchParams({ media_type: item.type, tmdb_id: String(item.tmdbId) })
const response = await authFetch(`${baseUrl}/requests/request-options?${params.toString()}`)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await apiError(response, `Could not load request options (${response.status}).`))
const payload = (await response.json()) as RequestOptions
const refreshedSelection: DiscoveryResult = {
...item,
title: payload.media.title || item.title,
year: payload.media.year ?? item.year,
overview: payload.media.overview || item.overview,
posterPath: payload.media.posterPath || item.posterPath,
backdropPath: payload.media.backdropPath || item.backdropPath,
requestId: payload.media.existingRequestId || item.requestId,
statusLabel: payload.media.existingRequestId ? 'Already requested' : item.statusLabel,
}
setSelected(refreshedSelection)
if (payload.media.existingRequestId) {
setResults((current) => current.map((result) => result.tmdbId === item.tmdbId && result.type === item.type
? refreshedSelection
: result))
return
}
setOptions(payload)
setProfileId(payload.destination.defaultProfileId)
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber))
} catch (caught) {
setError(caught instanceof Error ? caught.message : 'Could not load request options.')
} finally {
setLoadingOptions(false)
window.setTimeout(() => configureSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80)
}
}
const pollOperation = async (operationId: string) => {
try {
const response = await authFetch(`${getApiBase()}/operations/${operationId}`)
if (response.ok) setOperation((await response.json()) as OperationProgress)
} catch {
// The request response remains authoritative if a progress poll is interrupted.
}
}
const submitRequest = async () => {
if (!selected || !options || !profileId) return
if (selected.type === 'tv' && selectedSeasons.length === 0) {
setError('Select at least one season.')
return
}
setSubmitting(true)
setError(null)
setSuccess(null)
const operationId = globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}`
setOperation({ status: 'running', events: [] })
const interval = window.setInterval(() => void pollOperation(operationId), 500)
try {
const response = await authFetch(`${getApiBase()}/requests/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Magent-Operation-ID': operationId,
'X-Magent-Operation-Label': `Requesting ${selected.title}`,
},
body: JSON.stringify({
mediaType: selected.type,
tmdbId: selected.tmdbId,
profileId,
seasons: selected.type === 'tv' ? selectedSeasons : undefined,
}),
})
await pollOperation(operationId)
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (!response.ok) throw new Error(await apiError(response, `Request failed (${response.status}).`))
const payload = await response.json()
const requestId = typeof payload?.requestId === 'number' ? payload.requestId : null
setSelected((current) => current ? { ...current, requestId, statusLabel: payload?.statusLabel ?? current.statusLabel } : current)
setResults((current) => current.map((item) => item.tmdbId === selected.tmdbId && item.type === selected.type
? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
: item))
setSuccess(requestId ? `Request #${requestId} has been accepted by Seerr.` : 'Your request has been accepted by Seerr.')
} catch (caught) {
setError(caught instanceof Error ? caught.message : 'The request could not be submitted.')
} finally {
window.clearInterval(interval)
await pollOperation(operationId)
setSubmitting(false)
}
}
const setEverySeason = (checked: boolean) => {
setSelectedSeasons(checked && options ? options.media.seasons.map((season) => season.seasonNumber) : [])
}
const selectedPoster = artworkUrl(selected?.posterPath, 'w185')
return (
<main className="card request-portal-page">
<header className="request-portal-hero">
<div>
<span className="section-kicker">New requests</span>
<h1>Find something worth watching.</h1>
<p>Choose what you want, find the right title, then tailor the request before it goes to Seerr.</p>
</div>
<div className="request-portal-route">
<span>Seerr</span><i aria-hidden="true" />
<span>{mediaType === 'tv' ? 'Sonarr' : mediaType === 'movie' ? 'Radarr' : 'Collector'}</span><i aria-hidden="true" />
<span>Grizzlyflix</span>
</div>
</header>
{error && <div className="error-banner request-flow-alert">{error}</div>}
{success && <div className="status-banner request-flow-alert">{success}</div>}
<section className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">01</span>
<div><span>Start here</span><h2>What are you looking for?</h2></div>
</div>
<div className="request-type-grid">
{mediaChoices.map((choice) => (
<button
key={choice.type}
type="button"
className={`request-type-card ${mediaType === choice.type ? 'is-selected' : ''}`}
onClick={() => resetAfterType(choice.type)}
aria-pressed={mediaType === choice.type}
>
<span className="request-type-card-body">
<span className="request-service-icon">
<img src={choice.icon} alt={`${choice.collector} logo`} />
</span>
<span className="request-type-card-copy">
<span>{choice.eyebrow}</span>
<strong>{choice.title}</strong>
<span className="request-type-description">{choice.description}</span>
<b>{mediaType === choice.type ? 'Selected' : `Choose ${choice.title.toLowerCase()}`}</b>
</span>
</span>
</button>
))}
</div>
</section>
{mediaType && (
<section ref={searchSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">02</span>
<div><span>{mediaType === 'tv' ? 'TV show selected' : 'Movie selected'}</span><h2>Search for the title</h2></div>
</div>
<form className="request-flow-search" onSubmit={runSearch}>
<label htmlFor="request-title-search">Title</label>
<div>
<input
id="request-title-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={mediaType === 'tv' ? 'Search TV shows' : 'Search movies'}
autoComplete="off"
/>
<button type="submit" disabled={searching}>{searching ? 'Searching…' : 'Search Seerr'}</button>
</div>
</form>
</section>
)}
{mediaType && searchAttempted && !searching && (
<section ref={resultsSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">03</span>
<div><span>Search results</span><h2>{results.length ? 'Select the right title' : 'No matches found'}</h2></div>
</div>
{results.length === 0 ? (
<div className="request-flow-empty">
<strong>Nothing matched {query.trim()}.</strong>
<p>Check the spelling or try a shorter title.</p>
</div>
) : (
<div className="request-result-grid">
{results.map((item) => {
const poster = artworkUrl(item.posterPath)
const isSelected = selected?.tmdbId === item.tmdbId && selected.type === item.type
return (
<button
key={`${item.type}:${item.tmdbId}`}
type="button"
className={`request-result-card ${isSelected ? 'is-selected' : ''}`}
onClick={() => void selectResult(item)}
>
<span className="request-result-poster">
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
</span>
<span className="request-result-copy">
<small>{item.type === 'tv' ? 'TV show' : 'Movie'}{item.year ? ` · ${item.year}` : ''}</small>
<strong>{item.title}</strong>
<p>{item.overview || 'Select this title to view the available request options.'}</p>
<b>{item.requestId ? item.statusLabel || 'Already requested' : isSelected ? 'Selected' : 'Select title'}</b>
</span>
</button>
)
})}
</div>
)}
</section>
)}
{selected && (
<section ref={configureSectionRef} className="request-flow-stage is-current request-configure-stage">
<div className="request-flow-heading">
<span className="request-flow-number">04</span>
<div><span>Final step</span><h2>{selected.requestId ? 'This title is already in the pipeline' : 'Configure your request'}</h2></div>
</div>
<div className="request-selection-summary">
<span className="request-selection-poster">
{selectedPoster ? <img src={selectedPoster} alt="" /> : <i>No artwork</i>}
</span>
<div>
<small>{selected.type === 'tv' ? 'TV show' : 'Movie'}{selected.year ? ` · ${selected.year}` : ''}</small>
<h3>{selected.title}</h3>
<p>{selected.overview || 'Ready to configure.'}</p>
</div>
</div>
{selected.requestId ? (
<div className="request-existing-state">
<div><span>Current status</span><strong>{selected.statusLabel || 'Already requested'}</strong><p>Request #{selected.requestId} is already being tracked by Magent.</p></div>
<button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Open request</button>
</div>
) : loadingOptions ? (
<div className="request-flow-empty"><strong>Checking Seerr and {selected.type === 'tv' ? 'Sonarr' : 'Radarr'}</strong><p>Loading valid profiles and request choices.</p></div>
) : options ? (
<div className="request-options-layout">
{selected.type === 'tv' && (
<fieldset className="request-season-picker">
<legend>Which seasons?</legend>
<div className="request-season-actions">
<button type="button" onClick={() => setEverySeason(true)}>Select all</button>
<button type="button" onClick={() => setEverySeason(false)}>Clear</button>
</div>
<div className="request-season-grid">
{options.media.seasons.map((season) => (
<label key={season.seasonNumber} className={selectedSeasons.includes(season.seasonNumber) ? 'is-selected' : ''}>
<input
type="checkbox"
checked={selectedSeasons.includes(season.seasonNumber)}
onChange={(event) => setSelectedSeasons((current) => event.target.checked
? [...current, season.seasonNumber].sort((a, b) => a - b)
: current.filter((value) => value !== season.seasonNumber))}
/>
<span><strong>{season.name}</strong><small>{season.episodeCount} episode{season.episodeCount === 1 ? '' : 's'}</small></span>
</label>
))}
</div>
</fieldset>
)}
<label className="request-profile-field">
<span>Quality profile</span>
<select value={profileId ?? ''} onChange={(event) => setProfileId(Number(event.target.value))}>
{options.destination.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
<small>Live options from {options.destination.collector}. Seerr will use {options.destination.serverName}.</small>
</label>
<div className="request-submit-bar">
<div><span>Delivery route</span><strong>Seerr {options.destination.collector} Grizzlyflix</strong><small>Only settings currently accepted by {options.destination.collector} are available.</small></div>
<button type="button" onClick={() => void submitRequest()} disabled={submitting || !profileId || (selected.type === 'tv' && selectedSeasons.length === 0)}>
{submitting ? 'Sending request…' : `Request ${selected.type === 'tv' ? 'show' : 'movie'}`}
</button>
</div>
</div>
) : null}
{operation && (
<div className={`request-submit-progress is-${operation.status}`} aria-live="polite">
<header><div><span>Remote activity</span><strong>{operation.status === 'running' ? 'Sending your request' : operation.status === 'complete' ? 'Request hand-off complete' : 'Request hand-off needs attention'}</strong></div>{formatDuration(operation.duration_ms) && <small>{formatDuration(operation.duration_ms)}</small>}</header>
<div>
{operation.events.map((event) => (
<p key={event.id} className={`is-${event.state}`}><i aria-hidden="true" /><span><strong>{event.service}</strong>{event.message}</span><small>{formatDuration(event.duration_ms)}{event.status_code ? ` · HTTP ${event.status_code}` : ''}</small></p>
))}
{operation.events.length === 0 && <p className="is-active"><i aria-hidden="true" /><span><strong>Magent</strong>Preparing the request</span></p>}
</div>
</div>
)}
{success && selected.requestId && (
<div className="request-complete-actions"><button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Track request #{selected.requestId}</button><button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>Request something else</button></div>
)}
</section>
)}
</main>
)
}
+9
View File
@@ -0,0 +1,9 @@
import NewRequestClient from './NewRequestClient'
export const metadata = {
title: 'New Requests | Magent',
}
export default function NewRequestsPage() {
return <NewRequestClient />
}
File diff suppressed because it is too large Load Diff
+94 -291
View File
@@ -64,14 +64,6 @@ export default function HomePage() {
const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false)
const [servicesStatus, setServicesStatus] = useState<
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
>(null)
const [servicesLoading, setServicesLoading] = useState(false)
const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const submit = (event: React.FormEvent) => {
event.preventDefault()
@@ -84,61 +76,6 @@ export default function HomePage() {
void runSearch(trimmed)
}
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
const updateServiceStatus = (name: string, status: string, message?: string) => {
setServicesStatus((prev) => {
if (!prev) return prev
return {
...prev,
services: prev.services.map((service) =>
service.name === name ? { ...service, status, message } : service
),
}
})
}
const testService = async (name: string) => {
const slug = toServiceSlug(name)
setServiceTesting((prev) => ({ ...prev, [name]: true }))
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
method: 'POST',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Service test failed: ${response.status}`)
}
const data = await response.json()
const status = data?.status ?? 'unknown'
const message =
data?.message ||
(status === 'up'
? 'API OK'
: status === 'down'
? 'API unreachable'
: status === 'degraded'
? 'Health warnings'
: status === 'not_configured'
? 'Not configured'
: 'Unknown')
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
updateServiceStatus(name, status, data?.message)
} catch (error) {
console.error(error)
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
} finally {
setServiceTesting((prev) => ({ ...prev, [name]: false }))
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
@@ -198,45 +135,7 @@ export default function HomePage() {
if (!authReady) {
return
}
const load = async () => {
setServicesLoading(true)
setServicesError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Service status failed: ${response.status}`)
}
const data = await response.json()
setServicesStatus(data)
} catch (error) {
console.error(error)
setServicesError('Service status is not available right now.')
} finally {
setServicesLoading(false)
}
}
void load()
if (liveStreamConnected) {
return
}
const timer = setInterval(load, 30000)
return () => clearInterval(timer)
}, [authReady, liveStreamConnected, router])
useEffect(() => {
if (!authReady) {
setLiveStreamConnected(false)
return
}
if (!getToken()) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
@@ -257,14 +156,8 @@ export default function HomePage() {
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`
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') {
@@ -281,29 +174,14 @@ export default function HomePage() {
}
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)
}
} catch (error) {
if (closed) return
console.error(error)
setLiveStreamConnected(false)
}
}
@@ -311,7 +189,6 @@ export default function HomePage() {
return () => {
closed = true
setLiveStreamConnected(false)
source?.close()
}
}, [authReady, recentDays, recentStage])
@@ -362,89 +239,95 @@ export default function HomePage() {
return date.toLocaleString()
}
const activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
}).length
const readyRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return label.includes('ready') || label.includes('available')
}).length
return (
<main className="card">
<div className="layout-grid">
<section className="recent centerpiece">
<div className="system-status">
<div className="system-header">
<h2>System status</h2>
<span
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
>
{servicesLoading
? 'Checking services...'
: servicesError
? 'Status not available yet'
: servicesStatus?.overall === 'up'
? 'Services are up and running'
: servicesStatus?.overall === 'down'
? 'Something is down'
: 'Some services need attention'}
</span>
<main className="card home-page">
<section className="home-command">
<div className="home-command-copy">
<span className="section-kicker">Request lookup</span>
<h1>My requests</h1>
<p>
Enter a title and year, or jump straight to a request using its request number.
</p>
</div>
<div className="system-list">
{(() => {
const order = [
'Seerr',
'Sonarr',
'Radarr',
'Prowlarr',
'qBittorrent',
'Jellyfin',
]
const items = servicesStatus?.services ?? []
return order.map((name) => {
const item = items.find((entry) => entry.name === name)
const status = item?.status ?? 'unknown'
const testing = serviceTesting[name] ?? false
return (
<div key={name} className={`system-item system-${status}`}>
<span className="system-dot" />
<div className="system-meta">
<span className="system-name">{name}</span>
{serviceTestResults[name] && (
<span className="system-test-message">{serviceTestResults[name]}</span>
)}
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
<input
id="request-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Dune 2021 or 1289"
/>
<button type="submit">Find request</button>
</div>
<div className="system-actions">
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</form>
</section>
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
<div className="home-section-heading">
<div>
<span className="section-kicker">Search results</span>
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
</div>
<button type="button" className="ghost-button" onClick={() => {
setSearchResults([])
setSearchError(null)
}}>
Clear
</button>
</div>
{searchError ? (
<div className="error-banner">{searchError}</div>
) : (
<div className="home-result-grid">
{searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
className="home-result-card"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
>
<span>
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
</span>
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
</button>
))}
</div>
)
})
})()}
)}
</section>
)}
<section className="home-metric-strip" aria-label="Request summary">
<div><span>In view</span><strong>{recent.length}</strong></div>
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
</section>
<section className="recent home-recent">
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
</div>
</div>
<div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Show</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={0}>All</option>
<span>Period</span>
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
<option value={0}>All time</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
@@ -453,34 +336,28 @@ export default function HomePage() {
</label>
<label className="recent-filter">
<span>Stage</span>
<select
value={recentStage}
onChange={(event) => setRecentStage(event.target.value)}
>
<select value={recentStage} onChange={(event) => setRecentStage(event.target.value)}>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
</div>
)}
</div>
<div className="recent-grid">
<div className="recent-grid home-recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests</span>
<span className="loading-text">Loading recent requests...</span>
</div>
) : recentError ? (
<button type="button" disabled>
{recentError}
</button>
<div className="error-banner">{recentError}</div>
) : recent.length === 0 ? (
<button type="button" disabled>
No recent requests found
</button>
<div className="home-empty-state">
<strong>No requests match these filters</strong>
<span>Try a wider period or a different stage.</span>
</div>
) : (
recent.map((item) => (
<button
@@ -489,103 +366,29 @@ export default function HomePage() {
onClick={() => router.push(`/requests/${item.id}`)}
className="recent-card"
>
{item.artwork?.poster_url && (
{item.artwork?.poster_url ? (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
)}
<span className="recent-info">
<span className="recent-title">
{item.title || 'Untitled'}
{item.year ? ` (${item.year})` : ''}
</span>
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
<span className="recent-meta">
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
{item.id}
{item.statusLabel || 'Status not available yet'} · Request {item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
</button>
))
)}
</div>
</section>
<aside className="side-panel">
<section className="main-panel find-panel">
<div className="find-header">
<h1>Search all requests</h1>
<p className="lede">
Search any request by title + year or request number and see whether it already
exists in the system.
</p>
</div>
<div className="find-controls">
<form onSubmit={submit} className="search search-row">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="e.g. Dune 2021 or 1289"
/>
<button type="submit">Check status</button>
</form>
<div className="filters filters-compact">
<div className="filter">
<span>Type</span>
<div className="pill-group">
<button type="button">TV</button>
<button type="button">Movie</button>
</div>
</div>
<div className="filter">
<span>Status</span>
<div className="pill-group">
<button type="button">Pending</button>
<button type="button">Approved</button>
<button type="button">Processing</button>
<button type="button">Failed</button>
<button type="button">Available</button>
</div>
</div>
</div>
</div>
<section className="recent results-panel">
<h2>Search results</h2>
<div className="recent-grid">
{searchError ? (
<button type="button" disabled>
{searchError}
</button>
) : searchResults.length === 0 ? (
<button type="button" disabled>
No matches yet
</button>
) : (
searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
disabled={!item.requestId}
onClick={() =>
item.requestId && router.push(`/requests/${item.requestId}`)
}
>
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
{!item.requestId
? '- not requested'
: item.statusLabel
? `- ${item.statusLabel}`
: '- already requested'}
</button>
))
)}
</div>
</section>
</section>
</aside>
</div>
</main>
)
}
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,5 @@
import { redirect } from 'next/navigation'
export default function PortalIndexPage() {
redirect('/portal/requests')
redirect('/new-requests')
}
+2 -3
View File
@@ -1,6 +1,5 @@
import PortalClient from '../PortalClient'
import { redirect } from 'next/navigation'
export default function RequestPortalPage() {
return <PortalClient workspace="request" />
redirect('/new-requests')
}
+2 -40
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type ProfileInfo = {
@@ -315,47 +315,9 @@ export default function ProfileInvitesPage() {
<main className="card">
<div className="user-directory-panel-header profile-page-header">
<div>
<h1>My invites</h1>
<h1>Invites</h1>
<p className="lede">Create invite links, email them directly, and track who you have invited.</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
Back to profile
</button>
</div>
</div>
{profile ? (
<div className="status-banner">
Signed in as <strong>{profile.username}</strong> ({profile.role}).
</div>
) : null}
<div className="profile-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
<button type="button" role="tab" aria-selected={false} onClick={() => router.push('/profile')}>
Overview
</button>
<button
type="button"
role="tab"
aria-selected={false}
onClick={() => router.push('/profile?tab=activity')}
>
Activity
</button>
<button type="button" role="tab" aria-selected className="is-active">
My invites
</button>
<button
type="button"
role="tab"
aria-selected={false}
onClick={() => router.push('/profile?tab=security')}
>
Security
</button>
</div>
</div>
{inviteError && <div className="error-banner">{inviteError}</div>}
+1 -6
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type ProfileInfo = {
@@ -265,11 +265,6 @@ export default function ProfilePage() {
>
Activity
</button>
{canManageInvites ? (
<button type="button" role="tab" aria-selected={false} onClick={() => router.push(inviteLink)}>
My invites
</button>
) : null}
<button
type="button"
role="tab"
File diff suppressed because it is too large Load Diff
+1
View File
@@ -22,6 +22,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
<main className="card admin-card">
<div className="admin-header">
<div>
<span className="section-kicker">Beta stream</span>
<h1>{title}</h1>
{subtitle && <p className="lede">{subtitle}</p>}
</div>
+27 -10
View File
@@ -4,35 +4,50 @@ import { usePathname } from 'next/navigation'
const NAV_GROUPS = [
{
title: 'Services',
title: 'Configuration',
items: [
{ href: '/admin', label: 'Config overview' },
{ href: '/admin/general', label: 'Application & proxy' },
{ href: '/admin/site', label: 'Site & login' },
{ href: '/admin/notifications', label: 'Notifications' },
],
},
{
title: 'Media Services',
items: [
{ href: '/admin/general', label: 'General' },
{ href: '/admin/seerr', label: 'Seerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr' },
{ href: '/admin/bazarr', label: 'Bazarr' },
{ href: '/admin/prowlarr', label: 'Prowlarr' },
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
],
},
{
title: 'Requests',
title: 'Request Pipeline',
items: [
{ href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/requests', label: 'Sync & retention' },
{ href: '/admin/issue-workflow', label: 'Issue workflow' },
{ href: '/admin/cache', label: 'Request cache' },
{ href: '/admin/artwork', label: 'Artwork cache' },
{ href: '/admin/requests-all', label: 'All requests' },
{ href: '/admin/cache', label: 'Cache Control' },
],
},
{
title: 'Admin',
title: 'Users & Access',
items: [
{ href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/system', label: 'How it works' },
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' },
],
},
{
title: 'System',
items: [
{ href: '/admin/diagnostics', label: 'System health' },
{ href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' },
{ href: '/admin/system', label: 'How it works' },
],
},
]
@@ -49,7 +64,9 @@ export default function AdminSidebar() {
{group.items.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href))
(item.href !== '/' &&
item.href !== '/admin' &&
pathname.startsWith(`${item.href}/`))
return (
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
{item.label}
+32 -2
View File
@@ -1,14 +1,44 @@
'use client'
import { useState } from 'react'
type BrandingLogoProps = {
className?: string
alt?: string
}
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
const [loaded, setLoaded] = useState(false)
const [failed, setFailed] = useState(false)
return (
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
{!failed ? (
<img
className={className}
className={loaded ? 'is-loaded' : undefined}
src="/api/branding/logo.png"
alt={alt}
alt=""
aria-hidden="true"
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
) : null}
{!loaded ? (
<svg aria-hidden="true" viewBox="0 0 64 64" focusable="false">
<defs>
<linearGradient id="magentLogoGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#7ed7ff" />
<stop offset="100%" stopColor="#c6c1ff" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="12" fill="#0b1328" />
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
<path
d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z"
fill="url(#magentLogoGlow)"
/>
</svg>
) : null}
</span>
)
}
+86 -14
View File
@@ -1,29 +1,46 @@
'use client'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
export default function HeaderActions() {
const [signedIn, setSignedIn] = useState(false)
const [role, setRole] = useState<string | null>(null)
const [showRequestsNav, setShowRequestsNav] = useState(true)
const pathname = usePathname()
useEffect(() => {
const token = getToken()
setSignedIn(Boolean(token))
if (!token) {
setShowRequestsNav(true)
return
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
const [response, siteResponse] = await Promise.all([
authFetch(`${baseUrl}/auth/me`),
fetch(`${baseUrl}/site/public`).catch(() => null),
])
if (!response.ok) {
clearToken()
setSignedIn(false)
setRole(null)
return
}
await response.json()
const data = await response.json()
setRole(data?.role ?? null)
if (siteResponse?.ok) {
const siteData = await siteResponse.json()
setShowRequestsNav(siteData?.navigation?.showRequests !== false)
} else {
setShowRequestsNav(true)
}
} catch (err) {
console.error(err)
setShowRequestsNav(true)
}
}
void load()
@@ -33,18 +50,73 @@ export default function HeaderActions() {
return null
}
const roleItems =
role === null
? []
: role === 'admin'
? [
{
href: '/profile/invites',
label: 'Invites',
match: (path: string) => path.startsWith('/profile/invites'),
},
{
href: '/admin',
label: 'Config',
match: (path: string) => path.startsWith('/admin'),
},
]
: [
{
href: '/profile',
label: 'Profile',
match: (path: string) => path.startsWith('/profile') && !path.startsWith('/profile/invites'),
},
{
href: '/profile/invites',
label: 'Invites',
match: (path: string) => path.startsWith('/profile/invites'),
},
]
const commonItems = [
{
href: '/',
label: 'My Requests',
match: (path: string) => path === '/' || path.startsWith('/requests/'),
},
...(showRequestsNav
? [
{
href: '/new-requests',
label: 'New Requests',
match: (path: string) => path === '/new-requests',
},
]
: []),
{
href: '/portal/issues',
label: 'Issues',
match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
},
]
const items = [
...commonItems,
...roleItems,
]
return (
<div className="header-actions">
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
<div className="header-actions-center">
<a href="/how-it-works">How it works</a>
</div>
<div className="header-actions-right">
<a href="/">Requests</a>
<a href="/profile/invites">Invites</a>
<a href="/portal/requests">Portal</a>
<a href="/portal/issues">Issues</a>
</div>
</div>
<nav className="header-actions" aria-label="Primary">
{items.map((item, index) => {
const active = item.match(pathname)
return (
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
<span aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
{item.label}
</a>
)
})}
</nav>
)
}
+22 -1
View File
@@ -2,11 +2,13 @@
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
const [buildNumber, setBuildNumber] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const viewAsUser = useUserViewPreview()
useEffect(() => {
const token = getToken()
@@ -27,6 +29,9 @@ export default function HeaderIdentity() {
const data = await response.json()
if (data?.username) {
setIdentity({ username: data.username, role: data.role })
if (data.role !== 'admin') {
setUserViewPreview(false)
}
}
const siteResponse = await fetch(`${baseUrl}/site/public`)
if (siteResponse.ok) {
@@ -50,6 +55,7 @@ export default function HeaderIdentity() {
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase()
const signOut = async () => {
setUserViewPreview(false)
await logout().catch(() => undefined)
clearToken()
if (typeof window !== 'undefined') {
@@ -58,6 +64,17 @@ export default function HeaderIdentity() {
}
return (
<div className="signed-in-context">
{identity.role === 'admin' ? (
<button
type="button"
className={`user-view-toggle ${viewAsUser ? 'is-active' : ''}`}
aria-pressed={viewAsUser}
onClick={() => setUserViewPreview(!viewAsUser)}
>
{viewAsUser ? 'Exit user view' : 'View as user'}
</button>
) : null}
<div className="signed-in-menu">
<button
type="button"
@@ -71,7 +88,10 @@ export default function HeaderIdentity() {
</button>
{open && (
<div className="signed-in-dropdown">
<div className="signed-in-header">Signed in as {label}</div>
<div className="signed-in-header">
Signed in as {label}
{viewAsUser ? <span>Previewing user view</span> : null}
</div>
<div className="signed-in-actions">
<a href="/profile" onClick={() => setOpen(false)}>
My profile
@@ -92,5 +112,6 @@ export default function HeaderIdentity() {
</div>
)}
</div>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
'use client'
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
export default function UserViewBanner() {
const enabled = useUserViewPreview()
if (!enabled) return null
return (
<div className="user-view-banner" role="status">
<div>
<strong>User view</strong>
<span>You are previewing the non-admin experience. Your account and backend permissions remain admin.</span>
</div>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view
</button>
</div>
)
}
+91
View File
@@ -101,8 +101,10 @@ export default function UserDetailPage() {
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
const [profileSelection, setProfileSelection] = useState('')
const [expiryInput, setExpiryInput] = useState('')
const [emailInput, setEmailInput] = useState('')
const [savingProfile, setSavingProfile] = useState(false)
const [savingExpiry, setSavingExpiry] = useState(false)
const [savingEmail, setSavingEmail] = useState(false)
const [systemActionBusy, setSystemActionBusy] = useState(false)
const [actionStatus, setActionStatus] = useState<string | null>(null)
const [lineage, setLineage] = useState<UserLineage>(null)
@@ -165,6 +167,7 @@ export default function UserDetailPage() {
: String(nextUser.profile_id)
)
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
setEmailInput(nextUser?.email ?? '')
setError(null)
} catch (err) {
console.error(err)
@@ -218,6 +221,47 @@ export default function UserDetailPage() {
}
}
const saveUserEmail = async (clear = false) => {
if (!user) return
const email = clear ? '' : emailInput.trim()
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('Enter a valid email address.')
setActionStatus(null)
return
}
setSavingEmail(true)
setError(null)
setActionStatus(null)
try {
const response = await authFetch(
`${getApiBase()}/admin/users/${encodeURIComponent(user.username)}/email`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email || null }),
}
)
const text = await response.text()
let data: any = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = null
}
if (!response.ok) {
throw new Error(data?.detail || text || 'Email update failed')
}
setEmailInput(data?.user?.email ?? '')
await loadUser()
setActionStatus(email ? 'Contact email saved.' : 'Contact email removed.')
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not update the contact email.')
} finally {
setSavingEmail(false)
}
}
const updateAutoSearchEnabled = async (enabled: boolean) => {
if (!user) return
try {
@@ -546,6 +590,53 @@ export default function UserDetailPage() {
</div>
<div className="user-detail-side-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Contact email</h2>
<p className="lede">Used by Magent for account recovery and issue updates.</p>
</div>
<form
className="user-detail-actions user-detail-actions--stacked"
onSubmit={(event) => {
event.preventDefault()
void saveUserEmail()
}}
>
<label>
<span className="user-bulk-label">Email address</span>
<input
type="email"
value={emailInput}
onChange={(event) => setEmailInput(event.target.value)}
placeholder="person@example.com"
autoComplete="off"
disabled={savingEmail}
/>
</label>
<div className="user-detail-helper">
This updates Magent only. It does not change the user&apos;s Jellyfin or Seerr account.
</div>
<div className="admin-inline-actions">
<button
type="submit"
disabled={savingEmail || !emailInput.trim() || emailInput.trim() === (user.email ?? '')}
>
{savingEmail ? 'Saving...' : user.email ? 'Save email' : 'Add email'}
</button>
{user.email && (
<button
type="button"
className="ghost-button"
onClick={() => void saveUserEmail(true)}
disabled={savingEmail}
>
Remove email
</button>
)}
</div>
</form>
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Access controls</h2>
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
"files": {
"includes": [
"app/**/*.{ts,tsx}",
"next.config.js",
"!node_modules",
"!.next"
]
},
"formatter": {
"enabled": false
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"useExhaustiveDependencies": "off"
},
"performance": {
"noImgElement": "off"
},
"suspicious": {
"noArrayIndexKey": "off",
"noDocumentCookie": "off",
"noExplicitAny": "off"
}
}
}
}
+488 -216
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -6,21 +6,23 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "biome lint ."
},
"dependencies": {
"next": "16.1.6",
"next": "16.2.12",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"typescript": "5.9.3",
"@biomejs/biome": "2.5.6",
"@types/node": "24.11.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
"@types/react-dom": "19.2.3",
"typescript": "5.9.3"
},
"overrides": {
"nanoid": "3.3.18",
"postcss": "8.5.25",
"sharp": "0.35.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(70 21.00012)">
<path d="M105.302 154.943L112.824 869.492C52.651 877.014 7.52158 846.927 7.52158 786.755L0 192.55C0 4.51106 172.996-40.6184 278.298 34.5974L812.33 342.982C887.546 395.633 902.589 493.413 864.981 561.107 857.46 508.456 834.895 478.37 789.765 448.284L188.039 109.813C142.91 79.7268 105.302 87.2484 105.302 154.943Z" fill="#24292E"/>
<path d="M0 376.079C45.1295 391.122 90.259 383.6 127.867 361.036L744.636 0C782.244 52.651 774.723 105.302 729.593 135.388L210.604 436.251C135.388 473.859 37.6079 436.251 0 376.079Z" transform="translate(60.17249 531.0214)" fill="#24292E"/>
<path d="M0 413.687L368.557 203.083 7.52157 0 0 413.687Z" transform="translate(240.6902 282.8092)" fill="#FFC230"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 846 B

+9
View File
@@ -0,0 +1,9 @@
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/>
<path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/>
<g clip-rule="evenodd">
<path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/>
<path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/>
<path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
mkdir -p '${deploy_path}'
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
tar -xf - -C '${deploy_path}'
if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
cp '${prod_path}/.env' '${deploy_path}/.env'
fi
mkdir -p '${deploy_path}/data'
if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
cp -a '${prod_path}/data/.' '${deploy_path}/data/'
fi
cd '${deploy_path}'
docker compose -p magent-beta -f docker-compose.beta.yml build
docker compose -p magent-beta -f docker-compose.beta.yml up -d
"
echo "Running remote beta smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
from urllib import request
checks = [
('http://127.0.0.1:8100/health', 200),
('http://${beta_frontend_bind}:3100/login', 200),
]
for url, expected in checks:
with request.urlopen(url, timeout=20) as response:
if response.status != expected:
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
print(url, response.status)
PY
"
echo "Beta deployment completed successfully"
+1
View File
@@ -2,6 +2,7 @@ $ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path "$PSScriptRoot\.."
Set-Location $repoRoot
$env:PYTHONIOENCODING = "utf-8"
function Assert-LastExitCode {
param([Parameter(Mandatory = $true)][string]$CommandName)