Build guided media issue workflow
This commit is contained in:
@@ -213,6 +213,17 @@ class JellyfinClient(ApiClient):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
async def get_sessions(self) -> Optional[list[Dict[str, Any]]]:
|
||||||
|
if not self.base_url or not self.api_key:
|
||||||
|
return None
|
||||||
|
url = f"{self.base_url}/Sessions"
|
||||||
|
headers = self._emby_headers()
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
return payload if isinstance(payload, list) else []
|
||||||
|
|
||||||
async def refresh_library(self, recursive: bool = True) -> None:
|
async def refresh_library(self, recursive: bool = True) -> None:
|
||||||
if not self.base_url or not self.api_key:
|
if not self.base_url or not self.api_key:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
|
from ..clients.jellyfin import JellyfinClient
|
||||||
from ..db import (
|
from ..db import (
|
||||||
add_portal_comment,
|
add_portal_comment,
|
||||||
count_portal_items,
|
count_portal_items,
|
||||||
@@ -18,6 +21,7 @@ from ..db import (
|
|||||||
update_portal_item,
|
update_portal_item,
|
||||||
)
|
)
|
||||||
from ..services.notifications import send_portal_notification
|
from ..services.notifications import send_portal_notification
|
||||||
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
|
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -55,6 +59,10 @@ PORTAL_MEDIA_STATUSES = {
|
|||||||
PORTAL_ISSUE_TYPES = {
|
PORTAL_ISSUE_TYPES = {
|
||||||
"general",
|
"general",
|
||||||
"playback",
|
"playback",
|
||||||
|
"transcode",
|
||||||
|
"service_unavailable",
|
||||||
|
"broken_media",
|
||||||
|
"audio",
|
||||||
"subtitle",
|
"subtitle",
|
||||||
"quality",
|
"quality",
|
||||||
"metadata",
|
"metadata",
|
||||||
@@ -62,6 +70,9 @@ PORTAL_ISSUE_TYPES = {
|
|||||||
"other",
|
"other",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_MEDIA_STATUS_CACHE: Dict[str, Any] = {"expires_at": 0.0, "payload": None}
|
||||||
|
_MEDIA_STATUS_CACHE_SECONDS = 15.0
|
||||||
|
|
||||||
REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = {
|
REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = {
|
||||||
"pending": {"pending", "approved", "declined"},
|
"pending": {"pending", "approved", "declined"},
|
||||||
"approved": {"approved", "declined"},
|
"approved": {"approved", "declined"},
|
||||||
@@ -339,6 +350,36 @@ def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool:
|
|||||||
return str(user.get("username") or "") == str(item.get("created_by_username") or "")
|
return str(user.get("username") or "") == str(item.get("created_by_username") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _public_media_status_payload(
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
headline: str,
|
||||||
|
message: str,
|
||||||
|
latency_ms: Optional[int] = None,
|
||||||
|
version: Optional[str] = None,
|
||||||
|
restart_pending: Optional[bool] = None,
|
||||||
|
active_streams: Optional[int] = None,
|
||||||
|
transcoding_streams: Optional[int] = None,
|
||||||
|
session_check_available: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"status": status,
|
||||||
|
"headline": headline,
|
||||||
|
"message": message,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"server": {
|
||||||
|
"version": version,
|
||||||
|
"restart_pending": restart_pending,
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active_streams": active_streams,
|
||||||
|
"transcoding_streams": transcoding_streams,
|
||||||
|
"available": session_check_available,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
|
def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
is_admin = _is_admin(user)
|
is_admin = _is_admin(user)
|
||||||
is_owner = _is_owner(user, item)
|
is_owner = _is_owner(user, item)
|
||||||
@@ -406,6 +447,122 @@ async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_use
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/issues/media-status")
|
||||||
|
async def portal_media_status() -> Dict[str, Any]:
|
||||||
|
"""Return a short, privacy-safe Jellyfin health check for guided issue reporting."""
|
||||||
|
now = time.monotonic()
|
||||||
|
cached_payload = _MEDIA_STATUS_CACHE.get("payload")
|
||||||
|
if isinstance(cached_payload, dict) and now < float(_MEDIA_STATUS_CACHE.get("expires_at") or 0):
|
||||||
|
return cached_payload
|
||||||
|
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
|
if not jellyfin.configured():
|
||||||
|
payload = _public_media_status_payload(
|
||||||
|
status="not_configured",
|
||||||
|
headline="Media server status is unavailable",
|
||||||
|
message="Magent cannot run a playback check right now. Your report can still be submitted.",
|
||||||
|
)
|
||||||
|
_MEDIA_STATUS_CACHE.update(
|
||||||
|
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
try:
|
||||||
|
system_info = await jellyfin.get_system_info()
|
||||||
|
except (httpx.HTTPError, RuntimeError, ValueError):
|
||||||
|
latency_ms = round((time.perf_counter() - started_at) * 1000)
|
||||||
|
payload = _public_media_status_payload(
|
||||||
|
status="down",
|
||||||
|
headline="The media server is not responding",
|
||||||
|
message=(
|
||||||
|
"This looks broader than one title. The report will include the failed server check "
|
||||||
|
"so an administrator can investigate the service first."
|
||||||
|
),
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
_MEDIA_STATUS_CACHE.update(
|
||||||
|
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
logger.exception("guided issue Jellyfin system check failed")
|
||||||
|
latency_ms = round((time.perf_counter() - started_at) * 1000)
|
||||||
|
payload = _public_media_status_payload(
|
||||||
|
status="down",
|
||||||
|
headline="The media server check failed",
|
||||||
|
message="Your report can still be submitted and will include this failed service check.",
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
_MEDIA_STATUS_CACHE.update(
|
||||||
|
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
latency_ms = round((time.perf_counter() - started_at) * 1000)
|
||||||
|
info = system_info if isinstance(system_info, dict) else {}
|
||||||
|
version_value = info.get("Version")
|
||||||
|
version = str(version_value).strip() if version_value is not None else None
|
||||||
|
restart_pending = bool(info.get("HasPendingRestart"))
|
||||||
|
|
||||||
|
session_check_available = False
|
||||||
|
active_streams: Optional[int] = None
|
||||||
|
transcoding_streams: Optional[int] = None
|
||||||
|
try:
|
||||||
|
sessions = await jellyfin.get_sessions()
|
||||||
|
if isinstance(sessions, list):
|
||||||
|
session_check_available = True
|
||||||
|
active_streams = sum(
|
||||||
|
1 for session in sessions if isinstance(session, dict) and session.get("NowPlayingItem")
|
||||||
|
)
|
||||||
|
transcoding_streams = sum(
|
||||||
|
1
|
||||||
|
for session in sessions
|
||||||
|
if isinstance(session, dict)
|
||||||
|
and session.get("NowPlayingItem")
|
||||||
|
and session.get("TranscodingInfo")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("guided issue Jellyfin session check unavailable", exc_info=True)
|
||||||
|
|
||||||
|
if restart_pending:
|
||||||
|
status = "degraded"
|
||||||
|
headline = "Media server is online but needs attention"
|
||||||
|
message = "Jellyfin is responding, but it reports that a restart is pending."
|
||||||
|
elif session_check_available and active_streams:
|
||||||
|
status = "up"
|
||||||
|
headline = "Media server is online and actively streaming"
|
||||||
|
message = (
|
||||||
|
"Other playback is currently working, so this is more likely specific to the title, "
|
||||||
|
"audio track, subtitle, client, or transcode path."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status = "up"
|
||||||
|
headline = "Media server is online"
|
||||||
|
message = "Jellyfin responded normally. Continue with the report if playback is still failing."
|
||||||
|
|
||||||
|
payload = _public_media_status_payload(
|
||||||
|
status=status,
|
||||||
|
headline=headline,
|
||||||
|
message=message,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
version=version,
|
||||||
|
restart_pending=restart_pending,
|
||||||
|
active_streams=active_streams,
|
||||||
|
transcoding_streams=transcoding_streams,
|
||||||
|
session_check_available=session_check_available,
|
||||||
|
)
|
||||||
|
_MEDIA_STATUS_CACHE.update(
|
||||||
|
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items")
|
@router.get("/items")
|
||||||
async def portal_list_items(
|
async def portal_list_items(
|
||||||
kind: Optional[str] = None,
|
kind: Optional[str] = None,
|
||||||
|
|||||||
@@ -1166,6 +1166,67 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
portal_router._MEDIA_STATUS_CACHE.update(expires_at=0.0, payload=None)
|
||||||
|
|
||||||
|
async def test_media_status_is_live_and_removes_session_identity(self) -> None:
|
||||||
|
client = SimpleNamespace(
|
||||||
|
configured=lambda: True,
|
||||||
|
get_system_info=AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"Version": "10.10.7",
|
||||||
|
"HasPendingRestart": False,
|
||||||
|
"WanAddress": "https://private.example",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
get_sessions=AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"UserName": "private-user",
|
||||||
|
"DeviceName": "Living room television",
|
||||||
|
"NowPlayingItem": {"Name": "Private title"},
|
||||||
|
"TranscodingInfo": {"VideoCodec": "h264"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(portal_router, "get_runtime_settings", return_value=SimpleNamespace(
|
||||||
|
jellyfin_base_url="http://jellyfin",
|
||||||
|
jellyfin_api_key="secret",
|
||||||
|
)),
|
||||||
|
patch.object(portal_router, "JellyfinClient", return_value=client),
|
||||||
|
):
|
||||||
|
result = await portal_router.portal_media_status()
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "up")
|
||||||
|
self.assertEqual(result["activity"]["active_streams"], 1)
|
||||||
|
self.assertEqual(result["activity"]["transcoding_streams"], 1)
|
||||||
|
serialized = str(result)
|
||||||
|
self.assertNotIn("private-user", serialized)
|
||||||
|
self.assertNotIn("Living room television", serialized)
|
||||||
|
self.assertNotIn("Private title", serialized)
|
||||||
|
self.assertNotIn("private.example", serialized)
|
||||||
|
|
||||||
|
async def test_media_status_reports_unavailable_without_exposing_exception(self) -> None:
|
||||||
|
client = SimpleNamespace(
|
||||||
|
configured=lambda: True,
|
||||||
|
get_system_info=AsyncMock(side_effect=RuntimeError("secret upstream failure")),
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(portal_router, "get_runtime_settings", return_value=SimpleNamespace(
|
||||||
|
jellyfin_base_url="http://jellyfin",
|
||||||
|
jellyfin_api_key="secret",
|
||||||
|
)),
|
||||||
|
patch.object(portal_router, "JellyfinClient", return_value=client),
|
||||||
|
):
|
||||||
|
result = await portal_router.portal_media_status()
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "down")
|
||||||
|
self.assertNotIn("secret upstream failure", str(result))
|
||||||
|
|
||||||
|
|
||||||
class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
|
class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
|
||||||
def test_legacy_request_status_maps_to_workflow(self) -> None:
|
def test_legacy_request_status_maps_to_workflow(self) -> None:
|
||||||
item = {"kind": "request", "status": "in_progress"}
|
item = {"kind": "request", "status": "in_progress"}
|
||||||
|
|||||||
@@ -2514,3 +2514,375 @@ button:disabled,
|
|||||||
.request-submit-progress p > small { grid-column: 2; }
|
.request-submit-progress p > small { grid-column: 2; }
|
||||||
.request-complete-actions { display: grid; }
|
.request-complete-actions { display: grid; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Guided issue reporting */
|
||||||
|
.issue-portal-page {
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-portal-page > .issue-portal-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 22px 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(126, 215, 255, 0.11), transparent 42%),
|
||||||
|
linear-gradient(135deg, rgba(90, 80, 240, 0.12), transparent 48%),
|
||||||
|
var(--ops-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-portal-hero > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
max-width: 790px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-portal-hero h1 {
|
||||||
|
font-size: clamp(1.65rem, 3vw, 2.45rem);
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-portal-hero .lede {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-hero-count {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 130px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-hero-count strong {
|
||||||
|
color: var(--ops-cyan);
|
||||||
|
font-family: "JetBrains Mono", Consolas, monospace;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-hero-count span {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-flow {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--ops-line);
|
||||||
|
border-radius: var(--ops-radius-lg);
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-flow-heading {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-flow-heading > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-flow-heading h2,
|
||||||
|
.issue-resolution-card h2,
|
||||||
|
.issue-history-heading h2,
|
||||||
|
.media-status-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-flow-heading p,
|
||||||
|
.issue-resolution-card p,
|
||||||
|
.media-status-check > p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ops-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-step-number {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid rgba(126, 215, 255, 0.34);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(14, 165, 233, 0.08);
|
||||||
|
color: var(--ops-cyan);
|
||||||
|
font-family: "JetBrains Mono", Consolas, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-category-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-category-card {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 190px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: var(--ops-radius-lg);
|
||||||
|
background: rgba(255, 255, 255, 0.022);
|
||||||
|
color: var(--ops-text);
|
||||||
|
text-align: left;
|
||||||
|
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-category-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(126, 215, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-category-card.is-selected {
|
||||||
|
border-color: rgba(72, 224, 178, 0.58);
|
||||||
|
background: linear-gradient(145deg, rgba(72, 224, 178, 0.09), rgba(14, 165, 233, 0.035));
|
||||||
|
box-shadow: 0 0 24px rgba(72, 224, 178, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-category-card strong {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-category-card p,
|
||||||
|
.issue-category-card small {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-category-card small {
|
||||||
|
align-self: end;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 9px;
|
||||||
|
border-top: 1px solid var(--ops-line-soft);
|
||||||
|
color: var(--request-green);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-category-marker {
|
||||||
|
justify-self: start;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: 1px solid rgba(126, 215, 255, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--ops-cyan);
|
||||||
|
font-family: "JetBrains Mono", Consolas, monospace;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-guided-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--ops-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-choice-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-choice-field legend,
|
||||||
|
.issue-question-grid label > span {
|
||||||
|
margin-bottom: 7px;
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-choice-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-choice-row button {
|
||||||
|
border-color: var(--ops-line-soft);
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
color: var(--ops-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-choice-row button.is-selected {
|
||||||
|
border-color: rgba(126, 215, 255, 0.48);
|
||||||
|
background: rgba(14, 165, 233, 0.12);
|
||||||
|
color: var(--ops-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-question-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-question-grid label {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-field-span-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-check {
|
||||||
|
display: grid;
|
||||||
|
gap: 13px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(126, 215, 255, 0.28);
|
||||||
|
border-radius: var(--ops-radius-lg);
|
||||||
|
background: rgba(14, 165, 233, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-check.media-status-up {
|
||||||
|
border-color: rgba(72, 224, 178, 0.34);
|
||||||
|
background: rgba(72, 224, 178, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-check.media-status-degraded {
|
||||||
|
border-color: rgba(255, 192, 84, 0.36);
|
||||||
|
background: rgba(255, 192, 84, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-check.media-status-down {
|
||||||
|
border-color: rgba(255, 86, 113, 0.38);
|
||||||
|
background: rgba(255, 86, 113, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-heading,
|
||||||
|
.issue-history-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-heading > div,
|
||||||
|
.issue-history-heading > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-live-scan {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-live-scan i {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ops-cyan);
|
||||||
|
box-shadow: 0 0 14px var(--ops-cyan);
|
||||||
|
animation: request-operation-pulse 1.15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-metrics > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(255, 255, 255, 0.022);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-metrics span {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-status-metrics strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-resolution-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(72, 224, 178, 0.3);
|
||||||
|
border-radius: var(--ops-radius-lg);
|
||||||
|
background: linear-gradient(110deg, rgba(72, 224, 178, 0.07), rgba(14, 165, 233, 0.035));
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-resolution-card > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-resolution-card > button {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-history-heading {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-history-heading > span {
|
||||||
|
padding: 5px 9px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-family: "JetBrains Mono", Consolas, monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-portal-page .portal-item-row p {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.media-status-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.issue-portal-page > .issue-portal-hero,
|
||||||
|
.media-status-heading,
|
||||||
|
.issue-history-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.issue-hero-count { width: 100%; }
|
||||||
|
.issue-flow { padding: 14px; }
|
||||||
|
.issue-category-grid,
|
||||||
|
.issue-question-grid,
|
||||||
|
.media-status-metrics { grid-template-columns: 1fr; }
|
||||||
|
.issue-field-span-2 { grid-column: span 1; }
|
||||||
|
.issue-category-card { min-height: 0; }
|
||||||
|
.issue-resolution-card { grid-template-columns: auto minmax(0, 1fr); }
|
||||||
|
.issue-resolution-card > button { grid-column: 1 / -1; width: 100%; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,119 @@ type DiscoveryResult = {
|
|||||||
backdropPath?: string | null
|
backdropPath?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IssueCategoryId =
|
||||||
|
| 'broken_media'
|
||||||
|
| 'missing_content'
|
||||||
|
| 'audio'
|
||||||
|
| 'subtitle'
|
||||||
|
| 'playback'
|
||||||
|
| 'service_unavailable'
|
||||||
|
|
||||||
|
type MediaServerStatus = {
|
||||||
|
checked_at?: string
|
||||||
|
status: 'up' | 'degraded' | 'down' | 'not_configured'
|
||||||
|
headline: string
|
||||||
|
message: string
|
||||||
|
latency_ms?: number | null
|
||||||
|
server?: {
|
||||||
|
version?: string | null
|
||||||
|
restart_pending?: boolean | null
|
||||||
|
}
|
||||||
|
activity?: {
|
||||||
|
active_streams?: number | null
|
||||||
|
transcoding_streams?: number | null
|
||||||
|
available?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ISSUE_CATEGORIES: Array<{
|
||||||
|
id: IssueCategoryId
|
||||||
|
marker: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
outcome: string
|
||||||
|
issueType: string
|
||||||
|
titlePrefix: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'broken_media',
|
||||||
|
marker: 'REPLACE',
|
||||||
|
label: 'Picture or file is broken',
|
||||||
|
description: 'Corruption, visual artefacts, freezing, or playback stopping at the same point.',
|
||||||
|
outcome: 'Likely action: replace the affected media file.',
|
||||||
|
issueType: 'broken_media',
|
||||||
|
titlePrefix: 'Replace media',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'missing_content',
|
||||||
|
marker: 'MISSING',
|
||||||
|
label: 'Movie or episode is missing',
|
||||||
|
description: 'A title, season, episode, or expected part is not available in Grizzlyflix.',
|
||||||
|
outcome: 'Likely action: check the request pipeline, then collect the missing media.',
|
||||||
|
issueType: 'missing_content',
|
||||||
|
titlePrefix: 'Missing content',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audio',
|
||||||
|
marker: 'AUDIO',
|
||||||
|
label: 'Audio is wrong',
|
||||||
|
description: 'No sound, wrong language, commentary only, distorted audio, or audio out of sync.',
|
||||||
|
outcome: 'Likely action: replace the file or correct its audio tracks.',
|
||||||
|
issueType: 'audio',
|
||||||
|
titlePrefix: 'Audio problem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subtitle',
|
||||||
|
marker: 'SUBS',
|
||||||
|
label: 'Subtitles are wrong',
|
||||||
|
description: 'Missing, incorrect, forced, unreadable, or out-of-sync subtitles.',
|
||||||
|
outcome: 'Likely action: repair the subtitle track or replace the media.',
|
||||||
|
issueType: 'subtitle',
|
||||||
|
titlePrefix: 'Subtitle problem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'playback',
|
||||||
|
marker: 'PLAYBACK',
|
||||||
|
label: 'Playback or transcoding problem',
|
||||||
|
description: 'The title will not start, constantly buffers, stops, or reports a transcode error.',
|
||||||
|
outcome: 'Magent will check Jellyfin before deciding whether this is file-, device-, or server-related.',
|
||||||
|
issueType: 'playback',
|
||||||
|
titlePrefix: 'Playback problem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'service_unavailable',
|
||||||
|
marker: 'SERVER',
|
||||||
|
label: 'Nothing will play',
|
||||||
|
description: 'Grizzlyflix will not open or every title fails across the device or household.',
|
||||||
|
outcome: 'Magent will check the media server and include the result with the report.',
|
||||||
|
issueType: 'service_unavailable',
|
||||||
|
titlePrefix: 'Media server unavailable',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
|
||||||
|
broken_media: ['Visual artefacts or corruption', 'Freezes at the same point', 'Stops before the end', 'File will not play'],
|
||||||
|
missing_content: ['Entire title is missing', 'Season is missing', 'Episode is missing', 'Part or edition is missing'],
|
||||||
|
audio: ['No audio', 'Wrong language', 'Commentary track only', 'Audio is out of sync', 'Audio is distorted'],
|
||||||
|
subtitle: ['Subtitles are missing', 'Wrong subtitles', 'Subtitles are out of sync', 'Forced subtitles are missing'],
|
||||||
|
playback: ['Will not start', 'Constant buffering', 'Transcode error', 'Stops during playback', 'Only fails on one device'],
|
||||||
|
service_unavailable: ['Grizzlyflix will not open', 'Every title fails', 'Login works but playback does not', 'Server error is shown'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ISSUE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
general: 'General',
|
||||||
|
playback: 'Playback',
|
||||||
|
transcode: 'Transcoding',
|
||||||
|
service_unavailable: 'Server unavailable',
|
||||||
|
broken_media: 'Broken media',
|
||||||
|
missing_content: 'Missing content',
|
||||||
|
audio: 'Audio',
|
||||||
|
subtitle: 'Subtitles',
|
||||||
|
quality: 'Quality',
|
||||||
|
metadata: 'Metadata',
|
||||||
|
other: 'Other',
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'new', label: 'New' },
|
{ value: 'new', label: 'New' },
|
||||||
{ value: 'triaging', label: 'Triaging' },
|
{ value: 'triaging', label: 'Triaging' },
|
||||||
@@ -200,6 +313,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const [editYear, setEditYear] = useState('')
|
const [editYear, setEditYear] = useState('')
|
||||||
const [editExternalRef, setEditExternalRef] = useState('')
|
const [editExternalRef, setEditExternalRef] = useState('')
|
||||||
const [editStatus, setEditStatus] = useState('new')
|
const [editStatus, setEditStatus] = useState('new')
|
||||||
|
const [editIssueType, setEditIssueType] = useState('general')
|
||||||
const [editRequestStatus, setEditRequestStatus] = useState('pending')
|
const [editRequestStatus, setEditRequestStatus] = useState('pending')
|
||||||
const [editMediaStatus, setEditMediaStatus] = useState('pending')
|
const [editMediaStatus, setEditMediaStatus] = useState('pending')
|
||||||
const [editPriority, setEditPriority] = useState('normal')
|
const [editPriority, setEditPriority] = useState('normal')
|
||||||
@@ -213,11 +327,28 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const [discoverResults, setDiscoverResults] = useState<DiscoveryResult[]>([])
|
const [discoverResults, setDiscoverResults] = useState<DiscoveryResult[]>([])
|
||||||
const [discoverError, setDiscoverError] = useState<string | null>(null)
|
const [discoverError, setDiscoverError] = useState<string | null>(null)
|
||||||
const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({})
|
const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({})
|
||||||
|
const [issueCategory, setIssueCategory] = useState<IssueCategoryId | null>(null)
|
||||||
|
const [issueMediaTitle, setIssueMediaTitle] = useState('')
|
||||||
|
const [issueMediaType, setIssueMediaType] = useState<'movie' | 'tv'>('movie')
|
||||||
|
const [issueEpisode, setIssueEpisode] = useState('')
|
||||||
|
const [issueScope, setIssueScope] = useState<'one_title' | 'multiple_titles' | 'everything'>('one_title')
|
||||||
|
const [issueSymptom, setIssueSymptom] = useState('')
|
||||||
|
const [issueDevice, setIssueDevice] = useState('')
|
||||||
|
const [issueNotes, setIssueNotes] = useState('')
|
||||||
|
const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null)
|
||||||
|
const [mediaServerChecking, setMediaServerChecking] = useState(false)
|
||||||
|
const [mediaServerError, setMediaServerError] = useState<string | null>(null)
|
||||||
|
|
||||||
const isAdmin = me?.role === 'admin'
|
const isAdmin = me?.role === 'admin'
|
||||||
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
|
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
|
||||||
const workspaceLabel = workspace === 'request' ? 'request' : 'issue'
|
const workspaceLabel = workspace === 'request' ? 'request' : 'issue'
|
||||||
const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues'
|
const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues'
|
||||||
|
const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null
|
||||||
|
const issueNeedsServerCheck = issueCategory === 'playback' || issueCategory === 'service_unavailable'
|
||||||
|
const issueNeedsMediaTitle =
|
||||||
|
Boolean(issueCategory) &&
|
||||||
|
issueCategory !== 'service_unavailable' &&
|
||||||
|
!(issueCategory === 'playback' && issueScope === 'everything')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -463,6 +594,158 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkMediaServer = async () => {
|
||||||
|
setMediaServerChecking(true)
|
||||||
|
setMediaServerError(null)
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${getApiBase()}/portal/issues/media-status`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('The live media-server check is temporarily unavailable.')
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as MediaServerStatus
|
||||||
|
setMediaServerStatus(payload)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setMediaServerStatus(null)
|
||||||
|
setMediaServerError(
|
||||||
|
err instanceof Error ? err.message : 'The live media-server check is temporarily unavailable.'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setMediaServerChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chooseIssueCategory = (category: IssueCategoryId) => {
|
||||||
|
setIssueCategory(category)
|
||||||
|
setIssueSymptom(ISSUE_SYMPTOMS[category][0] ?? '')
|
||||||
|
setIssueScope(category === 'service_unavailable' ? 'everything' : 'one_title')
|
||||||
|
setMediaServerStatus(null)
|
||||||
|
setMediaServerError(null)
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
if (category === 'playback' || category === 'service_unavailable') {
|
||||||
|
void checkMediaServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createGuidedIssue = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!selectedIssueDefinition || !issueCategory) {
|
||||||
|
setError('Choose the problem that best matches what you are seeing.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cleanMediaTitle = issueMediaTitle.trim()
|
||||||
|
if (issueNeedsMediaTitle && !cleanMediaTitle) {
|
||||||
|
setError('Enter the affected movie or TV show so the file can be identified.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true)
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
try {
|
||||||
|
const scopeLabel =
|
||||||
|
issueScope === 'everything'
|
||||||
|
? 'Everything / service-wide'
|
||||||
|
: issueScope === 'multiple_titles'
|
||||||
|
? 'Multiple titles'
|
||||||
|
: 'One title'
|
||||||
|
const diagnosticLines: string[] = []
|
||||||
|
if (mediaServerStatus) {
|
||||||
|
diagnosticLines.push(
|
||||||
|
`Media server check: ${mediaServerStatus.headline}`,
|
||||||
|
`Checked: ${formatDate(mediaServerStatus.checked_at)}`,
|
||||||
|
)
|
||||||
|
if (typeof mediaServerStatus.latency_ms === 'number') {
|
||||||
|
diagnosticLines.push(`Response time: ${mediaServerStatus.latency_ms} ms`)
|
||||||
|
}
|
||||||
|
if (mediaServerStatus.server?.restart_pending) {
|
||||||
|
diagnosticLines.push('Server restart pending: yes')
|
||||||
|
}
|
||||||
|
if (mediaServerStatus.activity?.available) {
|
||||||
|
diagnosticLines.push(
|
||||||
|
`Active streams: ${mediaServerStatus.activity.active_streams ?? 0}`,
|
||||||
|
`Active transcodes: ${mediaServerStatus.activity.transcoding_streams ?? 0}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (issueNeedsServerCheck) {
|
||||||
|
diagnosticLines.push('Media server check: unavailable at the time of reporting')
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = [
|
||||||
|
`Problem: ${selectedIssueDefinition.label}`,
|
||||||
|
`Symptom: ${issueSymptom}`,
|
||||||
|
`Scope: ${scopeLabel}`,
|
||||||
|
cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null,
|
||||||
|
cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null,
|
||||||
|
issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null,
|
||||||
|
issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null,
|
||||||
|
...diagnosticLines,
|
||||||
|
issueNotes.trim() ? `Additional information: ${issueNotes.trim()}` : null,
|
||||||
|
]
|
||||||
|
.filter((line): line is string => Boolean(line))
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const titleTarget = cleanMediaTitle || (issueScope === 'everything' ? 'all playback' : 'multiple titles')
|
||||||
|
const resolvedIssueType =
|
||||||
|
issueCategory === 'playback' && issueSymptom.toLowerCase().includes('transcode')
|
||||||
|
? 'transcode'
|
||||||
|
: selectedIssueDefinition.issueType
|
||||||
|
const priority =
|
||||||
|
mediaServerStatus?.status === 'down' || issueCategory === 'service_unavailable'
|
||||||
|
? 'high'
|
||||||
|
: 'normal'
|
||||||
|
const response = await authFetch(`${getApiBase()}/portal/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
kind: 'issue',
|
||||||
|
title: `${selectedIssueDefinition.titlePrefix}: ${titleTarget}`,
|
||||||
|
description,
|
||||||
|
issue_type: resolvedIssueType,
|
||||||
|
media_type: cleanMediaTitle ? issueMediaType : null,
|
||||||
|
priority,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Could not submit the issue.')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const item = data?.item as PortalItem | undefined
|
||||||
|
setStatus(
|
||||||
|
item?.id
|
||||||
|
? `Issue #${item.id} submitted with the troubleshooting details.`
|
||||||
|
: 'Issue submitted with the troubleshooting details.'
|
||||||
|
)
|
||||||
|
setIssueCategory(null)
|
||||||
|
setIssueMediaTitle('')
|
||||||
|
setIssueMediaType('movie')
|
||||||
|
setIssueEpisode('')
|
||||||
|
setIssueScope('one_title')
|
||||||
|
setIssueSymptom('')
|
||||||
|
setIssueDevice('')
|
||||||
|
setIssueNotes('')
|
||||||
|
setMediaServerStatus(null)
|
||||||
|
await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not submit the issue.')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -514,6 +797,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
setEditYear(selectedItem.year == null ? '' : String(selectedItem.year))
|
setEditYear(selectedItem.year == null ? '' : String(selectedItem.year))
|
||||||
setEditExternalRef(selectedItem.external_ref ?? '')
|
setEditExternalRef(selectedItem.external_ref ?? '')
|
||||||
setEditStatus(selectedItem.status ?? 'new')
|
setEditStatus(selectedItem.status ?? 'new')
|
||||||
|
setEditIssueType(selectedItem.issue?.issue_type ?? 'general')
|
||||||
setEditRequestStatus(selectedItem.workflow?.request_status ?? 'pending')
|
setEditRequestStatus(selectedItem.workflow?.request_status ?? 'pending')
|
||||||
setEditMediaStatus(selectedItem.workflow?.media_status ?? 'pending')
|
setEditMediaStatus(selectedItem.workflow?.media_status ?? 'pending')
|
||||||
setEditPriority(selectedItem.priority ?? 'normal')
|
setEditPriority(selectedItem.priority ?? 'normal')
|
||||||
@@ -591,6 +875,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
payload.media_status = editMediaStatus
|
payload.media_status = editMediaStatus
|
||||||
} else {
|
} else {
|
||||||
payload.status = editStatus
|
payload.status = editStatus
|
||||||
|
payload.issue_type = editIssueType
|
||||||
}
|
}
|
||||||
payload.priority = editPriority
|
payload.priority = editPriority
|
||||||
payload.assignee_username = editAssignee || null
|
payload.assignee_username = editAssignee || null
|
||||||
@@ -672,40 +957,39 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loadingItems && !items.length) {
|
if (loadingItems && !items.length) {
|
||||||
return <main className="card">Loading request portal...</main>
|
return <main className="card">Loading {workspace === 'issue' ? 'issues' : 'requests'}...</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card portal-page">
|
<main className={`card portal-page ${workspace === 'issue' ? 'issue-portal-page' : ''}`}>
|
||||||
<div className="user-directory-panel-header">
|
<div className={`user-directory-panel-header ${workspace === 'issue' ? 'issue-portal-hero' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<h1>{workspace === 'request' ? 'Request portal' : 'Issue portal'}</h1>
|
{workspace === 'issue' ? <span className="section-kicker">Guided support</span> : null}
|
||||||
|
<h1>{workspace === 'request' ? 'Request portal' : 'What is going wrong?'}</h1>
|
||||||
<p className="lede">
|
<p className="lede">
|
||||||
{workspace === 'request'
|
{workspace === 'request'
|
||||||
? 'Search and track content requests through the delivery pipeline.'
|
? 'Search and track content requests through the delivery pipeline.'
|
||||||
: 'Raise operational issues and manage resolution updates.'}
|
: 'Choose the symptom and Magent will collect the right details, check the media server when relevant, and recommend the next action.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{workspace === 'issue' ? (
|
||||||
|
<div className="issue-hero-count">
|
||||||
|
<strong>{visibleKindCount}</strong>
|
||||||
|
<span>reported issues</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{workspace === 'request' ? (
|
||||||
<section className="portal-workspace-switch">
|
<section className="portal-workspace-switch">
|
||||||
<button
|
<button type="button" className="is-active" disabled>
|
||||||
type="button"
|
|
||||||
className={workspace === 'request' ? 'is-active' : ''}
|
|
||||||
onClick={() => router.push('/new-requests')}
|
|
||||||
disabled={workspace === 'request'}
|
|
||||||
>
|
|
||||||
New requests
|
New requests
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" onClick={() => router.push('/portal/issues')}>
|
||||||
type="button"
|
|
||||||
className={workspace === 'issue' ? 'is-active' : ''}
|
|
||||||
onClick={() => router.push('/portal/issues')}
|
|
||||||
disabled={workspace === 'issue'}
|
|
||||||
>
|
|
||||||
Issues
|
Issues
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
{status && <div className="status-banner">{status}</div>}
|
{status && <div className="status-banner">{status}</div>}
|
||||||
@@ -790,13 +1074,180 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<section className="admin-panel">
|
<section className="issue-flow">
|
||||||
<div className="status-banner">
|
<div className="issue-flow-heading">
|
||||||
Issue workspace is for reporting problems and tracking resolution separately from content requests.
|
<span className="issue-step-number">01</span>
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Choose a symptom</span>
|
||||||
|
<h2>Which best describes the problem?</h2>
|
||||||
|
<p>Only the questions needed for that problem will appear next.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="issue-category-grid">
|
||||||
|
{ISSUE_CATEGORIES.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
className={`issue-category-card ${issueCategory === category.id ? 'is-selected' : ''}`}
|
||||||
|
onClick={() => chooseIssueCategory(category.id)}
|
||||||
|
>
|
||||||
|
<span className="issue-category-marker">{category.marker}</span>
|
||||||
|
<strong>{category.label}</strong>
|
||||||
|
<p>{category.description}</p>
|
||||||
|
<small>{category.outcome}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedIssueDefinition && issueCategory ? (
|
||||||
|
<form className="issue-guided-form" onSubmit={createGuidedIssue}>
|
||||||
|
<div className="issue-flow-heading">
|
||||||
|
<span className="issue-step-number">02</span>
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Narrow it down</span>
|
||||||
|
<h2>Tell us what is affected</h2>
|
||||||
|
<p>Magent will attach these details to the issue so nobody has to ask for them again.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issueCategory === 'playback' ? (
|
||||||
|
<fieldset className="issue-choice-field">
|
||||||
|
<legend>How widespread is it?</legend>
|
||||||
|
<div className="issue-choice-row">
|
||||||
|
{[
|
||||||
|
['one_title', 'One title'],
|
||||||
|
['multiple_titles', 'Several titles'],
|
||||||
|
['everything', 'Nothing will play'],
|
||||||
|
].map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={issueScope === value ? 'is-selected' : ''}
|
||||||
|
onClick={() => setIssueScope(value as typeof issueScope)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="issue-question-grid">
|
||||||
|
{issueNeedsMediaTitle ? (
|
||||||
|
<label className="issue-field-span-2">
|
||||||
|
<span>Affected movie or TV show</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={issueMediaTitle}
|
||||||
|
onChange={(event) => setIssueMediaTitle(event.target.value)}
|
||||||
|
placeholder="Start typing the exact title"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{issueNeedsMediaTitle ? (
|
||||||
|
<label>
|
||||||
|
<span>Media type</span>
|
||||||
|
<select
|
||||||
|
value={issueMediaType}
|
||||||
|
onChange={(event) => setIssueMediaType(event.target.value as 'movie' | 'tv')}
|
||||||
|
>
|
||||||
|
<option value="movie">Movie</option>
|
||||||
|
<option value="tv">TV show</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{issueNeedsMediaTitle && issueMediaType === 'tv' ? (
|
||||||
|
<label>
|
||||||
|
<span>Season / episode</span>
|
||||||
|
<input
|
||||||
|
value={issueEpisode}
|
||||||
|
onChange={(event) => setIssueEpisode(event.target.value)}
|
||||||
|
placeholder="For example S02 E04"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className={issueNeedsMediaTitle && issueMediaType === 'tv' ? 'issue-field-span-2' : ''}>
|
||||||
|
<span>What happens?</span>
|
||||||
|
<select value={issueSymptom} onChange={(event) => setIssueSymptom(event.target.value)}>
|
||||||
|
{ISSUE_SYMPTOMS[issueCategory].map((symptom) => (
|
||||||
|
<option key={symptom} value={symptom}>{symptom}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{(issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
|
||||||
|
<label>
|
||||||
|
<span>Device or app</span>
|
||||||
|
<input
|
||||||
|
value={issueDevice}
|
||||||
|
onChange={(event) => setIssueDevice(event.target.value)}
|
||||||
|
placeholder="For example Samsung TV or Chrome"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="issue-field-span-2">
|
||||||
|
<span>Anything else we should know?</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={issueNotes}
|
||||||
|
onChange={(event) => setIssueNotes(event.target.value)}
|
||||||
|
placeholder="Optional error message, timestamp, language, edition, or anything unusual"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issueNeedsServerCheck ? (
|
||||||
|
<section className={`media-status-check media-status-${mediaServerStatus?.status ?? 'checking'}`}>
|
||||||
|
<div className="media-status-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Live media-server check</span>
|
||||||
|
<h3>
|
||||||
|
{mediaServerChecking
|
||||||
|
? 'Checking Jellyfin now...'
|
||||||
|
: mediaServerStatus?.headline ?? 'Server check unavailable'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ghost-button" onClick={() => void checkMediaServer()} disabled={mediaServerChecking}>
|
||||||
|
{mediaServerChecking ? 'Checking...' : 'Check again'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mediaServerChecking ? <div className="issue-live-scan"><i /><span>Contacting the media server</span></div> : null}
|
||||||
|
{mediaServerError ? <div className="error-banner">{mediaServerError}</div> : null}
|
||||||
|
{mediaServerStatus ? (
|
||||||
|
<>
|
||||||
|
<p>{mediaServerStatus.message}</p>
|
||||||
|
<div className="media-status-metrics">
|
||||||
|
<div><span>Server API</span><strong>{mediaServerStatus.status === 'down' ? 'Unavailable' : 'Responding'}</strong></div>
|
||||||
|
<div><span>Response</span><strong>{mediaServerStatus.latency_ms ?? '--'} ms</strong></div>
|
||||||
|
<div><span>Active streams</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.active_streams ?? 0 : '--'}</strong></div>
|
||||||
|
<div><span>Transcoding</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.transcoding_streams ?? 0 : '--'}</strong></div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="issue-resolution-card">
|
||||||
|
<span className="issue-step-number">03</span>
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Recommended path</span>
|
||||||
|
<h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2>
|
||||||
|
<p>
|
||||||
|
{issueNeedsServerCheck
|
||||||
|
? 'The live check above will be saved in the report, giving administrators immediate context.'
|
||||||
|
: 'Submit this report and it will arrive with the replacement or collection path already identified.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={creating || mediaServerChecking}>
|
||||||
|
{creating ? 'Submitting...' : 'Submit issue'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{workspace === 'request' ? (
|
||||||
|
<>
|
||||||
<section className="portal-overview-grid">
|
<section className="portal-overview-grid">
|
||||||
<div className="portal-overview-card">
|
<div className="portal-overview-card">
|
||||||
<span>Total {workspace === 'request' ? 'requests' : 'issues'}</span>
|
<span>Total {workspace === 'request' ? 'requests' : 'issues'}</span>
|
||||||
@@ -908,6 +1359,18 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{workspace === 'issue' ? (
|
||||||
|
<div className="issue-history-heading">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Issue history</span>
|
||||||
|
<h2>Reported problems</h2>
|
||||||
|
</div>
|
||||||
|
<span>{totalItems} total</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="portal-toolbar">
|
<section className="portal-toolbar">
|
||||||
<label>
|
<label>
|
||||||
@@ -970,7 +1433,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
<div className="portal-item-row-main">
|
<div className="portal-item-row-main">
|
||||||
<div className="portal-item-row-title">
|
<div className="portal-item-row-title">
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
<span className="small-pill">{item.kind}</span>
|
<span className="small-pill">
|
||||||
|
{item.kind === 'issue'
|
||||||
|
? ISSUE_TYPE_LABELS[item.issue?.issue_type ?? 'general'] ?? 'Issue'
|
||||||
|
: item.kind}
|
||||||
|
</span>
|
||||||
<span className="small-pill is-muted">{item.priority}</span>
|
<span className="small-pill is-muted">{item.priority}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{item.description}</p>
|
<p>{item.description}</p>
|
||||||
@@ -1013,6 +1480,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
<p className="lede">
|
<p className="lede">
|
||||||
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
|
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
|
||||||
</p>
|
</p>
|
||||||
|
{selectedItem.kind === 'issue' ? (
|
||||||
|
<p className="lede">
|
||||||
|
Category:{' '}
|
||||||
|
<strong>
|
||||||
|
{ISSUE_TYPE_LABELS[selectedItem.issue?.issue_type ?? 'general'] ?? 'General'}
|
||||||
|
</strong>
|
||||||
|
{selectedItem.issue?.is_resolved ? ' · Resolved' : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{selectedItem.kind === 'request' && (
|
{selectedItem.kind === 'request' && (
|
||||||
<p className="lede">
|
<p className="lede">
|
||||||
Pipeline:{' '}
|
Pipeline:{' '}
|
||||||
@@ -1111,6 +1587,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<label>
|
<label>
|
||||||
<span>Status</span>
|
<span>Status</span>
|
||||||
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
|
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
|
||||||
@@ -1121,6 +1598,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Issue category</span>
|
||||||
|
<select value={editIssueType} onChange={(event) => setEditIssueType(event.target.value)}>
|
||||||
|
{Object.entries(ISSUE_TYPE_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<label>
|
<label>
|
||||||
<span>Priority</span>
|
<span>Priority</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user