Compare commits

..
2 Commits
Author SHA1 Message Date
Assclaw a32928b1c5 Make verified repair acceptance prominent and simplify confirmation email
Magent CI/CD / verify (push) Canceled after 5m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s
2026-09-06 22:32:21 +12:00
Assclaw 74c49fad5b Compact request activity into a live summary and modal history 2026-09-06 22:20:33 +12:00
18 changed files with 477 additions and 114 deletions
+9 -9
View File
@@ -117,7 +117,7 @@ def _operation_result_message(
if "/queue" in normalized_path and normalized_method == "GET":
return _queue_result_message(service, result)
if "/command" in normalized_path and normalized_method == "POST":
return f"{service} accepted the {_command_name(payload)} and queued it for processing."
return f"{service} accepted the {_command_name(payload)} and put it in line to run. This does not mean a download has started."
if "/release" in normalized_path:
if normalized_method == "GET":
count = len(_result_items(result, "records", "items"))
@@ -129,10 +129,10 @@ def _operation_result_message(
return f"{service} accepted the selected release and sent it to the download client."
if "/qualityprofile" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'available quality profile')}."
return f"{service} returned {_count_message(count, 'download quality setting')}."
if "/rootfolder" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} returned {_count_message(count, 'configured library location')}."
return f"{service} returned {_count_message(count, 'library folder')}."
if "/indexer" in normalized_path and normalized_method == "GET":
count = len(_result_items(result))
return f"{service} reports {_count_message(count, 'configured search source')}."
@@ -174,7 +174,7 @@ def _operation_result_message(
if "/health" in normalized_path:
issues = _result_items(result)
if not issues:
return "Prowlarr reports that all configured indexers are healthy."
return "The download search sources are working normally."
first = next((item for item in issues if isinstance(item, dict)), {})
detail = str(first.get("message") or first.get("source") or "").strip()
suffix = f" First issue: {detail}" if detail else ""
@@ -182,9 +182,9 @@ def _operation_result_message(
if "/search" in normalized_path:
results = _result_items(result, "results", "records")
return (
f"Prowlarr found {_count_message(len(results), 'possible release')}."
f"Prowlarr found {_count_message(len(results), 'possible download')}."
if results
else "Prowlarr did not find any possible releases."
else "Prowlarr did not find any possible downloads."
)
if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path:
@@ -193,9 +193,9 @@ def _operation_result_message(
return f"Bazarr accepted a fresh {language} subtitle search for the {target}."
if normalized_method == "GET":
return f"{service} completed the check successfully."
return f"{service} finished this check without reporting a problem."
if normalized_method == "POST":
return f"{service} accepted the request and started processing it."
return f"{service} received the request. Its result will be checked separately."
if normalized_method == "PUT":
return f"{service} saved the requested changes."
if normalized_method == "DELETE":
@@ -247,7 +247,7 @@ def _operation_messages(service: str, method: str, path: str) -> tuple[str, str]
if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH":
return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search"
if service == "Prowlarr" and "/health" in normalized_path:
return "Checking Prowlarr indexer health", "Prowlarr returned its indexer health"
return "Checking whether the download search sources are working", "Prowlarr returned its indexer health"
return f"Contacting {service}", f"{service} responded"
+2 -2
View File
@@ -15,9 +15,9 @@ def _availability_message(result: Any) -> str:
or (isinstance(items, list) and len(items) > 0)
)
return (
"The title is available to watch in Jellyfin."
"Grizzlyflix returned possible matches. Magent still needs to check the exact title and file."
if available
else "The title is not currently available in Jellyfin."
else "Grizzlyflix did not find this title in its library search."
)
+15 -9
View File
@@ -8,20 +8,26 @@ from ..services.operation_progress import finish_remote_call, start_remote_call
def _torrent_state_text(state: Any) -> str:
normalized = str(state or "").strip().lower()
if "pause" in normalized:
if normalized in {"uploading", "stalledup", "forcedup", "queuedup", "pausedup", "stoppedup", "completed"}:
return "finished"
if "pause" in normalized or normalized == "stoppeddl":
return "paused"
if "stall" in normalized:
return "stalled"
return "waiting for data"
if normalized.startswith("queued"):
return "waiting in the queue"
if "downloading" in normalized or normalized in {"forcedl", "metadl", "checkingdl"}:
if normalized == "metadl":
return "getting the download details"
if normalized in {"checkingdl", "checkingup", "checkingresumedata"}:
return "checking the downloaded files"
if "downloading" in normalized or normalized in {"forcedl", "forceddl"}:
return "downloading"
if "upload" in normalized or normalized in {"stalledup", "forcedup"}:
return "finished and seeding"
if "upload" in normalized:
return "downloaded and sharing with others"
if normalized in {"completed", "missingfiles"}:
return "finished" if normalized == "completed" else "missing files"
if "error" in normalized:
return "in an error state"
return "unable to continue"
return "present"
@@ -31,14 +37,14 @@ def _torrent_result_message(result: Any) -> str:
return "qBittorrent found no matching downloads."
first = next((item for item in torrents if isinstance(item, dict)), {})
if len(torrents) == 1:
name = str(first.get("name") or "the matching download").strip()
progress = first.get("progress")
progress_text = (
f" and {max(0, min(100, round(progress * 100)))}% complete"
f" {max(0, min(100, round(progress * 100)))}% complete"
if isinstance(progress, (int, float))
else ""
)
return f'qBittorrent found "{name}"; it is {_torrent_state_text(first.get("state"))}{progress_text}.'
state_text = _torrent_state_text(first.get("state"))
return f'{"Downloading" if state_text == "downloading" else "The download is " + state_text}{progress_text}.'
active = sum(
1
for item in torrents
+26 -15
View File
@@ -129,6 +129,10 @@ def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
activity = list_portal_item_activity(item_id, limit=500)
for entry in reversed(activity):
# A rejected repair must not be proposed again simply because the same
# replacement file is still present. Wait for a NEW repair attempt.
if str(entry.get("event_type") or "") == "resolution_rejected":
return {}, activity
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
continue
tracking = _activity_metadata(entry).get("repairTracking")
@@ -205,27 +209,34 @@ async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
attempt_number = attempts + 1
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
recipient = resolve_user_delivery_email(reporter)
issue_url = _issue_url(int(item["id"]))
issue_url = f"{_app_url()}/issues/confirm/{int(item['id'])}"
sent = False
delivery_error: Optional[str] = None
if recipient:
subject = f"Is your issue fixed? #{item['id']} {item.get('title') or ''}".strip()
subject = f"Ready to try again? Grizzlyflix issue #{item['id']}"
body_text = (
f"We have marked issue #{item['id']} as fixed and need your confirmation.\n\n"
f"Issue: {item.get('title') or 'Untitled issue'}\n"
f"Confirmation request: {attempt_number} of {maximum}\n\n"
f"Open the issue and choose whether it is fixed or still happening:\n{issue_url}\n\n"
"If you do not respond, Magent will close the issue automatically after the configured confirmation period."
"Your repair looks ready to test.\n\n"
f"{item.get('title') or 'Your reported issue'}\n\n"
"Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
f"YES — it works: {issue_url}#yes\n"
f"NO — still broken: {issue_url}#no\n\n"
"Confirm your answer in Magent. You may need to sign in first.\n"
"Yes closes the report. No keeps it open for another look.\n\n"
f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
)
body_html = (
'<div style="font-family:Segoe UI,Arial,sans-serif;color:#132033;">'
'<h2 style="margin:0 0 12px;">Is your issue fixed?</h2>'
f'<p style="line-height:1.6;">We have marked issue <strong>#{int(item["id"])}</strong> as fixed and need your confirmation.</p>'
f'<p style="line-height:1.6;"><strong>{escape(str(item.get("title") or "Untitled issue"))}</strong><br>'
f'Confirmation request {attempt_number} of {maximum}</p>'
f'<a href="{escape(issue_url)}" style="display:inline-block;padding:11px 18px;border-radius:8px;background:#1c6bff;color:#fff;text-decoration:none;font-weight:700;">Confirm the outcome</a>'
'<p style="margin-top:18px;color:#64748b;line-height:1.6;">If you do not respond, Magent will close the issue automatically after the configured confirmation period.</p>'
'</div>'
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">GRIZZLYFLIX · MAGENT</p>'
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
'<h2 style="font-size:26px;color:#fff;margin:24px 0 16px;">Is it fixed?</h2>'
f'<a href="{escape(issue_url)}#yes" style="display:block;text-align:center;padding:20px;margin-bottom:12px;border-radius:12px;background:#b4f4d2;color:#10261b;text-decoration:none;font-size:24px;font-weight:bold;">YES — it works</a>'
f'<a href="{escape(issue_url)}#no" style="display:block;text-align:center;padding:20px;border-radius:12px;background:#ffc1c5;color:#391318;text-decoration:none;font-size:24px;font-weight:bold;">NO — still broken</a>'
'<p style="font-size:14px;line-height:1.6;color:#dedee3;">Confirm your answer in Magent. You may need to sign in first.<br>Yes closes the report. No keeps it open for another look.</p>'
f'<p style="font-size:12px;line-height:1.6;color:#b9b9c3;">Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])}<br>If we do not hear back after the reminder period, this report will close automatically.</p>'
'</td></tr></table></div>'
)
try:
await send_generic_email(
+3 -3
View File
@@ -70,7 +70,7 @@ def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> To
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete",
"message": "Magent received the action.",
"message": "Your action has been received. Magent is starting the checks.",
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
@@ -178,9 +178,9 @@ def finish_operation(operation_id: str, *, success: bool, status_code: Optional[
"service": "Magent",
"state": "complete" if success else "error",
"message": (
"Magent finished processing the action."
"This action has finished. Check the request status for what happens next."
if success
else "Magent could not complete the action."
else "This action could not be completed. Open the activity details to see which step needs attention."
),
"started_at": now_iso,
"finished_at": now_iso,
+13 -3
View File
@@ -240,7 +240,7 @@ class OperationMessageTests(unittest.TestCase):
)
self.assertEqual(queue_message, "Sonarr has no matching downloads in its queue.")
self.assertEqual(health_message, "Prowlarr reports that all configured indexers are healthy.")
self.assertEqual(health_message, "The download search sources are working normally.")
def test_download_and_jellyfin_results_include_actual_state(self) -> None:
torrent_message = _torrent_result_message(
@@ -249,11 +249,11 @@ class OperationMessageTests(unittest.TestCase):
self.assertEqual(
torrent_message,
'qBittorrent found "Arrival.2016"; it is downloading and 42% complete.',
'Downloading 42% complete.',
)
self.assertEqual(
_availability_message({"TotalRecordCount": 0, "Items": []}),
"The title is not currently available in Jellyfin.",
"Grizzlyflix did not find this title in its library search.",
)
def test_bazarr_subtitle_search_is_explained_in_plain_english(self) -> None:
@@ -270,6 +270,16 @@ class OperationMessageTests(unittest.TestCase):
"Bazarr accepted a fresh EN subtitle search for the selected episode.",
)
def test_library_search_does_not_claim_playable_media(self) -> None:
message = _availability_message({"TotalRecordCount": 1, "Items": [{"Name": "Example"}]})
self.assertIn("still needs to check the exact title and file", message)
self.assertNotIn("available to watch", message)
def test_finished_or_paused_download_is_not_described_as_stuck(self) -> None:
for state in ["stalledUP", "stoppedUP", "pausedUP"]:
self.assertIn("finished", _torrent_result_message([{"state": state, "progress": 1}]))
self.assertIn("paused", _torrent_result_message([{"state": "stoppedDL", "progress": .3}]))
def test_http_failures_are_translated_without_exposing_stack_traces(self) -> None:
self.assertEqual(
_operation_error_message("Radarr", 500),
+55
View File
@@ -0,0 +1,55 @@
import json
import unittest
from unittest.mock import AsyncMock, patch
from backend.app import db
from backend.app.services import issue_resolution as service
from backend.tests.test_backend_quality import TempDatabaseMixin
class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def issue(self):
item = db.create_portal_item(kind="issue", title="Broken <movie>", description="Repair",
created_by_username="reporter", created_by_id=None, status="in_progress", issue_type="broken_media")
self.start(item["id"])
return item
def start(self, item_id):
db.add_portal_item_activity(item_id, event_type="replacement_started", actor_username="reporter",
actor_role="user", message="New repair", metadata_json=json.dumps({"repairTracking": {"requestId": "12", "actionId": "replace_media"}}))
async def test_importing_or_unverified_media_does_not_email_reporter(self):
self.issue()
for phase in ["collecting", "indexing", "unavailable"]:
with patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": False, "phase": phase})), patch.object(service, "begin_issue_confirmation", new=AsyncMock()) as begin:
await service.process_active_media_repairs()
begin.assert_not_awaited()
async def test_verified_repair_emails_once_and_no_requires_a_new_repair(self):
item = self.issue()
with (
patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": True, "phase": "complete"})),
patch.object(service, "_workflow_settings", return_value=(3, 2, "days")),
patch.object(service, "get_user_by_username", return_value={"username": "reporter"}),
patch.object(service, "resolve_user_delivery_email", return_value="reporter@example.test"),
patch.object(service, "send_generic_email", new=AsyncMock()) as email,
):
await service.process_active_media_repairs()
await service.process_active_media_repairs()
self.assertEqual(email.await_count, 1)
self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
content = email.await_args.kwargs
self.assertIn("YES — it works", content["body_html"])
self.assertIn("NO — still broken", content["body_html"])
self.assertIn(f"/issues/confirm/{item['id']}#yes", content["body_html"])
self.assertIn("Broken &lt;movie&gt;", content["body_html"])
self.assertNotIn("<movie>", content["body_html"])
self.assertIn("Confirm your answer in Magent", content["body_text"])
service.respond_to_issue_confirmation(item["id"], resolved=False, actor_username="reporter", actor_role="user")
await service.process_active_media_repairs()
await service.process_due_issue_confirmations()
self.assertEqual(email.await_count, 1)
self.assertEqual(db.get_portal_item(item["id"])["status"], "in_progress")
self.start(item["id"])
await service.process_active_media_repairs()
self.assertEqual(email.await_count, 2)
+4
View File
@@ -8,6 +8,8 @@
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
- A media repair starts a new collection cycle: preserve Requested/Approved and unaffected TV episodes, but do not reuse an old torrent or Jellyfin entry to mark the replacement ready. Show Pending → Downloading → Indexing → Available from collector/file evidence. Subtitle-only repairs must not reset video availability.
- Request action feedback uses a compact Latest activity card beside download status; full event history opens in a native modal dialog without expanding the page. Keep messages outcome-based and do not equate a successful service response with playable media.
- Issue acceptance uses `ui/ResolutionChoice.tsx`: large YES/NO choices at the top of issue details and on `/issues/confirm/[id]`. Email links only open that page; answers require an authenticated POST. A NO must wait for a new repair before automatic acceptance is proposed again.
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
@@ -19,6 +21,8 @@ Build the frontend before reviewing. The scripts in `scripts/` run using Node an
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
- `review_repair_pipeline_ui.cjs`: fixture-only movie/TV replacement stages, old-cycle progress rejection, desktop/mobile layout and automatic availability transitions.
- `review_activity_ui.cjs`: fixture-only latest activity, desktop/tablet/mobile placement, modal history, keyboard dismissal and focus restoration.
- `review_acceptance_ui.cjs`: fixture-only acceptance choices, exact YES/NO submissions, email-link safety, permissions and sign-in return links.
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
+63
View File
@@ -0,0 +1,63 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { authFetch, getApiBase, clearToken } from '../../../lib/auth'
import ResolutionChoice from '../../../ui/ResolutionChoice'
type Issue = { id: number; kind: string; title: string; status: string; permissions?: { can_confirm_resolution?: boolean } }
export default function ConfirmIssuePage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [item, setItem] = useState<Issue | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState('')
const login = () => {
clearToken()
router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`)
}
useEffect(() => {
const controller = new AbortController()
setLoading(true); setItem(null); setError(''); setResult('')
const load = async () => {
try {
const response = await authFetch(`${getApiBase()}/portal/items/${id}`, { signal: controller.signal, cache: 'no-store' })
if (response.status === 401) { login(); return }
if (!response.ok) throw new Error('This issue is unavailable. Please sign in with the account that reported it.')
const data = await response.json()
if (data.item?.kind !== 'issue') throw new Error('This link does not belong to an issue.')
setItem(data.item)
} catch (err) { if (!controller.signal.aborted) setError(err instanceof Error ? err.message : 'Could not load this issue. Please try again.') }
finally { if (!controller.signal.aborted) setLoading(false) }
}
void load()
return () => controller.abort()
// The confirmation link identifies one issue. Never submit an answer on GET.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
const answer = async (resolved: boolean) => {
if (busy) return
setBusy(true); setError('')
try {
const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolved }),
})
if (response.status === 401) { login(); return }
if (!response.ok) throw new Error('Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.')
setResult(resolved ? 'Thanks! Your issue is now closed.' : 'Thanks for letting us know. Your issue stays open for another look.')
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save your answer. Please try again.') }
finally { setBusy(false) }
}
return <main className="resolution-response-page">
{error && <p role="alert" className="status-banner">{error}</p>}
{loading ? <p role="status">Loading your issue</p> : result ? <section className="resolution-choice" role="status"><h2>{result}</h2><a href="/portal/issues">Back to issues</a></section> : item ? (
item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution
? <ResolutionChoice title={item.title} busy={busy} onAnswer={(value) => void answer(value)} />
: <section className="resolution-choice"><h2>{item.status === 'awaiting_confirmation' ? 'This question is for the person who reported the issue.' : 'No answer is needed right now.'}</h2><p>{item.title}</p><a href={`/portal/issues?item=${item.id}`}>View issue</a></section>
) : null}
</main>
}
+2 -1
View File
@@ -70,7 +70,8 @@ export default function LoginPage() {
const data = await response.json()
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
setToken('cookie')
window.location.assign('/')
const next = new URLSearchParams(window.location.search).get('next') || ''
window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/')
} catch {
setError('Could not reach Magent. Check your connection and try again.')
} finally { setLoading(false) }
+11 -27
View File
@@ -1,4 +1,5 @@
'use client'
import ResolutionChoice from '../ui/ResolutionChoice'
import PageHeading from '../ui/PageHeading'
import IssueFlowStep from './IssueFlowStep'
@@ -1501,6 +1502,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
actions={workspace === 'issue' ? <span className="page-heading-meta">{visibleKindCount} reported {visibleKindCount === 1 ? 'issue' : 'issues'}</span> : undefined}
/>
{workspace === 'issue' && items.filter((item) => item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution && item.created_by_username === me?.username).map((item) => (
<section key={item.id} className="resolution-choice">
<h2>Is it fixed?</h2><p>{item.title}</p>
<a className="button" href={`/issues/confirm/${item.id}`}>Answer YES or NO </a>
</section>
))}
{workspace === 'request' ? (
<section className="portal-workspace-switch">
<button type="button" className="is-active" disabled>
@@ -2225,6 +2233,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
) : (
<>
{selectedItem.kind === 'issue' && selectedItem.status === 'awaiting_confirmation' && selectedItem.permissions?.can_confirm_resolution && (
<ResolutionChoice title={selectedItem.title} busy={respondingResolution} onAnswer={(value) => void respondToResolution(value)} />
)}
<div className="user-directory-panel-header">
<div>
<h2>
@@ -2294,33 +2305,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
) : null}
{selectedItem.kind === 'issue' && selectedItem.status === 'awaiting_confirmation' ? (
<section className="issue-confirmation-card" aria-live="polite">
<div>
<span className="section-kicker">Resolution check</span>
<h3>Has this issue been fixed?</h3>
<p>
Magent is waiting for the reporter to confirm the result.
{(selectedItem.issue?.confirmation?.maximum_attempts ?? 0) > 0
? ` ${selectedItem.issue?.confirmation?.attempts_sent ?? 0} of ${selectedItem.issue?.confirmation?.maximum_attempts ?? 0} confirmation emails have been attempted.`
: ' Confirmation emails are disabled, so this issue will close automatically.'}
</p>
{selectedItem.issue?.confirmation?.next_contact_at ? (
<small>Next reminder or automatic closure check: {formatDate(selectedItem.issue.confirmation.next_contact_at)}</small>
) : null}
</div>
{selectedItem.permissions?.can_confirm_resolution ? (
<div className="issue-confirmation-actions">
<button type="button" disabled={respondingResolution} onClick={() => void respondToResolution(true)}>
Yes, it is fixed
</button>
<button type="button" className="ghost-button" disabled={respondingResolution} onClick={() => void respondToResolution(false)}>
No, it is still happening
</button>
</div>
) : null}
</section>
) : null}
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
<label className="portal-field-span-2">
@@ -0,0 +1,57 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import './latest-activity.css'
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string }
type Operation = { id: string; label: string; status: string; events: Event[] }
export default function LatestActivity({ operation, besideDownload, onDismiss }: {
operation: Operation; besideDownload: boolean; onDismiss: () => void
}) {
const dialog = useRef<HTMLDialogElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
const latest = [...operation.events].sort((a, b) =>
(a.finished_at ?? a.started_at ?? '').localeCompare(b.finished_at ?? b.started_at ?? '')
).at(-1)
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : 'Needs attention'
useEffect(() => {
if (!open) return
const element = dialog.current
element?.showModal()
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
element?.close()
document.body.style.overflow = previous
trigger.current?.focus()
}
}, [open])
return (
<div className={`request-overview-block latest-activity ${besideDownload ? 'beside-download' : 'full-row'}`}>
<button ref={trigger} type="button" className="latest-activity-trigger" onClick={() => setOpen(true)} aria-haspopup="dialog" aria-expanded={open}>
<span className="latest-activity-heading"><span className="request-overview-label">Latest activity</span><span className={`latest-activity-badge is-${operation.status}`}>{status}</span></span>
<span className="latest-activity-message" role="status">{latest?.message || 'Getting ready to check your request…'}</span>
<span className="latest-activity-more">View all activity ({operation.events.length}) <span aria-hidden="true"></span></span>
</button>
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)} onClick={(event) => { if (event.target === event.currentTarget) setOpen(false) }}>
<div className="activity-dialog-content">
<header>
<div><span className="request-overview-label">Activity details</span><h2 id="activity-dialog-title">{operation.label}</h2><small>{status} · {operation.events.length} updates</small></div>
<button type="button" onClick={() => setOpen(false)} autoFocus>Close</button>
</header>
<ol className="activity-dialog-events" aria-label="All activity, oldest first">
{operation.events.map((event) => <li key={event.id} className={`is-${event.state}`}>
<span className="activity-event-state">{event.state === 'active' ? 'Working' : event.state === 'error' ? 'Needs attention' : 'Done'}</span>
<div><strong>{event.service}</strong><p>{event.message}</p></div>
</li>)}
</ol>
{operation.status !== 'running' && <footer><button type="button" onClick={() => { setOpen(false); onDismiss() }}>Dismiss activity</button></footer>}
</div>
</dialog>
</div>
)
}
@@ -0,0 +1,28 @@
.latest-activity.beside-download { grid-column: 7 / -1; grid-row: 2; }
.latest-activity.full-row { grid-column: 1 / -1; }
.latest-activity .latest-activity-trigger { display: grid; gap: .6rem; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; box-shadow: none; text-transform: none; }
.latest-activity-trigger:focus-visible { outline: 2px solid var(--ops-accent, #83d7f7); outline-offset: 6px; }
.latest-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.latest-activity-message { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: .875rem; line-height: 1.5; font-weight: 400; }
.latest-activity-more { color: var(--ops-accent, #83d7f7); font-size: .75rem; }
.latest-activity-badge { font-size: .7rem; font-weight: 500; color: var(--ops-muted, #bbb); }
.latest-activity-badge.is-error { color: #ff9b9b; }
.latest-activity-badge.is-complete { color: #55dec0; }
.activity-dialog { position: fixed; inset: 0; margin: auto; width: min(720px, calc(100vw - 32px)); max-width: none; max-height: min(760px, calc(100dvh - 40px)); padding: 0; border: 1px solid var(--ops-border, #444); border-radius: 16px; color: var(--ops-text, #eee); background: var(--ops-surface, #1c1c1e); overflow: auto; box-shadow: 0 24px 80px #0008; }
.activity-dialog::backdrop { background: #000a; backdrop-filter: blur(5px); }
.activity-dialog-content { padding: 1.25rem; }
.activity-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.activity-dialog h2 { font-size: 1.2rem; margin: .4rem 0; }
.activity-dialog small { color: var(--ops-muted, #bbb); }
.activity-dialog-events { list-style: none; padding: 0; margin: 1.25rem 0 0; display: grid; gap: .65rem; }
.activity-dialog-events li { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: .8rem; padding: .9rem; border: 1px solid var(--ops-border, #444); border-radius: 10px; }
.activity-dialog-events strong { font-size: .8rem; }
.activity-dialog-events p { margin: .3rem 0 0; font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; }
.activity-event-state { font-size: .7rem; color: #55dec0; }
.is-error > .activity-event-state { color: #ff9b9b; }
.is-active > .activity-event-state { color: #83d7f7; }
.activity-dialog footer { display: flex; justify-content: flex-end; margin-top: 1rem; }
@media (max-width: 720px) {
.latest-activity.beside-download { grid-column: 1 / -1; grid-row: auto; }
.activity-dialog-events li { grid-template-columns: 1fr; gap: .4rem; }
}
+20 -45
View File
@@ -1,6 +1,7 @@
'use client'
import PageHeading from '../../ui/PageHeading'
import LatestActivity from './LatestActivity'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
@@ -200,12 +201,6 @@ const torrentProgress = (torrent: Record<string, any>) => {
const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, '')}% complete`
const formatDuration = (duration?: number | null) => {
if (typeof duration !== 'number' || Number.isNaN(duration)) return null
if (duration < 1000) return `${Math.max(0, Math.round(duration))}ms`
return `${(duration / 1000).toFixed(1)}s`
}
const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => {
if (String(current.request_id) !== String(live.request_id)) return current
if ((current.presentation?.repairCycle ?? null) !== (live.repairCycle ?? null)) return current
@@ -583,7 +578,10 @@ export default function RequestTimelinePage() {
})
if (!stopped && progressResponse.ok) {
const progress = await progressResponse.json()
if (Array.isArray(progress?.events)) setOperationProgress(progress)
if (Array.isArray(progress?.events)) {
setOperationProgress(progress)
return progress as OperationProgress
}
}
} catch (error) {
if (!stopped) console.error(error)
@@ -594,8 +592,21 @@ export default function RequestTimelinePage() {
const timer = window.setInterval(() => void refreshProgress(), 650)
try {
const response = await request
await refreshProgress()
const finalProgress = await refreshProgress()
if (!finalProgress || finalProgress.status === 'running') {
setOperationProgress((current) => current?.id === operationId ? {
...current, status: response.ok ? 'complete' : 'error',
events: [...current.events, { id: 'result', service: 'Magent', state: response.ok ? 'complete' : 'error',
message: response.ok ? 'This action has finished. Check the request status for what happens next.' : 'This action could not be completed. Check the message beside the request controls.' }],
} : current)
}
return response
} catch (error) {
setOperationProgress((current) => current?.id === operationId ? {
...current, status: 'error', events: [...current.events, { id: 'connection-error', service: 'Magent', state: 'error',
message: 'The connection was interrupted. Recheck the request before trying the action again—it may already have started.' }],
} : current)
throw error
} finally {
stopped = true
window.clearInterval(timer)
@@ -753,6 +764,7 @@ export default function RequestTimelinePage() {
{download?.lastSeenAt && !download?.torrents?.length && <small>Last observed {formatWhen(download.lastSeenAt)}</small>}
</div>
)}
{operationProgress && <LatestActivity operation={operationProgress} besideDownload={downloadVisible} onDismiss={() => setOperationProgress(null)} />}
<div className="request-overview-block request-next-step">
<div className="request-next-step-main">
<div className="request-next-step-copy">
@@ -793,43 +805,6 @@ export default function RequestTimelinePage() {
{actionError ?? actionMessage}
</div>
)}
{operationProgress && (
<div className={`request-operation-progress is-${operationProgress.status}`} aria-live="polite">
<div className="request-operation-heading">
<div>
<span className="request-overview-label">Remote activity</span>
<strong>{operationProgress.label}</strong>
</div>
<div className="request-operation-heading-actions">
{formatDuration(operationProgress.duration_ms) && (
<span>{formatDuration(operationProgress.duration_ms)}</span>
)}
<span className={`request-operation-status is-${operationProgress.status}`}>
{operationProgress.status === 'running' ? 'In progress' : operationProgress.status}
</span>
{operationProgress.status !== 'running' && (
<button type="button" onClick={() => setOperationProgress(null)}>Dismiss</button>
)}
</div>
</div>
<div className="request-operation-events">
{operationProgress.events.map((event) => (
<div className={`request-operation-event is-${event.state}`} key={event.id}>
<i aria-hidden="true" />
<div>
<strong>{event.service}</strong>
<span>{event.message}</span>
</div>
<small>
{event.state === 'active'
? 'Waiting…'
: formatDuration(event.duration_ms) ?? (event.status_code ? `HTTP ${event.status_code}` : 'Done')}
</small>
</div>
))}
</div>
</div>
)}
</section>
{repairActivity?.visible && (
+19
View File
@@ -0,0 +1,19 @@
'use client'
import './resolution-choice.css'
export default function ResolutionChoice({ title, busy, onAnswer }: {
title: string; busy: boolean; onAnswer: (resolved: boolean) => void
}) {
return <section className="resolution-choice" aria-labelledby="resolution-question" aria-busy={busy}>
<span className="section-kicker">Your answer is needed</span>
<h2 id="resolution-question">Is it fixed?</h2>
<p>{title}</p>
<p>Try the affected content in Grizzlyflix, then choose:</p>
<div className="resolution-choice-buttons">
<button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}><strong>YES</strong><span>It works close this issue</span></button>
<button id="no" type="button" className="resolution-no" disabled={busy} onClick={() => onAnswer(false)}><strong>NO</strong><span>Still broken keep it open</span></button>
</div>
{busy && <p role="status">Saving your answer</p>}
</section>
}
+13
View File
@@ -0,0 +1,13 @@
.resolution-choice { padding: clamp(20px, 4vw, 36px); border: 1px solid var(--ops-border, #555); border-radius: 18px; background: var(--ops-surface, #202023); margin-bottom: 20px; }
.resolution-choice h2 { margin: 10px 0; font-size: clamp(2rem, 5vw, 3.25rem); line-height: 1.1; }
.resolution-choice p { line-height: 1.5; overflow-wrap: anywhere; }
.resolution-choice-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px; }
.resolution-choice-buttons button { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 130px; padding: 20px; border-radius: 14px; border: 2px solid transparent; text-transform: none; }
.resolution-choice-buttons button strong { font-size: 2.75rem; line-height: 1; color: inherit; }
.resolution-choice-buttons button span { color: inherit; opacity: 1; font-size: .9rem; }
/* Scoped overrides for the legacy global !important button palette. */
.page .resolution-choice-buttons button.resolution-yes { background: #b4f4d2 !important; color: #10261b !important; border-color: #b4f4d2 !important; }
.page .resolution-choice-buttons button.resolution-no { background: #ffc1c5 !important; color: #391318 !important; border-color: #ffc1c5 !important; }
.resolution-choice-buttons button:focus-visible { outline: 3px solid var(--ops-accent, #c7baff); outline-offset: 4px; }
.resolution-response-page { width: min(760px, 100%); margin: 20px auto; }
@media (max-width: 520px) { .resolution-choice-buttons { grid-template-columns: 1fr; } .resolution-choice-buttons button { min-height: 104px; } }
+63
View File
@@ -0,0 +1,63 @@
// All responses are fixtures. No real emails or issue updates.
const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const context = await browser.newContext()
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
let status = 'awaiting_confirmation', canConfirm = true, unauthorized = false
const answers = [], errors = []
await context.route('**/api/**', (route) => {
const path = new URL(route.request().url()).pathname
const reply = (json) => route.fulfill({ json })
if (path === '/api/auth/me') return reply({ username: 'Member', role: 'user' })
if (path === '/api/auth/logout') return reply({ status: 'ok' })
if (path === '/api/auth/login' || path === '/api/auth/jellyfin/login') { unauthorized = false; status = 'awaiting_confirmation'; return reply({ authenticated: true }) }
if (path === '/api/portal/items/12') return unauthorized ? route.fulfill({ status: 401, json: { detail: 'Sign in' } }) : reply({ item: { id: 12, kind: 'issue', title: 'Picture broken: Example movie', status, permissions: { can_confirm_resolution: canConfirm } } })
if (path.endsWith('/resolution-response')) { answers.push(route.request().postDataJSON()); return reply({}) }
if (route.request().method() !== 'GET') throw Error('Unexpected mutation: ' + path)
return reply({ navigation: { showRequests: true }, services: [], login: { showJellyfinLogin: true } })
})
const page = await context.newPage()
page.on('pageerror', (error) => errors.push(error.message))
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 })
for (const yes of [true, false]) {
const count = answers.length
await page.goto(base + '/issues/confirm/12#' + (yes ? 'yes' : 'no'))
await page.reload()
const button = page.getByRole('button', { name: yes ? 'YES It works — close this issue' : 'NO Still broken — keep it open', exact: true })
await button.waitFor()
assert.equal(await button.evaluate((el) => getComputedStyle(el).backgroundColor), yes ? 'rgb(180, 244, 210)' : 'rgb(255, 193, 197)')
assert.equal(answers.length, count, 'Email GET must never submit an answer')
const box = await button.boundingBox()
assert.ok(box.height >= 100 && box.y + box.height <= 1000, 'Both choices should be large and immediately visible')
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
if (process.env.REVIEW_DIR) await page.screenshot({ path: `${process.env.REVIEW_DIR}/acceptance-${width}.png` })
await button.click()
await page.getByRole('heading', { name: yes ? 'Thanks! Your issue is now closed.' : 'Thanks for letting us know. Your issue stays open for another look.', exact: true }).waitFor()
assert.deepEqual(answers.at(-1), { resolved: yes })
}
}
canConfirm = false
await page.goto(base + '/issues/confirm/12')
await page.getByRole('heading', { name: 'This question is for the person who reported the issue.' }).waitFor()
assert.equal(await page.getByRole('button', { name: /^YES/ }).count(), 0)
canConfirm = true; status = 'closed'
await page.goto(base + '/issues/confirm/12')
await page.getByRole('heading', { name: 'No answer is needed right now.' }).waitFor()
unauthorized = true
await page.goto(base + '/issues/confirm/12')
await page.waitForURL('**/login?next=*')
assert.equal(new URL(page.url()).searchParams.get('next'), '/issues/confirm/12')
await page.getByLabel('Username', { exact: true }).fill('fixture-user')
await page.getByLabel('Password', { exact: true }).fill('fixture-password')
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await page.waitForURL('**/issues/confirm/12')
await page.getByRole('button', { name: /^YES/ }).waitFor()
assert.deepEqual(errors, [])
console.log('PASS: prominent desktop/mobile YES/NO, exact answer POSTs, safe email GET, reporter permission, closed issue and sign-in return link; no live writes.')
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exitCode = 1 })
+74
View File
@@ -0,0 +1,74 @@
// Fixture-only action feedback and accessible dialog checks; no live API writes.
const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const context = await browser.newContext()
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
let downloading = true, finished = false
const events = [
{ id: '1', service: 'Magent', state: 'complete', message: 'Your action has been received.' },
{ id: '2', service: 'Radarr', state: 'active', message: 'Checking whether the replacement has been downloaded…' },
]
let releaseAction
await context.route('**/api/**', async (route) => {
const path = new URL(route.request().url()).pathname
const reply = (json) => route.fulfill({ json })
if (path === '/api/auth/me') return reply({ username: 'Member', role: 'user' })
if (path.endsWith('/snapshot')) return reply({ request_id: '12', title: 'Activity review', request_type: 'movie', state: 'DOWNLOADING', timeline: [], actions: [], presentation: {
status: { label: 'Download in progress', meaning: 'Your movie is downloading.' },
download: { visible: downloading, summary: 'Downloading (1 active).' },
nextStep: { title: 'Let the download finish', description: 'No action needed.', actionIds: [] }, pipeline: [],
} })
if (path.endsWith('/actions/recheck')) { await new Promise((resolve) => { releaseAction = resolve }); return reply({ message: 'Request checked.' }) }
if (path.includes('/operations/')) return reply({ id: 'fixture', label: 'Recheck request status', status: finished ? 'complete' : 'running', events: finished ? [...events, { id: '3', service: 'Magent', state: 'complete', message: 'This check has finished. Your movie is still downloading.' }] : events })
if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
return reply({ navigation: { showRequests: true }, services: [] })
})
const page = await context.newPage()
const errors = []
page.on('pageerror', (error) => errors.push(error.message))
for (const width of [1440, 710, 390]) {
await page.setViewportSize({ width, height: 960 })
for (downloading of [true, false]) {
finished = false
await page.goto(base + '/requests/12')
await page.getByRole('button', { name: 'Recheck request', exact: true }).click({ timeout: 5000 }).catch(async (error) => {
console.error(errors, (await page.locator('body').innerText()).slice(0, 1600)); throw error
})
const card = page.locator('.latest-activity')
await card.locator('.latest-activity-message').filter({ hasText: events[1].message }).waitFor()
assert.equal(await card.getByText(events[0].message, { exact: true }).isVisible(), false)
if (width === 1440 && downloading) {
const activityBox = await card.boundingBox()
const downloadBox = await page.locator('.request-overview-block').filter({ hasText: 'Current download state' }).boundingBox()
assert.ok(activityBox.x > downloadBox.x && Math.abs(activityBox.y - downloadBox.y) < 2, 'Activity must use the spare right-hand quadrant')
}
const before = await page.locator('.request-next-step').boundingBox()
await card.getByRole('button').click()
const dialog = page.getByRole('dialog', { name: 'Recheck request status' })
await dialog.waitFor()
await dialog.getByText(events[0].message, { exact: true }).waitFor()
assert.equal(await page.evaluate(() => document.body.style.overflow), 'hidden')
const after = await page.locator('.request-next-step').boundingBox()
assert.equal(before.y, after.y, 'Opening the dialog must not expand the page')
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
assert.equal(await card.getByRole('button').evaluate((el) => el === document.activeElement), true)
finished = true; releaseAction()
await card.getByText('Finished', { exact: true }).waitFor()
await card.getByRole('button').click()
await dialog.getByText('This check has finished. Your movie is still downloading.', { exact: true }).waitFor()
if (process.env.REVIEW_DIR) await page.screenshot({ path: `${process.env.REVIEW_DIR}/activity-${width}-${downloading}.png` })
await dialog.getByRole('button', { name: 'Dismiss activity' }).click()
await card.waitFor({ state: 'hidden' })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
assert.notEqual(await page.evaluate(() => document.body.style.overflow), 'hidden')
}
}
assert.deepEqual(errors, [])
console.log('PASS: right-hand activity card, latest event only, live updates, modal history, Escape/focus return, dismissal, desktop/tablet/mobile; all API calls mocked.')
} finally { await browser.close() }
})().catch((error) => { console.error(error); process.exitCode = 1 })