Compare commits

...

8 Commits

Author SHA1 Message Date
Rephl3x 0667a172d1 Add beta request nav toggle
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Has been skipped
Magent CI/CD / deploy-beta (push) Successful in 18s
2026-06-21 12:32:03 +12:00
Rephl3x e163920e21 Tidy beta landing page and qBittorrent status
Magent CI/CD / verify (push) Successful in 11m0s
Magent CI/CD / deploy-prod (push) Has been skipped
Magent CI/CD / deploy-beta (push) Successful in 15s
2026-06-21 12:06:38 +12:00
Rephl3x e6b4f99ea7 Redesign beta Magent UI
Magent CI/CD / verify (push) Successful in 11m8s
Magent CI/CD / deploy-prod (push) Has been skipped
Magent CI/CD / deploy-beta (push) Successful in 18s
2026-06-21 11:41:38 +12:00
Rephl3x e36da13264 Expose beta frontend to LAN proxy
Magent CI/CD / verify (push) Successful in 10m51s
Magent CI/CD / deploy-prod (push) Has been skipped
Magent CI/CD / deploy-beta (push) Successful in 16s
2026-06-18 22:08:54 +12:00
Rephl3x 7fcff0f24b Fix beta compose deploy command
Magent CI/CD / verify (push) Has been cancelled
Magent CI/CD / deploy-prod (push) Has been cancelled
Magent CI/CD / deploy-beta (push) Has been cancelled
2026-06-18 22:02:29 +12:00
Rephl3x 91ff47330a Add beta deployment stream
Magent CI/CD / verify (push) Has been cancelled
Magent CI/CD / deploy-prod (push) Has been cancelled
Magent CI/CD / deploy-beta (push) Has been cancelled
2026-06-18 21:51:49 +12:00
Rephl3x 87971d1ff0 Expose issue portal navigation
Magent CI/CD / verify (push) Successful in 11m11s
Magent CI/CD / deploy-prod (push) Failing after 2m22s
2026-06-18 21:45:03 +12:00
Rephl3x 8f03e315b8 Fix request detail load failures
Magent CI/CD / verify (push) Failing after 11m3s
Magent CI/CD / deploy-prod (push) Has been skipped
2026-06-18 21:10:56 +12:00
22 changed files with 2355 additions and 106 deletions
+31
View File
@@ -71,3 +71,34 @@ jobs:
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }} DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
run: bash scripts/deploy_ams_dev01.sh 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
+3 -1
View File
@@ -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]:
+3
View File
@@ -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(
+1
View File
@@ -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",
] ]
+1 -1
View File
@@ -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:
+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), "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()
+1
View File
@@ -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"}
+53
View File
@@ -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,8 @@ 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.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
@@ -96,6 +100,19 @@ class NetworkSecurityTests(unittest.TestCase):
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase): 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: async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", 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)): with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
@@ -117,6 +134,42 @@ class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("credentials", result["message"].lower()) 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(
+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.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
+86 -2
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import PortalClient from '../../portal/PortalClient'
export default function AdminIssuesPage() {
return <PortalClient workspace="issue" />
}
+240 -6
View File
@@ -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>
+3 -3
View File
@@ -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">
+20 -7
View File
@@ -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
View File
@@ -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 && (
+95 -4
View File
@@ -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 && (
+1
View File
@@ -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>
+10
View File
@@ -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' },
], ],
}, },
{ {
+35 -5
View File
@@ -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>
) )
} }
+77 -13
View File
@@ -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>
) )
} }
+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"