Add site banner, build number, and changelog
This commit is contained in:
@@ -38,6 +38,21 @@ class Settings(BaseSettings):
|
||||
artwork_cache_mode: str = Field(
|
||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
||||
)
|
||||
site_build_number: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER")
|
||||
)
|
||||
site_banner_enabled: bool = Field(
|
||||
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
|
||||
)
|
||||
site_banner_message: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
|
||||
)
|
||||
site_banner_tone: str = Field(
|
||||
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
||||
)
|
||||
site_changelog: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SITE_CHANGELOG")
|
||||
)
|
||||
|
||||
jellyseerr_base_url: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
||||
|
||||
@@ -18,6 +18,7 @@ from .routers.images import router as images_router
|
||||
from .routers.branding import router as branding_router
|
||||
from .routers.status import router as status_router
|
||||
from .routers.feedback import router as feedback_router
|
||||
from .routers.site import router as site_router
|
||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .logging_config import configure_logging
|
||||
from .runtime import get_runtime_settings
|
||||
@@ -56,3 +57,4 @@ app.include_router(images_router)
|
||||
app.include_router(branding_router)
|
||||
app.include_router(status_router)
|
||||
app.include_router(feedback_router)
|
||||
app.include_router(site_router)
|
||||
|
||||
@@ -77,6 +77,11 @@ SETTING_KEYS: List[str] = [
|
||||
"requests_cleanup_time",
|
||||
"requests_cleanup_days",
|
||||
"requests_data_source",
|
||||
"site_build_number",
|
||||
"site_banner_enabled",
|
||||
"site_banner_message",
|
||||
"site_banner_tone",
|
||||
"site_changelog",
|
||||
]
|
||||
|
||||
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -1031,6 +1031,57 @@ def _normalize_indexer_name(value: Optional[str]) -> str:
|
||||
return "".join(ch for ch in value.lower().strip() if ch.isalnum())
|
||||
|
||||
|
||||
def _log_arr_http_error(service_label: str, action: str, exc: httpx.HTTPStatusError) -> None:
|
||||
if exc.response is None:
|
||||
logger.warning("%s %s failed: %s", service_label, action, exc)
|
||||
return
|
||||
status = exc.response.status_code
|
||||
body = exc.response.text
|
||||
if isinstance(body, str):
|
||||
body = body.strip()
|
||||
if len(body) > 800:
|
||||
body = f"{body[:800]}...(truncated)"
|
||||
logger.warning("%s %s failed: status=%s body=%s", service_label, action, status, body)
|
||||
|
||||
|
||||
def _format_rejections(rejections: Any) -> Optional[str]:
|
||||
if isinstance(rejections, str):
|
||||
return rejections.strip() or None
|
||||
if isinstance(rejections, list):
|
||||
reasons = []
|
||||
for item in rejections:
|
||||
reason = None
|
||||
if isinstance(item, dict):
|
||||
reason = (
|
||||
item.get("reason")
|
||||
or item.get("message")
|
||||
or item.get("errorMessage")
|
||||
)
|
||||
if not reason and item is not None:
|
||||
reason = str(item)
|
||||
if isinstance(reason, str) and reason.strip():
|
||||
reasons.append(reason.strip())
|
||||
if reasons:
|
||||
return "; ".join(reasons)
|
||||
return None
|
||||
|
||||
|
||||
def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
|
||||
if not isinstance(response, dict):
|
||||
return True, None
|
||||
rejections = response.get("rejections") or response.get("rejectionReasons")
|
||||
reason = _format_rejections(rejections)
|
||||
if reason:
|
||||
return False, reason
|
||||
if response.get("rejected") is True:
|
||||
return False, "rejected"
|
||||
if response.get("downloadAllowed") is False:
|
||||
return False, "download not allowed"
|
||||
if response.get("approved") is False:
|
||||
return False, "not approved"
|
||||
return True, None
|
||||
|
||||
|
||||
def _resolve_arr_indexer_id(
|
||||
indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
|
||||
) -> Optional[int]:
|
||||
@@ -1745,122 +1796,22 @@ async def action_grab(
|
||||
bool(release_title),
|
||||
)
|
||||
|
||||
push_payload = None
|
||||
if download_url and release_title:
|
||||
push_payload = {
|
||||
"title": release_title,
|
||||
"downloadUrl": download_url,
|
||||
"protocol": release_protocol,
|
||||
"publishDate": release_publish,
|
||||
"size": release_size,
|
||||
"indexer": indexer_name,
|
||||
"guid": guid,
|
||||
"seeders": release_seeders,
|
||||
"leechers": release_leechers,
|
||||
}
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
if not download_url:
|
||||
raise HTTPException(status_code=400, detail="Missing downloadUrl")
|
||||
if snapshot.request_type.value == "tv":
|
||||
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Sonarr not configured")
|
||||
try:
|
||||
indexers = await client.get_indexers()
|
||||
resolved_indexer_id = _resolve_arr_indexer_id(indexers, indexer_name, indexer_id, "Sonarr")
|
||||
response = None
|
||||
action_message = "Grab sent to Sonarr."
|
||||
if resolved_indexer_id is not None:
|
||||
indexer_id = resolved_indexer_id
|
||||
logger.info("Sonarr grab: attempting DownloadRelease command.")
|
||||
try:
|
||||
response = await client.download_release(str(guid), int(indexer_id))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response is not None and exc.response.status_code in {404, 405}:
|
||||
logger.info("Sonarr grab: DownloadRelease unsupported; will try release push.")
|
||||
response = None
|
||||
else:
|
||||
raise
|
||||
if response is None and push_payload:
|
||||
logger.info("Sonarr grab: attempting release push.")
|
||||
try:
|
||||
response = await client.push_release(push_payload)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response is not None and exc.response.status_code == 404:
|
||||
logger.info("Sonarr grab: release push not supported.")
|
||||
else:
|
||||
raise
|
||||
if response is None:
|
||||
category = _resolve_qbittorrent_category(
|
||||
runtime.sonarr_qbittorrent_category, "sonarr"
|
||||
)
|
||||
qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
|
||||
if qbittorrent_added:
|
||||
action_message = f"Grab sent to qBittorrent (category {category})."
|
||||
response = {"qbittorrent": "queued"}
|
||||
else:
|
||||
if resolved_indexer_id is None:
|
||||
detail = "Indexer not found in Sonarr and no release push available."
|
||||
elif not push_payload:
|
||||
detail = "Sonarr did not accept the grab request (no release URL available)."
|
||||
else:
|
||||
detail = "Sonarr did not accept the grab request (DownloadRelease unsupported)."
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Sonarr grab failed: {exc}") from exc
|
||||
await asyncio.to_thread(
|
||||
save_action, request_id, "grab", "Grab release", "ok", action_message
|
||||
)
|
||||
return {"status": "ok", "response": response}
|
||||
category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
|
||||
if snapshot.request_type.value == "movie":
|
||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Radarr not configured")
|
||||
try:
|
||||
indexers = await client.get_indexers()
|
||||
resolved_indexer_id = _resolve_arr_indexer_id(indexers, indexer_name, indexer_id, "Radarr")
|
||||
response = None
|
||||
action_message = "Grab sent to Radarr."
|
||||
if resolved_indexer_id is not None:
|
||||
indexer_id = resolved_indexer_id
|
||||
logger.info("Radarr grab: attempting DownloadRelease command.")
|
||||
try:
|
||||
response = await client.download_release(str(guid), int(indexer_id))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response is not None and exc.response.status_code in {404, 405}:
|
||||
logger.info("Radarr grab: DownloadRelease unsupported; will try release push.")
|
||||
response = None
|
||||
else:
|
||||
raise
|
||||
if response is None and push_payload:
|
||||
logger.info("Radarr grab: attempting release push.")
|
||||
try:
|
||||
response = await client.push_release(push_payload)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response is not None and exc.response.status_code == 404:
|
||||
logger.info("Radarr grab: release push not supported.")
|
||||
else:
|
||||
raise
|
||||
if response is None:
|
||||
category = _resolve_qbittorrent_category(
|
||||
runtime.radarr_qbittorrent_category, "radarr"
|
||||
)
|
||||
category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
|
||||
if snapshot.request_type.value not in {"tv", "movie"}:
|
||||
raise HTTPException(status_code=400, detail="Unknown request type")
|
||||
|
||||
qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
|
||||
if qbittorrent_added:
|
||||
if not qbittorrent_added:
|
||||
raise HTTPException(status_code=400, detail="Failed to add torrent to qBittorrent")
|
||||
action_message = f"Grab sent to qBittorrent (category {category})."
|
||||
response = {"qbittorrent": "queued"}
|
||||
else:
|
||||
if resolved_indexer_id is None:
|
||||
detail = "Indexer not found in Radarr and no release push available."
|
||||
elif not push_payload:
|
||||
detail = "Radarr did not accept the grab request (no release URL available)."
|
||||
else:
|
||||
detail = "Radarr did not accept the grab request (DownloadRelease unsupported)."
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Radarr grab failed: {exc}") from exc
|
||||
await asyncio.to_thread(
|
||||
save_action, request_id, "grab", "Grab release", "ok", action_message
|
||||
)
|
||||
return {"status": "ok", "response": response}
|
||||
return {"status": "ok", "response": {"qbittorrent": "queued"}}
|
||||
|
||||
raise HTTPException(status_code=400, detail="Unknown request type")
|
||||
|
||||
39
backend/app/routers/site.py
Normal file
39
backend/app/routers/site.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/site", tags=["site"])
|
||||
|
||||
_BANNER_TONES = {"info", "warning", "error", "maintenance"}
|
||||
|
||||
|
||||
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
banner_message = (runtime.site_banner_message or "").strip()
|
||||
tone = (runtime.site_banner_tone or "info").strip().lower()
|
||||
if tone not in _BANNER_TONES:
|
||||
tone = "info"
|
||||
info = {
|
||||
"buildNumber": (runtime.site_build_number or "").strip(),
|
||||
"banner": {
|
||||
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
||||
"message": banner_message,
|
||||
"tone": tone,
|
||||
},
|
||||
}
|
||||
if include_changelog:
|
||||
info["changelog"] = (runtime.site_changelog or "").strip()
|
||||
return info
|
||||
|
||||
|
||||
@router.get("/public")
|
||||
async def site_public() -> Dict[str, Any]:
|
||||
return _build_site_info(False)
|
||||
|
||||
|
||||
@router.get("/info")
|
||||
async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
return _build_site_info(True)
|
||||
@@ -12,6 +12,7 @@ _INT_FIELDS = {
|
||||
}
|
||||
_BOOL_FIELDS = {
|
||||
"jellyfin_sync_to_arr",
|
||||
"site_banner_enabled",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -29,9 +29,12 @@ const SECTION_LABELS: Record<string, string> = {
|
||||
qbittorrent: 'qBittorrent',
|
||||
log: 'Activity log',
|
||||
requests: 'Request syncing',
|
||||
site: 'Site',
|
||||
}
|
||||
|
||||
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr'])
|
||||
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
|
||||
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
|
||||
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
||||
|
||||
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||
jellyseerr: 'Connect the request system where users submit content.',
|
||||
@@ -44,6 +47,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||
qbittorrent: 'Downloader connection settings.',
|
||||
requests: 'Sync and refresh cadence for requests.',
|
||||
log: 'Activity log for troubleshooting.',
|
||||
site: 'Sitewide banner, version, and changelog details.',
|
||||
}
|
||||
|
||||
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||
@@ -58,6 +62,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||
cache: null,
|
||||
logs: 'log',
|
||||
maintenance: null,
|
||||
site: 'site',
|
||||
}
|
||||
|
||||
const labelFromKey = (key: string) =>
|
||||
@@ -78,6 +83,11 @@ const labelFromKey = (key: string) =>
|
||||
.replace('jellyfin public url', 'Jellyfin public URL')
|
||||
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
|
||||
.replace('artwork cache mode', 'Artwork cache mode')
|
||||
.replace('site build number', 'Build number')
|
||||
.replace('site banner enabled', 'Sitewide banner enabled')
|
||||
.replace('site banner message', 'Sitewide banner message')
|
||||
.replace('site banner tone', 'Sitewide banner tone')
|
||||
.replace('site changelog', 'Changelog text')
|
||||
|
||||
type SettingsPageProps = {
|
||||
section: string
|
||||
@@ -290,6 +300,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
requests_data_source: 'Pick where Magent should read requests from.',
|
||||
log_level: 'How much detail is written to the activity log.',
|
||||
log_file: 'Where the activity log is stored.',
|
||||
site_build_number: 'Version or build identifier shown in the footer.',
|
||||
site_banner_enabled: 'Enable a sitewide banner for announcements.',
|
||||
site_banner_message: 'Short banner message for maintenance or updates.',
|
||||
site_banner_tone: 'Visual tone for the banner.',
|
||||
site_changelog: 'One update per line for the public changelog.',
|
||||
}
|
||||
|
||||
const buildSelectOptions = (
|
||||
@@ -1008,6 +1023,34 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (setting.key === 'site_banner_tone') {
|
||||
return (
|
||||
<label key={setting.key} data-helper={helperText || undefined}>
|
||||
<span className="label-row">
|
||||
<span>{labelFromKey(setting.key)}</span>
|
||||
<span className="meta">
|
||||
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
name={setting.key}
|
||||
value={value || 'info'}
|
||||
onChange={(event) =>
|
||||
setFormValues((current) => ({
|
||||
...current,
|
||||
[setting.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{BANNER_TONES.map((tone) => (
|
||||
<option key={tone} value={tone}>
|
||||
{tone.charAt(0).toUpperCase() + tone.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (
|
||||
setting.key === 'requests_full_sync_time' ||
|
||||
setting.key === 'requests_cleanup_time'
|
||||
@@ -1086,6 +1129,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (TEXTAREA_SETTINGS.has(setting.key)) {
|
||||
return (
|
||||
<label key={setting.key} data-helper={helperText || undefined}>
|
||||
<span className="label-row">
|
||||
<span>{labelFromKey(setting.key)}</span>
|
||||
<span className="meta">
|
||||
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||
{setting.sensitive && setting.isSet ? ' stored' : ''}
|
||||
</span>
|
||||
</span>
|
||||
<textarea
|
||||
name={setting.key}
|
||||
rows={setting.key === 'site_changelog' ? 6 : 3}
|
||||
placeholder={
|
||||
setting.key === 'site_changelog'
|
||||
? 'One update per line.'
|
||||
: ''
|
||||
}
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
setFormValues((current) => ({
|
||||
...current,
|
||||
[setting.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<label key={setting.key} data-helper={helperText || undefined}>
|
||||
<span className="label-row">
|
||||
|
||||
@@ -13,6 +13,7 @@ const ALLOWED_SECTIONS = new Set([
|
||||
'cache',
|
||||
'logs',
|
||||
'maintenance',
|
||||
'site',
|
||||
])
|
||||
|
||||
type PageProps = {
|
||||
|
||||
85
frontend/app/changelog/page.tsx
Normal file
85
frontend/app/changelog/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type SiteInfo = {
|
||||
changelog?: string
|
||||
}
|
||||
|
||||
const parseChangelog = (raw: string) =>
|
||||
raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const router = useRouter()
|
||||
const [entries, setEntries] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/site/info`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
throw new Error('Failed to load changelog')
|
||||
}
|
||||
const data: SiteInfo = await response.json()
|
||||
if (!active) return
|
||||
setEntries(parseChangelog(data?.changelog ?? ''))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (!active) return
|
||||
setEntries([])
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (loading) {
|
||||
return <div className="loading-text">Loading changelog...</div>
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return <div className="meta">No updates posted yet.</div>
|
||||
}
|
||||
return (
|
||||
<ul className="changelog-list">
|
||||
{entries.map((entry, index) => (
|
||||
<li key={`${entry}-${index}`}>{entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}, [entries, loading])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<section className="card changelog-card">
|
||||
<div className="changelog-header">
|
||||
<h1>Changelog</h1>
|
||||
<p className="lede">Latest updates and release notes.</p>
|
||||
</div>
|
||||
{content}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -968,6 +968,49 @@ button span {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.site-banner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.site-banner--info {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.site-banner--warning {
|
||||
background: rgba(255, 200, 87, 0.22);
|
||||
border-color: rgba(255, 200, 87, 0.5);
|
||||
}
|
||||
|
||||
.site-banner--error {
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
border-color: rgba(255, 59, 48, 0.4);
|
||||
}
|
||||
|
||||
.site-banner--maintenance {
|
||||
background: rgba(255, 107, 43, 0.18);
|
||||
border-color: rgba(255, 107, 43, 0.4);
|
||||
}
|
||||
|
||||
.site-version {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: 12px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-muted);
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.recent-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1589,3 +1632,21 @@ button span {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.changelog-card {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.changelog-header {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.changelog-list {
|
||||
list-style: disc;
|
||||
padding-left: 22px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import HeaderIdentity from './ui/HeaderIdentity'
|
||||
import ThemeToggle from './ui/ThemeToggle'
|
||||
import BrandingFavicon from './ui/BrandingFavicon'
|
||||
import BrandingLogo from './ui/BrandingLogo'
|
||||
import SiteStatus from './ui/SiteStatus'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Magent',
|
||||
@@ -35,6 +36,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<HeaderActions />
|
||||
</div>
|
||||
</header>
|
||||
<SiteStatus />
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -617,8 +617,8 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Request failed: ${response.status}`)
|
||||
}
|
||||
setActionMessage('Download sent to Sonarr/Radarr.')
|
||||
setModalMessage('Download sent to Sonarr/Radarr.')
|
||||
setActionMessage('Download sent to qBittorrent.')
|
||||
setModalMessage('Download sent to qBittorrent.')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const message = 'Download failed. Check the logs.'
|
||||
|
||||
@@ -25,6 +25,7 @@ const NAV_GROUPS = [
|
||||
{
|
||||
title: 'Admin',
|
||||
items: [
|
||||
{ href: '/admin/site', label: 'Site' },
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/admin/logs', label: 'Activity log' },
|
||||
{ href: '/admin/maintenance', label: 'Maintenance' },
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function HeaderActions() {
|
||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
||||
<a href="/">Requests</a>
|
||||
<a href="/how-it-works">How it works</a>
|
||||
<a href="/changelog">Changelog</a>
|
||||
<a href="/profile">My profile</a>
|
||||
{role === 'admin' && <a href="/admin">Settings</a>}
|
||||
<button type="button" className="header-link" onClick={signOut}>
|
||||
|
||||
65
frontend/app/ui/SiteStatus.tsx
Normal file
65
frontend/app/ui/SiteStatus.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type BannerInfo = {
|
||||
enabled: boolean
|
||||
message: string
|
||||
tone?: string
|
||||
}
|
||||
|
||||
type SiteInfo = {
|
||||
buildNumber?: string
|
||||
banner?: BannerInfo
|
||||
}
|
||||
|
||||
const buildRequest = () => {
|
||||
const token = getToken()
|
||||
const baseUrl = getApiBase()
|
||||
const url = token ? `${baseUrl}/site/info` : `${baseUrl}/site/public`
|
||||
const fetcher = token ? authFetch : fetch
|
||||
return { token, url, fetcher }
|
||||
}
|
||||
|
||||
export default function SiteStatus() {
|
||||
const [info, setInfo] = useState<SiteInfo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const load = async () => {
|
||||
try {
|
||||
const { token, url, fetcher } = buildRequest()
|
||||
const response = await fetcher(url)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && token) {
|
||||
clearToken()
|
||||
}
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!active) return
|
||||
setInfo(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const banner = info?.banner
|
||||
const tone = banner?.tone || 'info'
|
||||
return (
|
||||
<>
|
||||
{banner?.enabled && banner.message ? (
|
||||
<div className={`site-banner site-banner--${tone}`}>{banner.message}</div>
|
||||
) : null}
|
||||
{info?.buildNumber ? (
|
||||
<div className="site-version">Build {info.buildNumber}</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user