Add site banner, build number, and changelog
This commit is contained in:
@@ -38,6 +38,21 @@ class Settings(BaseSettings):
|
|||||||
artwork_cache_mode: str = Field(
|
artwork_cache_mode: str = Field(
|
||||||
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
|
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(
|
jellyseerr_base_url: Optional[str] = Field(
|
||||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
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.branding import router as branding_router
|
||||||
from .routers.status import router as status_router
|
from .routers.status import router as status_router
|
||||||
from .routers.feedback import router as feedback_router
|
from .routers.feedback import router as feedback_router
|
||||||
|
from .routers.site import router as site_router
|
||||||
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .runtime import get_runtime_settings
|
from .runtime import get_runtime_settings
|
||||||
@@ -56,3 +57,4 @@ app.include_router(images_router)
|
|||||||
app.include_router(branding_router)
|
app.include_router(branding_router)
|
||||||
app.include_router(status_router)
|
app.include_router(status_router)
|
||||||
app.include_router(feedback_router)
|
app.include_router(feedback_router)
|
||||||
|
app.include_router(site_router)
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ SETTING_KEYS: List[str] = [
|
|||||||
"requests_cleanup_time",
|
"requests_cleanup_time",
|
||||||
"requests_cleanup_days",
|
"requests_cleanup_days",
|
||||||
"requests_data_source",
|
"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]]:
|
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())
|
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(
|
def _resolve_arr_indexer_id(
|
||||||
indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
|
indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
@@ -1745,122 +1796,22 @@ async def action_grab(
|
|||||||
bool(release_title),
|
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()
|
runtime = get_runtime_settings()
|
||||||
|
if not download_url:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing downloadUrl")
|
||||||
if snapshot.request_type.value == "tv":
|
if snapshot.request_type.value == "tv":
|
||||||
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
|
||||||
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}
|
|
||||||
if snapshot.request_type.value == "movie":
|
if snapshot.request_type.value == "movie":
|
||||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
|
||||||
if not client.configured():
|
if snapshot.request_type.value not in {"tv", "movie"}:
|
||||||
raise HTTPException(status_code=400, detail="Radarr not configured")
|
raise HTTPException(status_code=400, detail="Unknown request type")
|
||||||
try:
|
|
||||||
indexers = await client.get_indexers()
|
qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
|
||||||
resolved_indexer_id = _resolve_arr_indexer_id(indexers, indexer_name, indexer_id, "Radarr")
|
if not qbittorrent_added:
|
||||||
response = None
|
raise HTTPException(status_code=400, detail="Failed to add torrent to qBittorrent")
|
||||||
action_message = "Grab sent to Radarr."
|
action_message = f"Grab sent to qBittorrent (category {category})."
|
||||||
if resolved_indexer_id is not None:
|
await asyncio.to_thread(
|
||||||
indexer_id = resolved_indexer_id
|
save_action, request_id, "grab", "Grab release", "ok", action_message
|
||||||
logger.info("Radarr grab: attempting DownloadRelease command.")
|
)
|
||||||
try:
|
return {"status": "ok", "response": {"qbittorrent": "queued"}}
|
||||||
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"
|
|
||||||
)
|
|
||||||
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 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}
|
|
||||||
|
|
||||||
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 = {
|
_BOOL_FIELDS = {
|
||||||
"jellyfin_sync_to_arr",
|
"jellyfin_sync_to_arr",
|
||||||
|
"site_banner_enabled",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ const SECTION_LABELS: Record<string, string> = {
|
|||||||
qbittorrent: 'qBittorrent',
|
qbittorrent: 'qBittorrent',
|
||||||
log: 'Activity log',
|
log: 'Activity log',
|
||||||
requests: 'Request syncing',
|
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> = {
|
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||||
jellyseerr: 'Connect the request system where users submit content.',
|
jellyseerr: 'Connect the request system where users submit content.',
|
||||||
@@ -44,6 +47,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
|||||||
qbittorrent: 'Downloader connection settings.',
|
qbittorrent: 'Downloader connection settings.',
|
||||||
requests: 'Sync and refresh cadence for requests.',
|
requests: 'Sync and refresh cadence for requests.',
|
||||||
log: 'Activity log for troubleshooting.',
|
log: 'Activity log for troubleshooting.',
|
||||||
|
site: 'Sitewide banner, version, and changelog details.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||||
@@ -58,6 +62,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
|||||||
cache: null,
|
cache: null,
|
||||||
logs: 'log',
|
logs: 'log',
|
||||||
maintenance: null,
|
maintenance: null,
|
||||||
|
site: 'site',
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelFromKey = (key: string) =>
|
const labelFromKey = (key: string) =>
|
||||||
@@ -78,6 +83,11 @@ const labelFromKey = (key: string) =>
|
|||||||
.replace('jellyfin public url', 'Jellyfin public URL')
|
.replace('jellyfin public url', 'Jellyfin public URL')
|
||||||
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
|
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
|
||||||
.replace('artwork cache mode', 'Artwork cache mode')
|
.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 = {
|
type SettingsPageProps = {
|
||||||
section: string
|
section: string
|
||||||
@@ -290,6 +300,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
requests_data_source: 'Pick where Magent should read requests from.',
|
requests_data_source: 'Pick where Magent should read requests from.',
|
||||||
log_level: 'How much detail is written to the activity log.',
|
log_level: 'How much detail is written to the activity log.',
|
||||||
log_file: 'Where the activity log is stored.',
|
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 = (
|
const buildSelectOptions = (
|
||||||
@@ -1008,6 +1023,34 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</label>
|
</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 (
|
if (
|
||||||
setting.key === 'requests_full_sync_time' ||
|
setting.key === 'requests_full_sync_time' ||
|
||||||
setting.key === 'requests_cleanup_time'
|
setting.key === 'requests_cleanup_time'
|
||||||
@@ -1086,6 +1129,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</label>
|
</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 (
|
return (
|
||||||
<label key={setting.key} data-helper={helperText || undefined}>
|
<label key={setting.key} data-helper={helperText || undefined}>
|
||||||
<span className="label-row">
|
<span className="label-row">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const ALLOWED_SECTIONS = new Set([
|
|||||||
'cache',
|
'cache',
|
||||||
'logs',
|
'logs',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
|
'site',
|
||||||
])
|
])
|
||||||
|
|
||||||
type PageProps = {
|
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);
|
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 {
|
.recent-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1589,3 +1632,21 @@ button span {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
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 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'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Magent',
|
title: 'Magent',
|
||||||
@@ -35,6 +36,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<HeaderActions />
|
<HeaderActions />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<SiteStatus />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -617,8 +617,8 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
|
|||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
throw new Error(text || `Request failed: ${response.status}`)
|
throw new Error(text || `Request failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
setActionMessage('Download sent to Sonarr/Radarr.')
|
setActionMessage('Download sent to qBittorrent.')
|
||||||
setModalMessage('Download sent to Sonarr/Radarr.')
|
setModalMessage('Download sent to qBittorrent.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
const message = 'Download failed. Check the logs.'
|
const message = 'Download failed. Check the logs.'
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const NAV_GROUPS = [
|
|||||||
{
|
{
|
||||||
title: 'Admin',
|
title: 'Admin',
|
||||||
items: [
|
items: [
|
||||||
|
{ href: '/admin/site', label: 'Site' },
|
||||||
{ href: '/users', label: 'Users' },
|
{ href: '/users', label: 'Users' },
|
||||||
{ href: '/admin/logs', label: 'Activity log' },
|
{ href: '/admin/logs', label: 'Activity log' },
|
||||||
{ href: '/admin/maintenance', label: 'Maintenance' },
|
{ 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 className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
||||||
<a href="/">Requests</a>
|
<a href="/">Requests</a>
|
||||||
<a href="/how-it-works">How it works</a>
|
<a href="/how-it-works">How it works</a>
|
||||||
|
<a href="/changelog">Changelog</a>
|
||||||
<a href="/profile">My profile</a>
|
<a href="/profile">My profile</a>
|
||||||
{role === 'admin' && <a href="/admin">Settings</a>}
|
{role === 'admin' && <a href="/admin">Settings</a>}
|
||||||
<button type="button" className="header-link" onClick={signOut}>
|
<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