diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 0f9102e..b44a20c 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -4,6 +4,7 @@ on: push: branches: - beta + - main - prod workflow_dispatch: diff --git a/PRODUCTION.md b/PRODUCTION.md new file mode 100644 index 0000000..b5b2507 --- /dev/null +++ b/PRODUCTION.md @@ -0,0 +1,33 @@ +# Fresh production setup + +Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and +`docker-compose.production.yml`. The legacy `prod` deployment and beta are not +overwritten. Main runs CI verification; production activation is deliberately +manual during the initial cutover. + +Only API connection URLs/credentials and SMTP configuration are exported by +`scripts/prepare_production_settings.py`. It reads the source's effective settings, +uses an explicit allowlist, refuses existing output directories, and creates +private files. It never copies a database, users, invite codes, issues, history, +tokens, sessions, branding or notification templates. A new bootstrap admin and +JWT secret are generated. Retrieve the bootstrap credentials from the protected +`bootstrap-admin.json` on the server; never commit them. + +The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables +`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic +imports and repair emails. The cover is not an authentication/security boundary; +normal API authentication remains in force. Administrators can use `/login`. + +Run `docker compose -f docker-compose.production.yml up -d --build` from the +production directory. Caddy should proxy this hostname to `10.30.1.32:3200`; +Next forwards `/api` internally. The backend health port is localhost-only at +8200. Do not alter beta's route or other Caddy sites. + +Before public activation, validate Caddy config, save its existing configuration, +verify HTTPS, admin login, connection diagnostics and the empty-client-data state. +Do not send SMTP tests without approval. Keep the old upstream for rollback. + +At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`, +then recreate the container. External service records can then be imported through +normal synchronization; no beta client data is migrated. Review quality profiles, +root folders, invite policy and notification rules in admin settings before use. diff --git a/backend/app/main.py b/backend/app/main.py index 43f6e8a..2aa0d8e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ import asyncio import logging +import os import time import uuid from typing import Awaitable, Callable @@ -251,6 +252,9 @@ async def startup() -> None: runtime.log_background_sync_level, runtime.requests_data_source, ) + if os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() == "false": + logger.info("Background imports and automation paused for initial setup") + return _launch_background_task("jellyfin-sync", run_daily_jellyfin_sync) _launch_background_task("requests-warmup", startup_warmup_requests_cache) _launch_background_task("requests-delta-loop", run_requests_delta_loop) diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..f4eee3d --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,13 @@ +name: magent-production + +services: + magent: + build: . + env_file: + - ./.env + ports: + - "10.30.1.32:3200:3000" + - "127.0.0.1:8200:8000" + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/frontend/app/MyRequests.tsx b/frontend/app/MyRequests.tsx new file mode 100644 index 0000000..52fae34 --- /dev/null +++ b/frontend/app/MyRequests.tsx @@ -0,0 +1,407 @@ +'use client' + +import PageHeading from './ui/PageHeading' + +import { useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth' + +const normalizeRecentResults = (items: any[]) => + items + .filter((item: any) => item?.id) + .map((item: any) => { + const id = item.id + const rawTitle = item.title + const placeholder = + typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}` + return { + id, + title: !rawTitle || placeholder ? `Request #${id}` : rawTitle, + year: item.year, + type: item.type, + statusLabel: item.statusLabel, + artwork: item.artwork, + createdAt: item.createdAt ?? null, + } + }) + +const REQUEST_STAGE_OPTIONS = [ + { value: 'all', label: 'All stages' }, + { value: 'pending', label: 'Waiting' }, + { value: 'approved', label: 'Approved' }, + { value: 'in_progress', label: 'In progress' }, + { value: 'working', label: 'Working' }, + { value: 'partial', label: 'Partial' }, + { value: 'ready', label: 'Ready' }, + { value: 'declined', label: 'Declined' }, +] + +export default function HomePage() { + const router = useRouter() + const [query, setQuery] = useState('') + const [recent, setRecent] = useState< + { + id: number + title: string + year?: number + type?: string + statusLabel?: string + artwork?: { poster_url?: string; backdrop_url?: string } + createdAt?: string | null + }[] + >([]) + const [recentError, setRecentError] = useState(null) + const [recentLoading, setRecentLoading] = useState(false) + const [searchResults, setSearchResults] = useState< + { + title: string + year?: number + type?: string + requestId?: number + statusLabel?: string + requestedBy?: string | null + accessible?: boolean + }[] + >([]) + const [searchError, setSearchError] = useState(null) + const [role, setRole] = useState(null) + const [recentDays, setRecentDays] = useState(90) + const [recentStage, setRecentStage] = useState('all') + const [authReady, setAuthReady] = useState(false) + + const submit = (event: React.FormEvent) => { + event.preventDefault() + const trimmed = query.trim() + if (!trimmed) return + if (/^\d+$/.test(trimmed)) { + router.push(`/requests/${encodeURIComponent(trimmed)}`) + return + } + void runSearch(trimmed) + } + + useEffect(() => { + if (!getToken()) { + router.push('/login') + return + } + const load = async () => { + setRecentLoading(true) + setRecentError(null) + try { + const baseUrl = getApiBase() + const meResponse = await authFetch(`${baseUrl}/auth/me`) + if (!meResponse.ok) { + if (meResponse.status === 401) { + clearToken() + router.push('/login') + return + } + throw new Error(`Auth failed: ${meResponse.status}`) + } + const me = await meResponse.json() + const userRole = me?.role ?? null + setRole(userRole) + setAuthReady(true) + const take = userRole === 'admin' ? 50 : 6 + const params = new URLSearchParams({ + take: String(take), + days: String(recentDays), + }) + if (recentStage !== 'all') { + params.set('stage', recentStage) + } + const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + throw new Error(`Recent requests failed: ${response.status}`) + } + const data = await response.json() + if (Array.isArray(data?.results)) { + setRecent(normalizeRecentResults(data.results)) + } + } catch (error) { + console.error(error) + setRecentError('Recent requests are not available right now.') + } finally { + setRecentLoading(false) + } + } + + load() + }, [recentDays, recentStage]) + + useEffect(() => { + if (!authReady) { + return + } + if (!getToken()) { + return + } + const baseUrl = getApiBase() + let closed = false + let source: EventSource | null = null + + const connect = async () => { + try { + const streamToken = await getEventStreamToken() + if (closed) return + const params = new URLSearchParams({ + stream_token: streamToken, + recent_days: String(recentDays), + }) + if (recentStage !== 'all') { + params.set('recent_stage', recentStage) + } + const streamUrl = `${baseUrl}/events/stream?${params.toString()}` + source = new EventSource(streamUrl) + + source.onmessage = (event) => { + if (closed) return + try { + const payload = JSON.parse(event.data) + if (!payload || typeof payload !== 'object') { + return + } + if (payload.type === 'home_recent') { + if (Array.isArray(payload.results)) { + setRecent(normalizeRecentResults(payload.results)) + setRecentError(null) + setRecentLoading(false) + } else if (typeof payload.error === 'string' && payload.error.trim()) { + setRecentError('Recent requests are not available right now.') + setRecentLoading(false) + } + return + } + } catch (error) { + console.error(error) + } + } + + } catch (error) { + if (closed) return + console.error(error) + } + } + + void connect() + + return () => { + closed = true + source?.close() + } + }, [authReady, recentDays, recentStage]) + + const runSearch = async (term: string) => { + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + throw new Error(`Search failed: ${response.status}`) + } + const data = await response.json() + if (Array.isArray(data?.results)) { + setSearchResults( + data.results.map((item: any) => ({ + title: item.title, + year: item.year, + type: item.type, + requestId: item.requestId, + statusLabel: item.statusLabel, + requestedBy: item.requestedBy ?? null, + accessible: Boolean(item.accessible), + })) + ) + setSearchError(null) + } + } catch (error) { + console.error(error) + setSearchError('Search failed. Try a request ID instead.') + setSearchResults([]) + } + } + + const resolveArtworkUrl = (url?: string | null) => { + if (!url) return null + return url.startsWith('http') ? url : `${getApiBase()}${url}` + } + + const formatRequestTime = (value?: string | null) => { + if (!value) return null + const date = new Date(value) + if (Number.isNaN(date.valueOf())) return value + return date.toLocaleString() + } + + const activeRecentCount = recent.filter((item) => { + const label = String(item.statusLabel ?? '').toLowerCase() + return !label.includes('ready') && !label.includes('available') && !label.includes('declined') + }).length + const readyRecentCount = recent.filter((item) => { + const label = String(item.statusLabel ?? '').toLowerCase() + return label.includes('ready') || label.includes('available') + }).length + + const requestCardState = (value?: string) => { + const label = String(value ?? '').toLowerCase() + if (label.includes('ready') || label.includes('available')) return { key: 'ready', label: value || 'Ready', progress: 100 } + if (label.includes('declined') || label.includes('failed') || label.includes('error')) return { key: 'attention', label: value || 'Needs attention', progress: 12 } + if (label.includes('partial')) return { key: 'attention', label: value || 'Partially ready', progress: 65 } + if (label.includes('working') || label.includes('progress') || label.includes('download')) return { key: 'processing', label: value || 'In progress', progress: 58 } + return { key: 'waiting', label: value || 'Waiting', progress: 4 } + } + + return ( +
+ + +
+ setQuery(event.target.value)} + placeholder="Dune 2021 or 1289" + /> + +
+ + } /> + + {(searchError || searchResults.length > 0) && ( +
+
+
+ Search results +

{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}

+
+ +
+ {searchError ? ( +
{searchError}
+ ) : ( +
+ {searchResults.map((item, index) => ( + + ))} +
+ )} +
+ )} + +
+
In view{recent.length}
+
In progress{activeRecentCount}
+
Ready{readyRecentCount}
+
+ +
+
+
+ Request activity +

{role === 'admin' ? 'Recent requests' : 'My recent requests'}

+
+ {authReady && ( +
+ +
+ )} +
+ {authReady && ( +
+ {REQUEST_STAGE_OPTIONS.filter((option) => ['all', 'pending', 'in_progress', 'working', 'ready'].includes(option.value)).map((option) => ( +