Compare commits
11 Commits
2d28047ad7
..
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 0667a172d1 | |||
| e163920e21 | |||
| e6b4f99ea7 | |||
| e36da13264 | |||
| 7fcff0f24b | |||
| 91ff47330a | |||
| 87971d1ff0 | |||
| 8f03e315b8 | |||
| a8aa8e38e2 | |||
| 329884b789 | |||
| 0700d37469 |
@@ -0,0 +1,104 @@
|
|||||||
|
name: Magent CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- beta
|
||||||
|
- prod
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: magent-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run backend quality gate
|
||||||
|
run: bash scripts/ci_backend_quality_gate.sh
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
deploy-prod:
|
||||||
|
if: github.ref_name == 'prod'
|
||||||
|
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 to AMS-DEV01
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||||
|
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
||||||
|
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
|
||||||
@@ -141,6 +141,26 @@ The frontend proxies `/api/*` to the backend container. Set:
|
|||||||
|
|
||||||
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
|
||||||
|
|
||||||
|
## Gitea CI/CD
|
||||||
|
|
||||||
|
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
|
||||||
|
|
||||||
|
- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
|
||||||
|
- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`.
|
||||||
|
|
||||||
|
The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:8000/health`
|
||||||
|
- `http://127.0.0.1:3000/login`
|
||||||
|
|
||||||
|
Configure these Gitea Actions secrets before enabling the deploy job:
|
||||||
|
|
||||||
|
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
|
||||||
|
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
|
||||||
|
- `PROD_SSH_USER`: target user, for example `zak`.
|
||||||
|
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
|
||||||
|
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
|
||||||
|
|
||||||
## History endpoints
|
## History endpoints
|
||||||
|
|
||||||
- `GET /requests/{id}/history?limit=10` recent snapshots
|
- `GET /requests/{id}/history?limit=10` recent snapshots
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ class QBittorrentClient(ApiClient):
|
|||||||
headers={"Referer": self.base_url},
|
headers={"Referer": self.base_url},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
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")
|
raise RuntimeError("qBittorrent login failed")
|
||||||
|
|
||||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||||
@@ -52,6 +54,17 @@ class QBittorrentClient(ApiClient):
|
|||||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
async def is_webui_reachable(self) -> bool:
|
||||||
|
if not self.base_url:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||||
|
response = await client.get(self.base_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
return True
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
|
|
||||||
async def get_torrents(self) -> Optional[Any]:
|
async def get_torrents(self) -> Optional[Any]:
|
||||||
return await self._get("/api/v2/torrents/info")
|
return await self._get("/api/v2/torrents/info")
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ class Settings(BaseSettings):
|
|||||||
site_login_show_signup_link: bool = Field(
|
site_login_show_signup_link: bool = Field(
|
||||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_SIGNUP_LINK")
|
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)
|
site_changelog: Optional[str] = Field(default=CHANGELOG)
|
||||||
|
|
||||||
magent_application_url: Optional[str] = Field(
|
magent_application_url: Optional[str] = Field(
|
||||||
|
|||||||
+10
-6
@@ -418,12 +418,16 @@ def init_db() -> None:
|
|||||||
ON requests_cache (updated_at DESC, request_id DESC)
|
ON requests_cache (updated_at DESC, request_id DESC)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
try:
|
||||||
"""
|
conn.execute(
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
|
"""
|
||||||
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
|
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
|
||||||
"""
|
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
|
||||||
)
|
"""
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Older databases may not have requested_by_id until later migrations run.
|
||||||
|
pass
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
|
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ SETTING_KEYS: List[str] = [
|
|||||||
"site_login_show_local_login",
|
"site_login_show_local_login",
|
||||||
"site_login_show_forgot_password",
|
"site_login_show_forgot_password",
|
||||||
"site_login_show_signup_link",
|
"site_login_show_signup_link",
|
||||||
|
"site_nav_show_requests",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ def _cache_get(key: str) -> Optional[Dict[str, Any]]:
|
|||||||
|
|
||||||
def _cache_set(key: str, payload: Dict[str, Any]) -> None:
|
def _cache_set(key: str, payload: Dict[str, Any]) -> None:
|
||||||
_detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
|
_detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
|
||||||
|
_failed_detail_cache.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
def _status_label_with_jellyfin(current_status: Any, jellyfin_available: bool) -> str:
|
def _status_label_with_jellyfin(current_status: Any, jellyfin_available: bool) -> str:
|
||||||
@@ -169,7 +170,6 @@ async def _request_is_available_in_jellyfin(
|
|||||||
return True
|
return True
|
||||||
availability_cache[cache_key] = False
|
availability_cache[cache_key] = False
|
||||||
return False
|
return False
|
||||||
_failed_detail_cache.pop(key, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _failure_cache_has(key: str) -> bool:
|
def _failure_cache_has(key: str) -> bool:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
|||||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
||||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
||||||
},
|
},
|
||||||
|
"navigation": {
|
||||||
|
"showRequests": bool(runtime.site_nav_show_requests),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if include_changelog:
|
if include_changelog:
|
||||||
info["changelog"] = (CHANGELOG or "").strip()
|
info["changelog"] = (CHANGELOG or "").strip()
|
||||||
|
|||||||
@@ -26,6 +26,35 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
|
|||||||
return {"name": name, "status": "down", "message": str(exc)}
|
return {"name": name, "status": "down", "message": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
|
||||||
|
if not qbittorrent.base_url:
|
||||||
|
return {"name": "qBittorrent", "status": "not_configured"}
|
||||||
|
if not qbittorrent.username or not qbittorrent.password:
|
||||||
|
reachable = await qbittorrent.is_webui_reachable()
|
||||||
|
return {
|
||||||
|
"name": "qBittorrent",
|
||||||
|
"status": "degraded" if reachable else "not_configured",
|
||||||
|
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = await qbittorrent.get_app_version()
|
||||||
|
return {"name": "qBittorrent", "status": "up", "detail": result}
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "login failed" in str(exc).lower():
|
||||||
|
reachable = await qbittorrent.is_webui_reachable()
|
||||||
|
if reachable:
|
||||||
|
return {
|
||||||
|
"name": "qBittorrent",
|
||||||
|
"status": "degraded",
|
||||||
|
"message": "qBittorrent is reachable but the saved credentials were rejected",
|
||||||
|
}
|
||||||
|
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/services")
|
@router.get("/services")
|
||||||
async def services_status() -> Dict[str, Any]:
|
async def services_status() -> Dict[str, Any]:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
@@ -71,13 +100,7 @@ async def services_status() -> Dict[str, Any]:
|
|||||||
prowlarr_status["status"] = "degraded"
|
prowlarr_status["status"] = "degraded"
|
||||||
prowlarr_status["message"] = "Health warnings"
|
prowlarr_status["message"] = "Health warnings"
|
||||||
services.append(prowlarr_status)
|
services.append(prowlarr_status)
|
||||||
services.append(
|
services.append(await _check_qbittorrent(qbittorrent))
|
||||||
await _check(
|
|
||||||
"qBittorrent",
|
|
||||||
qbittorrent.configured(),
|
|
||||||
qbittorrent.get_app_version,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
services.append(
|
services.append(
|
||||||
await _check(
|
await _check(
|
||||||
"Jellyfin",
|
"Jellyfin",
|
||||||
@@ -122,10 +145,12 @@ async def test_service(service: str) -> Dict[str, Any]:
|
|||||||
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
|
||||||
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
|
||||||
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
|
||||||
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
|
|
||||||
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if service_key == "qbittorrent":
|
||||||
|
return await _check_qbittorrent(qbittorrent)
|
||||||
|
|
||||||
if service_key not in checks:
|
if service_key not in checks:
|
||||||
raise HTTPException(status_code=404, detail="Unknown service")
|
raise HTTPException(status_code=404, detail="Unknown service")
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ _BOOL_FIELDS = {
|
|||||||
"site_login_show_local_login",
|
"site_login_show_local_login",
|
||||||
"site_login_show_forgot_password",
|
"site_login_show_forgot_password",
|
||||||
"site_login_show_signup_link",
|
"site_login_show_signup_link",
|
||||||
|
"site_nav_show_requests",
|
||||||
}
|
}
|
||||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
@@ -11,6 +13,9 @@ from backend.app.config import settings
|
|||||||
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
|
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
|
||||||
from backend.app.routers import auth as auth_router
|
from backend.app.routers import auth as auth_router
|
||||||
from backend.app.routers import portal as portal_router
|
from backend.app.routers import portal as portal_router
|
||||||
|
from backend.app.routers import requests as requests_router
|
||||||
|
from backend.app.routers import site as site_router
|
||||||
|
from backend.app.routers import status as status_router
|
||||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
||||||
from backend.app.services import password_reset
|
from backend.app.services import password_reset
|
||||||
|
|
||||||
@@ -94,6 +99,77 @@ class NetworkSecurityTests(unittest.TestCase):
|
|||||||
settings.magent_proxy_trusted_proxies = original_proxies
|
settings.magent_proxy_trusted_proxies = original_proxies
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_qbittorrent_login_accepts_modern_empty_response_with_session_cookie(self) -> None:
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.cookies = httpx.Cookies()
|
||||||
|
|
||||||
|
async def post(self, *_args, **_kwargs) -> httpx.Response:
|
||||||
|
self.cookies.set("QBT_SID_8080", "session")
|
||||||
|
return httpx.Response(204, request=httpx.Request("POST", "http://10.0.0.2:8080/api/v2/auth/login"))
|
||||||
|
|
||||||
|
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
|
||||||
|
|
||||||
|
await client._login(FakeClient())
|
||||||
|
|
||||||
|
async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
|
||||||
|
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
|
||||||
|
with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
|
||||||
|
result = await status_router._check_qbittorrent(client)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "degraded")
|
||||||
|
self.assertIn("credentials", result["message"].lower())
|
||||||
|
|
||||||
|
async def test_qbittorrent_rejected_credentials_report_degraded_when_reachable(self) -> None:
|
||||||
|
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
|
||||||
|
with patch.object(
|
||||||
|
client,
|
||||||
|
"get_app_version",
|
||||||
|
new=AsyncMock(side_effect=RuntimeError("qBittorrent login failed")),
|
||||||
|
), patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
|
||||||
|
result = await status_router._check_qbittorrent(client)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "degraded")
|
||||||
|
self.assertIn("credentials", result["message"].lower())
|
||||||
|
|
||||||
|
|
||||||
|
class SiteInfoTests(unittest.TestCase):
|
||||||
|
def test_site_public_exposes_requests_navigation_toggle(self) -> None:
|
||||||
|
runtime = SimpleNamespace(
|
||||||
|
site_build_number="test-build",
|
||||||
|
site_banner_enabled=False,
|
||||||
|
site_banner_message="",
|
||||||
|
site_banner_tone="info",
|
||||||
|
site_login_show_jellyfin_login=True,
|
||||||
|
site_login_show_local_login=True,
|
||||||
|
site_login_show_forgot_password=True,
|
||||||
|
site_login_show_signup_link=True,
|
||||||
|
site_nav_show_requests=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(site_router, "get_runtime_settings", return_value=runtime):
|
||||||
|
info = site_router._build_site_info(False)
|
||||||
|
|
||||||
|
self.assertEqual(info["navigation"], {"showRequests": False})
|
||||||
|
|
||||||
|
|
||||||
|
class RequestCacheTests(unittest.TestCase):
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
requests_router._detail_cache.clear()
|
||||||
|
requests_router._failed_detail_cache.clear()
|
||||||
|
|
||||||
|
def test_successful_detail_cache_write_clears_prior_failure(self) -> None:
|
||||||
|
key = "request:123"
|
||||||
|
requests_router._failure_cache_set(key)
|
||||||
|
self.assertTrue(requests_router._failure_cache_has(key))
|
||||||
|
|
||||||
|
requests_router._cache_set(key, {"id": 123})
|
||||||
|
|
||||||
|
self.assertFalse(requests_router._failure_cache_has(key))
|
||||||
|
self.assertEqual(requests_router._cache_get(key), {"id": 123})
|
||||||
|
|
||||||
|
|
||||||
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
|
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
|
||||||
def test_set_user_email_is_case_insensitive(self) -> None:
|
def test_set_user_email_is_case_insensitive(self) -> None:
|
||||||
created = db.create_user_if_missing(
|
created = db.create_user_if_missing(
|
||||||
|
|||||||
@@ -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.magent.grizzlyflix.co.nz
|
||||||
|
MAGENT_APPLICATION_URL: https://beta.magent.grizzlyflix.co.nz
|
||||||
|
MAGENT_API_URL: https://beta.magent.grizzlyflix.co.nz/api
|
||||||
|
AUTH_COOKIE_NAME: magent_beta_auth
|
||||||
|
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
|
||||||
|
AUTH_COOKIE_DOMAIN: beta.magent.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
|
||||||
@@ -19,6 +19,12 @@ type ServiceOptions = {
|
|||||||
qualityProfiles: { id: number; name: string; label: string }[]
|
qualityProfiles: { id: number; name: string; label: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServiceStatus = {
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
const SECTION_LABELS: Record<string, string> = {
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
magent: 'Magent',
|
magent: 'Magent',
|
||||||
general: 'General',
|
general: 'General',
|
||||||
@@ -44,6 +50,7 @@ const BOOL_SETTINGS = new Set([
|
|||||||
'site_login_show_local_login',
|
'site_login_show_local_login',
|
||||||
'site_login_show_forgot_password',
|
'site_login_show_forgot_password',
|
||||||
'site_login_show_signup_link',
|
'site_login_show_signup_link',
|
||||||
|
'site_nav_show_requests',
|
||||||
'magent_proxy_enabled',
|
'magent_proxy_enabled',
|
||||||
'magent_proxy_trust_forwarded_headers',
|
'magent_proxy_trust_forwarded_headers',
|
||||||
'magent_ssl_bind_enabled',
|
'magent_ssl_bind_enabled',
|
||||||
@@ -266,6 +273,12 @@ const SITE_SECTION_GROUPS: Array<{
|
|||||||
'site_login_show_signup_link',
|
'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 SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||||||
@@ -313,6 +326,7 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
site_login_show_local_login: 'Login page: local Magent 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_forgot_password: 'Login page: forgot password',
|
||||||
site_login_show_signup_link: 'Login page: invite signup link',
|
site_login_show_signup_link: 'Login page: invite signup link',
|
||||||
|
site_nav_show_requests: 'Top navigation: Requests',
|
||||||
log_file_max_bytes: 'Log file max size (bytes)',
|
log_file_max_bytes: 'Log file max size (bytes)',
|
||||||
log_file_backup_count: 'Rotated log files to keep',
|
log_file_backup_count: 'Rotated log files to keep',
|
||||||
log_http_client_level: 'Service HTTP log level',
|
log_http_client_level: 'Service HTTP log level',
|
||||||
@@ -343,6 +357,7 @@ const labelFromKey = (key: string) =>
|
|||||||
.replace('site banner enabled', 'Sitewide banner enabled')
|
.replace('site banner enabled', 'Sitewide banner enabled')
|
||||||
.replace('site banner message', 'Sitewide banner message')
|
.replace('site banner message', 'Sitewide banner message')
|
||||||
.replace('site banner tone', 'Sitewide banner tone')
|
.replace('site banner tone', 'Sitewide banner tone')
|
||||||
|
.replace('site nav show requests', 'Top navigation: Requests')
|
||||||
.replace('site changelog', 'Changelog text')
|
.replace('site changelog', 'Changelog text')
|
||||||
|
|
||||||
const formatBytes = (value?: number | null) => {
|
const formatBytes = (value?: number | null) => {
|
||||||
@@ -414,6 +429,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
||||||
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
||||||
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||||||
|
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([])
|
||||||
|
const [serviceStatusCheckedAt, setServiceStatusCheckedAt] = useState<string | null>(null)
|
||||||
const requestsSyncRef = useRef<any | null>(null)
|
const requestsSyncRef = useRef<any | null>(null)
|
||||||
const artworkPrefetchRef = useRef<any | null>(null)
|
const artworkPrefetchRef = useRef<any | null>(null)
|
||||||
const computeProgressPercent = (
|
const computeProgressPercent = (
|
||||||
@@ -543,6 +560,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(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
@@ -550,7 +582,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await loadSettings()
|
await Promise.all([loadSettings(), loadServiceStatuses()])
|
||||||
if (section === 'cache' || section === 'artwork') {
|
if (section === 'cache' || section === 'artwork') {
|
||||||
await loadArtworkPrefetchStatus()
|
await loadArtworkPrefetchStatus()
|
||||||
await loadArtworkSummary()
|
await loadArtworkSummary()
|
||||||
@@ -570,7 +602,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
if (section === 'radarr') {
|
if (section === 'radarr') {
|
||||||
void loadOptions('radarr')
|
void loadOptions('radarr')
|
||||||
}
|
}
|
||||||
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
|
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section])
|
||||||
|
|
||||||
const groupedSettings = useMemo(() => {
|
const groupedSettings = useMemo(() => {
|
||||||
const groups: Record<string, AdminSetting[]> = {}
|
const groups: Record<string, AdminSetting[]> = {}
|
||||||
@@ -583,6 +615,22 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
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 isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
||||||
const isSiteGroupedSection = section === 'site'
|
const isSiteGroupedSection = section === 'site'
|
||||||
const visibleSections = settingsSection ? [settingsSection] : []
|
const visibleSections = settingsSection ? [settingsSection] : []
|
||||||
@@ -606,6 +654,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
'site_login_show_local_login',
|
'site_login_show_local_login',
|
||||||
'site_login_show_forgot_password',
|
'site_login_show_forgot_password',
|
||||||
'site_login_show_signup_link',
|
'site_login_show_signup_link',
|
||||||
|
'site_nav_show_requests',
|
||||||
]
|
]
|
||||||
const sortByOrder = (items: AdminSetting[], order: string[]) => {
|
const sortByOrder = (items: AdminSetting[], order: string[]) => {
|
||||||
const position = new Map(order.map((key, index) => [key, index]))
|
const position = new Map(order.map((key, index) => [key, index]))
|
||||||
@@ -814,6 +863,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_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_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_login_show_signup_link: 'Show the invite signup link on the login page.',
|
||||||
|
site_nav_show_requests:
|
||||||
|
'Show the Requests item in the top navigation. Disable during beta while request routing is being reworked.',
|
||||||
site_changelog: 'One update per line for the public changelog.',
|
site_changelog: 'One update per line for the public changelog.',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1681,6 +1732,39 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status && <div className="error-banner">{status}</div>}
|
{status && <div className="error-banner">{status}</div>}
|
||||||
|
{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}
|
||||||
{settingsSections.length > 0 ? (
|
{settingsSections.length > 0 ? (
|
||||||
<div className="admin-form admin-zone-stack">
|
<div className="admin-form admin-zone-stack">
|
||||||
{settingsSections
|
{settingsSections
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import PortalClient from '../../portal/PortalClient'
|
||||||
|
|
||||||
|
export default function AdminIssuesPage() {
|
||||||
|
return <PortalClient workspace="issue" />
|
||||||
|
}
|
||||||
+240
-6
@@ -1,24 +1,258 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
import AdminShell from '../ui/AdminShell'
|
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() {
|
export default function AdminLandingPage() {
|
||||||
const router = useRouter()
|
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)
|
||||||
|
|
||||||
|
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 : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
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">Service ecosystem</span>
|
||||||
|
<div className="service-ecosystem">
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<div className="status-banner">Service status is not available yet.</div>
|
||||||
|
) : (
|
||||||
|
services.map((service) => (
|
||||||
|
<a
|
||||||
|
key={service.name}
|
||||||
|
className="service-row"
|
||||||
|
href={`/admin/${service.name.toLowerCase().replace(/[^a-z0-9]/g, '')}`}
|
||||||
|
>
|
||||||
|
<span className={`system-dot system-dot-${service.status}`} />
|
||||||
|
<span>
|
||||||
|
<strong>{service.name}</strong>
|
||||||
|
<small>{service.message ?? 'No message reported'}</small>
|
||||||
|
</span>
|
||||||
|
<span className={`small-pill system-pill-${service.status}`}>{service.status}</span>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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 (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
title="Settings"
|
title="Operations Center"
|
||||||
subtitle="Choose what you want to manage."
|
subtitle="Live Magent controls, request movement, issue intake, and service health."
|
||||||
|
rail={rail}
|
||||||
actions={
|
actions={
|
||||||
<button type="button" onClick={() => router.push('/')}>
|
<button type="button" onClick={() => router.push('/')}>
|
||||||
Back to requests
|
View health
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<section className="admin-section">
|
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
|
||||||
<div className="status-banner">
|
{error ? <div className="error-banner">{error}</div> : null}
|
||||||
Pick a section from the left. Each page explains what it does and how it helps.
|
|
||||||
|
<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">
|
||||||
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
|
|||||||
@@ -3565,12 +3565,14 @@ button:disabled {
|
|||||||
.user-grid-pill.is-blocked {
|
.user-grid-pill.is-blocked {
|
||||||
background: rgba(244, 114, 114, 0.14);
|
background: rgba(244, 114, 114, 0.14);
|
||||||
border-color: rgba(244, 114, 114, 0.24);
|
border-color: rgba(244, 114, 114, 0.24);
|
||||||
|
color: #ffd5d5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-pill-degraded,
|
.system-pill-degraded,
|
||||||
.user-grid-pill.is-disabled {
|
.user-grid-pill.is-disabled {
|
||||||
background: rgba(208, 166, 92, 0.14);
|
background: rgba(208, 166, 92, 0.14);
|
||||||
border-color: rgba(208, 166, 92, 0.22);
|
border-color: rgba(208, 166, 92, 0.22);
|
||||||
|
color: #ffe3a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-dot {
|
.system-dot {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import './ops-redesign.css'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import HeaderActions from './ui/HeaderActions'
|
import HeaderActions from './ui/HeaderActions'
|
||||||
import HeaderIdentity from './ui/HeaderIdentity'
|
import HeaderIdentity from './ui/HeaderIdentity'
|
||||||
import ThemeToggle from './ui/ThemeToggle'
|
|
||||||
import BrandingFavicon from './ui/BrandingFavicon'
|
import BrandingFavicon from './ui/BrandingFavicon'
|
||||||
import BrandingLogo from './ui/BrandingLogo'
|
import BrandingLogo from './ui/BrandingLogo'
|
||||||
import SiteStatus from './ui/SiteStatus'
|
import SiteStatus from './ui/SiteStatus'
|
||||||
@@ -24,12 +24,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<BrandingLogo className="brand-logo brand-logo--header" />
|
<BrandingLogo className="brand-logo brand-logo--header" />
|
||||||
<div className="brand-stack">
|
<div className="brand-stack">
|
||||||
<div className="brand">Magent</div>
|
<div className="brand">Magent</div>
|
||||||
<div className="tagline">Find and fix media requests fast.</div>
|
<div className="tagline">GrizzlyFlix media operations</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
<ThemeToggle />
|
<span className="beta-chip" aria-label="Beta environment">Beta</span>
|
||||||
<HeaderIdentity />
|
<HeaderIdentity />
|
||||||
</div>
|
</div>
|
||||||
<div className="header-nav">
|
<div className="header-nav">
|
||||||
|
|||||||
@@ -108,10 +108,17 @@ export default function LoginPage() {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card auth-card">
|
<main className="auth-screen">
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
<section className="auth-hero">
|
||||||
<h1>Sign in</h1>
|
<div className="auth-mark">
|
||||||
<p className="lede">{loginHelpText}</p>
|
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||||
|
</div>
|
||||||
|
<div className="auth-title-block">
|
||||||
|
<span className="section-kicker">Secure access</span>
|
||||||
|
<h1>Magent operational gateway</h1>
|
||||||
|
<p>{loginHelpText}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
if (!primaryMode) {
|
if (!primaryMode) {
|
||||||
@@ -121,23 +128,25 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
void submit(event, primaryMode)
|
void submit(event, primaryMode)
|
||||||
}}
|
}}
|
||||||
className="auth-form"
|
className="auth-form auth-panel"
|
||||||
>
|
>
|
||||||
<label>
|
<label>
|
||||||
Username
|
<span>Username</span>
|
||||||
<input
|
<input
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
placeholder="Enter your username"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Password
|
<span>Password</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
placeholder="Enter your password"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
@@ -171,6 +180,10 @@ export default function LoginPage() {
|
|||||||
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
||||||
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="auth-footnote">
|
||||||
|
<span className="live-dot" aria-hidden="true" />
|
||||||
|
Beta environment
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+105
-64
@@ -362,78 +362,119 @@ export default function HomePage() {
|
|||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serviceItems = servicesStatus?.services ?? []
|
||||||
|
const serviceUpCount = serviceItems.filter((service) => service.status === 'up').length
|
||||||
|
const serviceAttentionCount = serviceItems.filter((service) =>
|
||||||
|
['down', 'degraded', 'not_configured'].includes(service.status)
|
||||||
|
).length
|
||||||
|
const serviceOverall = servicesStatus?.overall ?? 'unknown'
|
||||||
|
const serviceStatusLabel = servicesLoading
|
||||||
|
? 'Checking services...'
|
||||||
|
: servicesError
|
||||||
|
? 'Status not available yet'
|
||||||
|
: serviceOverall === 'up'
|
||||||
|
? 'Services are up and running'
|
||||||
|
: serviceOverall === 'down'
|
||||||
|
? 'Something is down'
|
||||||
|
: 'Some services need attention'
|
||||||
|
const serviceSummary = servicesError
|
||||||
|
? 'Unable to load service status'
|
||||||
|
: serviceItems.length === 0
|
||||||
|
? 'No services reported yet'
|
||||||
|
: serviceAttentionCount > 0
|
||||||
|
? `${serviceAttentionCount} of ${serviceItems.length} need attention`
|
||||||
|
: `${serviceUpCount} of ${serviceItems.length} online`
|
||||||
|
const orderedServices = ['Seerr', 'Sonarr', 'Radarr', 'Prowlarr', 'qBittorrent', 'Jellyfin'].map(
|
||||||
|
(name) => {
|
||||||
|
const item = serviceItems.find((entry) => entry.name === name)
|
||||||
|
return { name, status: item?.status ?? 'unknown', message: item?.message }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const activeRecentCount = recent.filter((item) => {
|
||||||
|
const label = String(item.statusLabel ?? '').toLowerCase()
|
||||||
|
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
|
||||||
|
}).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card">
|
||||||
|
<section className="ops-metric-grid">
|
||||||
|
<div className="ops-metric-card">
|
||||||
|
<span className="section-kicker">Service mesh</span>
|
||||||
|
<strong>
|
||||||
|
{serviceUpCount}/{serviceItems.length || 0}
|
||||||
|
</strong>
|
||||||
|
<p>{servicesLoading ? 'Checking services now.' : 'Configured services online.'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ops-metric-card">
|
||||||
|
<span className="section-kicker">Attention</span>
|
||||||
|
<strong>{serviceAttentionCount}</strong>
|
||||||
|
<p>Services reporting down, degraded, or not configured.</p>
|
||||||
|
</div>
|
||||||
|
<div className="ops-metric-card">
|
||||||
|
<span className="section-kicker">Loaded requests</span>
|
||||||
|
<strong>{recent.length}</strong>
|
||||||
|
<p>Returned by the live request cache.</p>
|
||||||
|
</div>
|
||||||
|
<div className="ops-metric-card">
|
||||||
|
<span className="section-kicker">Active queue</span>
|
||||||
|
<strong>{activeRecentCount}</strong>
|
||||||
|
<p>Loaded requests still moving through the pipeline.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div className="layout-grid">
|
<div className="layout-grid">
|
||||||
<section className="recent centerpiece">
|
<section className="recent centerpiece">
|
||||||
<div className="system-status">
|
<details className="system-status system-status-dropdown">
|
||||||
<div className="system-header">
|
<summary className="system-summary">
|
||||||
<h2>System status</h2>
|
<span className="system-summary-copy">
|
||||||
<span
|
<span className="section-kicker">System status</span>
|
||||||
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
|
<strong>{serviceSummary}</strong>
|
||||||
>
|
<span>{serviceStatusLabel}</span>
|
||||||
{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>
|
</span>
|
||||||
</div>
|
<span className="system-summary-actions">
|
||||||
|
<span className={`system-pill system-pill-${serviceOverall}`}>
|
||||||
|
{servicesLoading ? 'Checking' : serviceOverall.replaceAll('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<span className="system-dropdown-cue" aria-hidden="true">Open</span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
<div className="system-list">
|
<div className="system-list">
|
||||||
{(() => {
|
{orderedServices.map(({ name, status, message }) => {
|
||||||
const order = [
|
const testing = serviceTesting[name] ?? false
|
||||||
'Seerr',
|
return (
|
||||||
'Sonarr',
|
<div key={name} className={`system-item system-${status}`}>
|
||||||
'Radarr',
|
<span className="system-dot" />
|
||||||
'Prowlarr',
|
<div className="system-meta">
|
||||||
'qBittorrent',
|
<span className="system-name">{name}</span>
|
||||||
'Jellyfin',
|
<span className="system-test-message">
|
||||||
]
|
{serviceTestResults[name] ?? message ?? 'No recent detail'}
|
||||||
const items = servicesStatus?.services ?? []
|
</span>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</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'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
<div className="recent-header">
|
<div className="recent-header">
|
||||||
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
||||||
{authReady && (
|
{authReady && (
|
||||||
|
|||||||
@@ -55,6 +55,44 @@ type ActionHistory = {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readApiError = async (response: Response, fallback: string) => {
|
||||||
|
try {
|
||||||
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = await response.text()
|
||||||
|
if (text.trim()) {
|
||||||
|
return text.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSnapshotPayload = (value: unknown): value is Snapshot => {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const snapshot = value as Partial<Snapshot>
|
||||||
|
return (
|
||||||
|
typeof snapshot.request_id === 'string' &&
|
||||||
|
typeof snapshot.title === 'string' &&
|
||||||
|
typeof snapshot.request_type === 'string' &&
|
||||||
|
typeof snapshot.state === 'string' &&
|
||||||
|
Array.isArray(snapshot.timeline) &&
|
||||||
|
Array.isArray(snapshot.actions)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const percentFromTorrent = (torrent: Record<string, any>) => {
|
const percentFromTorrent = (torrent: Record<string, any>) => {
|
||||||
const progress = Number(torrent.progress)
|
const progress = Number(torrent.progress)
|
||||||
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) {
|
if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) {
|
||||||
@@ -201,6 +239,7 @@ export default function RequestTimelinePage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
|
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
const [actionMessage, setActionMessage] = useState<string | null>(null)
|
const [actionMessage, setActionMessage] = useState<string | null>(null)
|
||||||
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
|
const [releaseOptions, setReleaseOptions] = useState<ReleaseOption[]>([])
|
||||||
@@ -214,6 +253,9 @@ export default function RequestTimelinePage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setLoadError(null)
|
||||||
|
setSnapshot(null)
|
||||||
try {
|
try {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -226,12 +268,22 @@ export default function RequestTimelinePage() {
|
|||||||
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=5`),
|
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=5`),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (snapshotResponse.status === 401) {
|
const authExpired = [snapshotResponse, historyResponse, actionsResponse].some(
|
||||||
|
(response) => response.status === 401
|
||||||
|
)
|
||||||
|
if (authExpired) {
|
||||||
clearToken()
|
clearToken()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!snapshotResponse.ok) {
|
||||||
|
const message = await readApiError(snapshotResponse, 'Unable to load this request.')
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
const snapshotData = await snapshotResponse.json()
|
const snapshotData = await snapshotResponse.json()
|
||||||
|
if (!isSnapshotPayload(snapshotData)) {
|
||||||
|
throw new Error('Unable to load this request.')
|
||||||
|
}
|
||||||
setSnapshot(snapshotData)
|
setSnapshot(snapshotData)
|
||||||
setReleaseOptions([])
|
setReleaseOptions([])
|
||||||
setSearchRan(false)
|
setSearchRan(false)
|
||||||
@@ -251,6 +303,9 @@ export default function RequestTimelinePage() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
setLoadError(
|
||||||
|
error instanceof Error && error.message ? error.message : 'Unable to load this request.'
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -319,7 +374,7 @@ export default function RequestTimelinePage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card request-detail-page">
|
||||||
<div className="loading-center" role="status" aria-live="polite">
|
<div className="loading-center" role="status" aria-live="polite">
|
||||||
<div className="spinner" aria-hidden="true" />
|
<div className="spinner" aria-hidden="true" />
|
||||||
<div className="loading-text">Loading request timeline...</div>
|
<div className="loading-text">Loading request timeline...</div>
|
||||||
@@ -328,8 +383,44 @@ export default function RequestTimelinePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<main className="card request-detail-page">
|
||||||
|
<section className="request-error-state">
|
||||||
|
<span className="section-kicker">Request unavailable</span>
|
||||||
|
<h1>Unable to load this request</h1>
|
||||||
|
<p>{loadError}</p>
|
||||||
|
<div className="request-error-actions">
|
||||||
|
<button type="button" onClick={() => router.refresh()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost-button" onClick={() => router.push('/')}>
|
||||||
|
Back to health
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return <main className="card">Could not load that request.</main>
|
return (
|
||||||
|
<main className="card request-detail-page">
|
||||||
|
<section className="request-error-state">
|
||||||
|
<span className="section-kicker">Request unavailable</span>
|
||||||
|
<h1>Unable to load this request</h1>
|
||||||
|
<p>The request API did not return a valid timeline payload.</p>
|
||||||
|
<div className="request-error-actions">
|
||||||
|
<button type="button" onClick={() => router.refresh()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost-button" onClick={() => router.push('/')}>
|
||||||
|
Back to health
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary =
|
const summary =
|
||||||
@@ -378,7 +469,7 @@ export default function RequestTimelinePage() {
|
|||||||
: friendlyState(snapshot.state)
|
: friendlyState(snapshot.state)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card request-detail-page">
|
||||||
<div className="request-header">
|
<div className="request-header">
|
||||||
<div className="request-header-main">
|
<div className="request-header-main">
|
||||||
{resolvedPoster && (
|
{resolvedPoster && (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
|||||||
<main className="card admin-card">
|
<main className="card admin-card">
|
||||||
<div className="admin-header">
|
<div className="admin-header">
|
||||||
<div>
|
<div>
|
||||||
|
<span className="section-kicker">Beta stream</span>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
{subtitle && <p className="lede">{subtitle}</p>}
|
{subtitle && <p className="lede">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
|
{
|
||||||
|
title: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin', label: 'Overview' },
|
||||||
|
{ href: '/', label: 'Health' },
|
||||||
|
{ href: '/portal/requests', label: 'Request portal' },
|
||||||
|
{ href: '/admin/issues', label: 'Issue tracking' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Services',
|
title: 'Services',
|
||||||
items: [
|
items: [
|
||||||
@@ -21,6 +30,7 @@ const NAV_GROUPS = [
|
|||||||
{ href: '/admin/requests', label: 'Request sync' },
|
{ href: '/admin/requests', label: 'Request sync' },
|
||||||
{ href: '/admin/requests-all', label: 'All requests' },
|
{ href: '/admin/requests-all', label: 'All requests' },
|
||||||
{ href: '/admin/cache', label: 'Cache Control' },
|
{ href: '/admin/cache', label: 'Cache Control' },
|
||||||
|
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
type BrandingLogoProps = {
|
type BrandingLogoProps = {
|
||||||
className?: string
|
className?: string
|
||||||
alt?: string
|
alt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
|
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
|
||||||
className={className}
|
{!failed ? (
|
||||||
src="/api/branding/logo.png"
|
<img
|
||||||
alt={alt}
|
className={loaded ? 'is-loaded' : undefined}
|
||||||
/>
|
src="/api/branding/logo.png"
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,46 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
export default function HeaderActions() {
|
export default function HeaderActions() {
|
||||||
const [signedIn, setSignedIn] = useState(false)
|
const [signedIn, setSignedIn] = useState(false)
|
||||||
|
const [role, setRole] = useState<string | null>(null)
|
||||||
|
const [showRequestsNav, setShowRequestsNav] = useState(true)
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
setSignedIn(Boolean(token))
|
setSignedIn(Boolean(token))
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
setShowRequestsNav(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
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) {
|
if (!response.ok) {
|
||||||
clearToken()
|
clearToken()
|
||||||
setSignedIn(false)
|
setSignedIn(false)
|
||||||
|
setRole(null)
|
||||||
return
|
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) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
setShowRequestsNav(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void load()
|
void load()
|
||||||
@@ -33,17 +50,64 @@ export default function HeaderActions() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roleItems =
|
||||||
|
role === null
|
||||||
|
? []
|
||||||
|
: role === 'admin'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
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: 'Health', match: (path: string) => path === '/' },
|
||||||
|
...(showRequestsNav
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
href: '/portal/requests',
|
||||||
|
label: 'Requests',
|
||||||
|
match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
href: '/portal/issues',
|
||||||
|
label: 'Issues',
|
||||||
|
match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...commonItems,
|
||||||
|
...roleItems,
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="header-actions">
|
<nav className="header-actions" aria-label="Primary">
|
||||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
{items.map((item, index) => {
|
||||||
<div className="header-actions-center">
|
const active = item.match(pathname)
|
||||||
<a href="/how-it-works">How it works</a>
|
return (
|
||||||
</div>
|
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
|
||||||
<div className="header-actions-right">
|
<span aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||||
<a href="/">Requests</a>
|
{item.label}
|
||||||
<a href="/profile/invites">Invites</a>
|
</a>
|
||||||
<a href="/portal">Portal</a>
|
)
|
||||||
</div>
|
})}
|
||||||
</div>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
python_bin="${PYTHON_BIN:-python3}"
|
||||||
|
|
||||||
|
echo "Installing backend Python requirements"
|
||||||
|
"$python_bin" -m pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
echo "Running Python dependency integrity check"
|
||||||
|
"$python_bin" -m pip check
|
||||||
|
|
||||||
|
echo "Running backend unit tests"
|
||||||
|
"$python_bin" -m unittest discover -s backend/tests -p "test_*.py" -v
|
||||||
|
|
||||||
|
echo "Backend quality gate passed"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/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}"
|
||||||
|
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||||
|
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 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-backups/${timestamp}\"
|
||||||
|
mkdir -p \"\${backup_root}\"
|
||||||
|
cd '${deploy_path}'
|
||||||
|
for path in backend frontend docker-compose.yml docker-compose.hub.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}'
|
||||||
|
docker compose up -d --build
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Running remote smoke checks"
|
||||||
|
ssh ${ssh_opts} "${remote}" "
|
||||||
|
set -e
|
||||||
|
python3 - <<'PY'
|
||||||
|
from urllib import request
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
('http://127.0.0.1:8000/health', 200),
|
||||||
|
('http://127.0.0.1:3000/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 "Deployment completed successfully"
|
||||||
Executable
+65
@@ -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"
|
||||||
Reference in New Issue
Block a user