Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0667a172d1 | |||
| e163920e21 | |||
| e6b4f99ea7 | |||
| e36da13264 | |||
| 7fcff0f24b | |||
| 91ff47330a |
@@ -71,3 +71,34 @@ jobs:
|
||||
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
||||
run: bash scripts/deploy_ams_dev01.sh
|
||||
|
||||
deploy-beta:
|
||||
if: github.ref_name == 'beta'
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure SSH key
|
||||
env:
|
||||
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
|
||||
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
fi
|
||||
|
||||
- name: Deploy beta to AMS-DEV01
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
|
||||
PROD_DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
|
||||
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
|
||||
run: bash scripts/deploy_beta_ams_dev01.sh
|
||||
|
||||
@@ -23,7 +23,9 @@ class QBittorrentClient(ApiClient):
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
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")
|
||||
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
|
||||
@@ -110,6 +110,9 @@ class Settings(BaseSettings):
|
||||
site_login_show_signup_link: bool = Field(
|
||||
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)
|
||||
|
||||
magent_application_url: Optional[str] = Field(
|
||||
|
||||
@@ -241,6 +241,7 @@ SETTING_KEYS: List[str] = [
|
||||
"site_login_show_local_login",
|
||||
"site_login_show_forgot_password",
|
||||
"site_login_show_signup_link",
|
||||
"site_nav_show_requests",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
||||
"showSignupLink": bool(runtime.site_login_show_signup_link),
|
||||
},
|
||||
"navigation": {
|
||||
"showRequests": bool(runtime.site_nav_show_requests),
|
||||
},
|
||||
}
|
||||
if include_changelog:
|
||||
info["changelog"] = (CHANGELOG or "").strip()
|
||||
|
||||
@@ -39,6 +39,7 @@ _BOOL_FIELDS = {
|
||||
"site_login_show_local_login",
|
||||
"site_login_show_forgot_password",
|
||||
"site_login_show_signup_link",
|
||||
"site_nav_show_requests",
|
||||
}
|
||||
_SKIP_OVERRIDE_FIELDS = {"site_build_number", "site_changelog"}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
@@ -12,6 +14,7 @@ from backend.app.network_security import request_trusts_forwarded_headers, valid
|
||||
from backend.app.routers import auth as auth_router
|
||||
from backend.app.routers import portal as portal_router
|
||||
from backend.app.routers import requests as requests_router
|
||||
from backend.app.routers import site as site_router
|
||||
from backend.app.routers import status as status_router
|
||||
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
|
||||
from backend.app.services import password_reset
|
||||
@@ -97,6 +100,19 @@ class NetworkSecurityTests(unittest.TestCase):
|
||||
|
||||
|
||||
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_qbittorrent_login_accepts_modern_empty_response_with_session_cookie(self) -> None:
|
||||
class FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self.cookies = httpx.Cookies()
|
||||
|
||||
async def post(self, *_args, **_kwargs) -> httpx.Response:
|
||||
self.cookies.set("QBT_SID_8080", "session")
|
||||
return httpx.Response(204, request=httpx.Request("POST", "http://10.0.0.2:8080/api/v2/auth/login"))
|
||||
|
||||
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
|
||||
|
||||
await client._login(FakeClient())
|
||||
|
||||
async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
|
||||
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
|
||||
with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
|
||||
@@ -118,6 +134,26 @@ class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: magent-beta
|
||||
|
||||
services:
|
||||
magent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
APP_NAME: Magent Beta
|
||||
CORS_ALLOW_ORIGIN: https://beta.magent.grizzlyflix.co.nz
|
||||
MAGENT_APPLICATION_URL: https://beta.magent.grizzlyflix.co.nz
|
||||
MAGENT_API_URL: https://beta.magent.grizzlyflix.co.nz/api
|
||||
AUTH_COOKIE_NAME: magent_beta_auth
|
||||
AUTH_STATE_COOKIE_NAME: magent_beta_logged_in
|
||||
AUTH_COOKIE_DOMAIN: beta.magent.grizzlyflix.co.nz
|
||||
SQLITE_PATH: /app/data/magent.db
|
||||
LOG_FILE: /app/data/magent.log
|
||||
SITE_BANNER_ENABLED: "true"
|
||||
SITE_BANNER_MESSAGE: "Beta environment"
|
||||
SITE_BANNER_TONE: warning
|
||||
ports:
|
||||
- "${BETA_FRONTEND_BIND:-10.30.1.32}:3100:3000"
|
||||
- "127.0.0.1:8100:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -19,6 +19,12 @@ type ServiceOptions = {
|
||||
qualityProfiles: { id: number; name: string; label: string }[]
|
||||
}
|
||||
|
||||
type ServiceStatus = {
|
||||
name: string
|
||||
status: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
magent: 'Magent',
|
||||
general: 'General',
|
||||
@@ -44,6 +50,7 @@ const BOOL_SETTINGS = new Set([
|
||||
'site_login_show_local_login',
|
||||
'site_login_show_forgot_password',
|
||||
'site_login_show_signup_link',
|
||||
'site_nav_show_requests',
|
||||
'magent_proxy_enabled',
|
||||
'magent_proxy_trust_forwarded_headers',
|
||||
'magent_ssl_bind_enabled',
|
||||
@@ -266,6 +273,12 @@ const SITE_SECTION_GROUPS: Array<{
|
||||
'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> = {
|
||||
@@ -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_forgot_password: 'Login page: forgot password',
|
||||
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_backup_count: 'Rotated log files to keep',
|
||||
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 message', 'Sitewide banner message')
|
||||
.replace('site banner tone', 'Sitewide banner tone')
|
||||
.replace('site nav show requests', 'Top navigation: Requests')
|
||||
.replace('site changelog', 'Changelog text')
|
||||
|
||||
const formatBytes = (value?: number | null) => {
|
||||
@@ -414,6 +429,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
||||
const [maintenanceBusy, setMaintenanceBusy] = 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 artworkPrefetchRef = useRef<any | null>(null)
|
||||
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(() => {
|
||||
const load = async () => {
|
||||
if (!getToken()) {
|
||||
@@ -550,7 +582,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await loadSettings()
|
||||
await Promise.all([loadSettings(), loadServiceStatuses()])
|
||||
if (section === 'cache' || section === 'artwork') {
|
||||
await loadArtworkPrefetchStatus()
|
||||
await loadArtworkSummary()
|
||||
@@ -570,7 +602,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
if (section === 'radarr') {
|
||||
void loadOptions('radarr')
|
||||
}
|
||||
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
|
||||
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section])
|
||||
|
||||
const groupedSettings = useMemo(() => {
|
||||
const groups: Record<string, AdminSetting[]> = {}
|
||||
@@ -583,6 +615,22 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}, [settings])
|
||||
|
||||
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 isSiteGroupedSection = section === 'site'
|
||||
const visibleSections = settingsSection ? [settingsSection] : []
|
||||
@@ -606,6 +654,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
'site_login_show_local_login',
|
||||
'site_login_show_forgot_password',
|
||||
'site_login_show_signup_link',
|
||||
'site_nav_show_requests',
|
||||
]
|
||||
const sortByOrder = (items: AdminSetting[], order: string[]) => {
|
||||
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_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_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.',
|
||||
}
|
||||
|
||||
@@ -1681,6 +1732,39 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}
|
||||
>
|
||||
{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 ? (
|
||||
<div className="admin-form admin-zone-stack">
|
||||
{settingsSections
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import PortalClient from '../../portal/PortalClient'
|
||||
|
||||
export default function AdminIssuesPage() {
|
||||
return <PortalClient workspace="issue" />
|
||||
}
|
||||
+240
-6
@@ -1,24 +1,258 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
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() {
|
||||
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 (
|
||||
<AdminShell
|
||||
title="Settings"
|
||||
subtitle="Choose what you want to manage."
|
||||
title="Operations Center"
|
||||
subtitle="Live Magent controls, request movement, issue intake, and service health."
|
||||
rail={rail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/')}>
|
||||
Back to requests
|
||||
View health
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<section className="admin-section">
|
||||
<div className="status-banner">
|
||||
Pick a section from the left. Each page explains what it does and how it helps.
|
||||
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
|
||||
{error ? <div className="error-banner">{error}</div> : null}
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</AdminShell>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import './globals.css'
|
||||
import './ops-redesign.css'
|
||||
import type { ReactNode } from 'react'
|
||||
import HeaderActions from './ui/HeaderActions'
|
||||
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'
|
||||
@@ -24,12 +24,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<BrandingLogo className="brand-logo brand-logo--header" />
|
||||
<div className="brand-stack">
|
||||
<div className="brand">Magent</div>
|
||||
<div className="tagline">Find and fix media requests fast.</div>
|
||||
<div className="tagline">GrizzlyFlix media operations</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<ThemeToggle />
|
||||
<span className="beta-chip" aria-label="Beta environment">Beta</span>
|
||||
<HeaderIdentity />
|
||||
</div>
|
||||
<div className="header-nav">
|
||||
|
||||
@@ -108,10 +108,17 @@ export default function LoginPage() {
|
||||
})()
|
||||
|
||||
return (
|
||||
<main className="card auth-card">
|
||||
<main className="auth-screen">
|
||||
<section className="auth-hero">
|
||||
<div className="auth-mark">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<h1>Sign in</h1>
|
||||
<p className="lede">{loginHelpText}</p>
|
||||
</div>
|
||||
<div className="auth-title-block">
|
||||
<span className="section-kicker">Secure access</span>
|
||||
<h1>Magent operational gateway</h1>
|
||||
<p>{loginHelpText}</p>
|
||||
</div>
|
||||
</section>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
if (!primaryMode) {
|
||||
@@ -121,23 +128,25 @@ export default function LoginPage() {
|
||||
}
|
||||
void submit(event, primaryMode)
|
||||
}}
|
||||
className="auth-form"
|
||||
className="auth-form auth-panel"
|
||||
>
|
||||
<label>
|
||||
Username
|
||||
<span>Username</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
@@ -171,6 +180,10 @@ export default function LoginPage() {
|
||||
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
||||
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
||||
) : null}
|
||||
<div className="auth-footnote">
|
||||
<span className="live-dot" aria-hidden="true" />
|
||||
Beta environment
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+75
-34
@@ -362,50 +362,92 @@ export default function HomePage() {
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="card">
|
||||
<div className="layout-grid">
|
||||
<section className="recent centerpiece">
|
||||
<div className="system-status">
|
||||
<div className="system-header">
|
||||
<h2>System status</h2>
|
||||
<span
|
||||
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
|
||||
>
|
||||
{servicesLoading
|
||||
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'
|
||||
: servicesStatus?.overall === 'up'
|
||||
: serviceOverall === 'up'
|
||||
? 'Services are up and running'
|
||||
: servicesStatus?.overall === 'down'
|
||||
: serviceOverall === 'down'
|
||||
? 'Something is down'
|
||||
: 'Some services need attention'}
|
||||
</span>
|
||||
: '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 (
|
||||
<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">
|
||||
<section className="recent centerpiece">
|
||||
<details className="system-status system-status-dropdown">
|
||||
<summary className="system-summary">
|
||||
<span className="system-summary-copy">
|
||||
<span className="section-kicker">System status</span>
|
||||
<strong>{serviceSummary}</strong>
|
||||
<span>{serviceStatusLabel}</span>
|
||||
</span>
|
||||
<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">
|
||||
{(() => {
|
||||
const order = [
|
||||
'Seerr',
|
||||
'Sonarr',
|
||||
'Radarr',
|
||||
'Prowlarr',
|
||||
'qBittorrent',
|
||||
'Jellyfin',
|
||||
]
|
||||
const items = servicesStatus?.services ?? []
|
||||
return order.map((name) => {
|
||||
const item = items.find((entry) => entry.name === name)
|
||||
const status = item?.status ?? 'unknown'
|
||||
{orderedServices.map(({ name, status, message }) => {
|
||||
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>
|
||||
)}
|
||||
<span className="system-test-message">
|
||||
{serviceTestResults[name] ?? message ?? 'No recent detail'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-actions">
|
||||
<span className="system-state">
|
||||
@@ -430,10 +472,9 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
<div className="recent-header">
|
||||
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
||||
{authReady && (
|
||||
|
||||
@@ -374,7 +374,7 @@ export default function RequestTimelinePage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="card">
|
||||
<main className="card request-detail-page">
|
||||
<div className="loading-center" role="status" aria-live="polite">
|
||||
<div className="spinner" aria-hidden="true" />
|
||||
<div className="loading-text">Loading request timeline...</div>
|
||||
@@ -384,11 +384,43 @@ export default function RequestTimelinePage() {
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return <main className="card">{loadError}</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>{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) {
|
||||
return <main className="card">Unable to load this 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 =
|
||||
@@ -437,7 +469,7 @@ export default function RequestTimelinePage() {
|
||||
: friendlyState(snapshot.state)
|
||||
|
||||
return (
|
||||
<main className="card">
|
||||
<main className="card request-detail-page">
|
||||
<div className="request-header">
|
||||
<div className="request-header-main">
|
||||
{resolvedPoster && (
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
||||
<main className="card admin-card">
|
||||
<div className="admin-header">
|
||||
<div>
|
||||
<span className="section-kicker">Beta stream</span>
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <p className="lede">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
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',
|
||||
items: [
|
||||
@@ -21,6 +30,7 @@ const NAV_GROUPS = [
|
||||
{ href: '/admin/requests', label: 'Request sync' },
|
||||
{ href: '/admin/requests-all', label: 'All requests' },
|
||||
{ href: '/admin/cache', label: 'Cache Control' },
|
||||
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
type BrandingLogoProps = {
|
||||
className?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
return (
|
||||
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
|
||||
{!failed ? (
|
||||
<img
|
||||
className={className}
|
||||
className={loaded ? 'is-loaded' : undefined}
|
||||
src="/api/branding/logo.png"
|
||||
alt={alt}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : null}
|
||||
{!loaded ? (
|
||||
<svg aria-hidden="true" viewBox="0 0 64 64" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id="magentLogoGlow" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#7ed7ff" />
|
||||
<stop offset="100%" stopColor="#c6c1ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="12" fill="#0b1328" />
|
||||
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
|
||||
<path
|
||||
d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z"
|
||||
fill="url(#magentLogoGlow)"
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [signedIn, setSignedIn] = useState(false)
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const [showRequestsNav, setShowRequestsNav] = useState(true)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
setSignedIn(Boolean(token))
|
||||
if (!token) {
|
||||
setShowRequestsNav(true)
|
||||
return
|
||||
}
|
||||
const load = async () => {
|
||||
try {
|
||||
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) {
|
||||
clearToken()
|
||||
setSignedIn(false)
|
||||
setRole(null)
|
||||
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) {
|
||||
console.error(err)
|
||||
setShowRequestsNav(true)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
@@ -33,18 +50,64 @@ export default function HeaderActions() {
|
||||
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 (
|
||||
<div className="header-actions">
|
||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
||||
<div className="header-actions-center">
|
||||
<a href="/how-it-works">How it works</a>
|
||||
</div>
|
||||
<div className="header-actions-right">
|
||||
<a href="/">Requests</a>
|
||||
<a href="/profile/invites">Invites</a>
|
||||
<a href="/portal/requests">Portal</a>
|
||||
<a href="/portal/issues">Issues</a>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="header-actions" aria-label="Primary">
|
||||
{items.map((item, index) => {
|
||||
const active = item.match(pathname)
|
||||
return (
|
||||
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
|
||||
<span aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||
{item.label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
|
||||
deploy_user="${DEPLOY_USER:-zak}"
|
||||
prod_path="${PROD_DEPLOY_PATH:-/home/${deploy_user}/magent}"
|
||||
deploy_path="${BETA_DEPLOY_PATH:-/home/${deploy_user}/magent-beta}"
|
||||
beta_frontend_bind="${BETA_FRONTEND_BIND:-10.30.1.32}"
|
||||
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
remote="${deploy_user}@${deploy_host}"
|
||||
|
||||
echo "Deploying tracked beta repository contents to ${remote}:${deploy_path}"
|
||||
|
||||
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
mkdir -p '${deploy_path}'
|
||||
backup_root=\"\${HOME}/magent-beta-backups/${timestamp}\"
|
||||
mkdir -p \"\${backup_root}\"
|
||||
cd '${deploy_path}'
|
||||
for path in backend frontend docker-compose.yml docker-compose.hub.yml docker-compose.beta.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
|
||||
if [ -e \"\$path\" ]; then
|
||||
cp -a \"\$path\" \"\${backup_root}/\"
|
||||
fi
|
||||
done
|
||||
tar -xf - -C '${deploy_path}'
|
||||
|
||||
if [ ! -f '${deploy_path}/.env' ] && [ -f '${prod_path}/.env' ]; then
|
||||
cp '${prod_path}/.env' '${deploy_path}/.env'
|
||||
fi
|
||||
|
||||
mkdir -p '${deploy_path}/data'
|
||||
if [ ! -f '${deploy_path}/data/magent.db' ] && [ -d '${prod_path}/data' ]; then
|
||||
cp -a '${prod_path}/data/.' '${deploy_path}/data/'
|
||||
fi
|
||||
|
||||
cd '${deploy_path}'
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml build
|
||||
docker compose -p magent-beta -f docker-compose.beta.yml up -d
|
||||
"
|
||||
|
||||
echo "Running remote beta smoke checks"
|
||||
ssh ${ssh_opts} "${remote}" "
|
||||
set -e
|
||||
python3 - <<'PY'
|
||||
from urllib import request
|
||||
|
||||
checks = [
|
||||
('http://127.0.0.1:8100/health', 200),
|
||||
('http://${beta_frontend_bind}:3100/login', 200),
|
||||
]
|
||||
|
||||
for url, expected in checks:
|
||||
with request.urlopen(url, timeout=20) as response:
|
||||
if response.status != expected:
|
||||
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
|
||||
print(url, response.status)
|
||||
PY
|
||||
"
|
||||
|
||||
echo "Beta deployment completed successfully"
|
||||
Reference in New Issue
Block a user