diff --git a/README.md b/README.md index d5565de..55bd11b 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,11 @@ write local creates, updates, and deletes back with CalDAV `PUT`/`DELETE` and ETag preconditions. If a remote resource changed, the local mutation is rejected and the user must sync before retrying. A calendar-owned task, `govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or -cron-style scheduler. +cron-style scheduler. Due-source and outbox-dispatch HTTP routes require a +service-account principal and are not interactive administration actions. Calendar exposes collection and credential management, sync status/actions, +bounded outbound-change diagnostics and guarded retry/reconcile/discard recovery, durable per-user view preferences, recurrence expansion, detached occurrence overrides, instance/series editing, and a free/busy API primitive for scheduling and appointment modules. It is not yet a CalDAV network server: external clients diff --git a/docs/CALENDAR_INTEGRATION_CONCEPT.md b/docs/CALENDAR_INTEGRATION_CONCEPT.md index d38d1d8..430fc1a 100644 --- a/docs/CALENDAR_INTEGRATION_CONCEPT.md +++ b/docs/CALENDAR_INTEGRATION_CONCEPT.md @@ -89,14 +89,23 @@ per-source throughput for a simple ordering guarantee: a stale inbound report cannot land after a newer outbound result, and queued leases do not expire while waiting behind another network request for the same source. -Operators can list, dispatch, retry, reconcile, and discard tenant operations -through the `/calendar/caldav/outbox` administration endpoints. Terminal ETag +Operators open **Outbound changes** from a synchronized calendar's settings to +inspect a bounded queue and use only the retry, reconcile, or discard actions +that are valid for the latest resource generation. Dispatch and due-source +routes are worker-only and reject interactive sessions even when the account is +an administrator. Terminal ETag conflicts and exhausted retries remain the current local desired state and shield that resource from inbound overwrite until explicitly resolved. Discarding is the administrator's accept-remote transition: it is allowed only for the latest generation, atomically cancels its unresolved predecessor chain, marks the event projection as discarded, clears the sync token, and schedules a full inbound reconciliation so an unchanged remote object is not missed. +The UI requires a separate destructive confirmation and explains that accepting +remote may lose the unresolved local desired state. Disabled and inbound-only +sources still permit discard when the retained source belongs to the tenant, +but they cannot be retried or reconciled until made writable again. Missing +sources, active worker leases, stale generations, and already-resolved rows +expose diagnostics instead of unsafe action buttons. Disabling outbound delivery or switching to inbound-only is rejected while unresolved desired state exists; retirement is the explicit exception and diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index 6ba8906..8914883 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -554,6 +554,23 @@ manifest = ModuleManifest( related_modules=("connectors", "audit", "ops"), metadata={"kind": "reference"}, ), + DocumentationTopic( + id="calendar.outbound-change-recovery", + title="Recover synchronized calendar writes", + summary="Inspect unresolved CalDAV writes and recover only the latest safe resource generation.", + body=( + "Calendar administrators open Outbound changes from a synchronized calendar's settings. " + "The bounded history explains attempts, conflicts, dead work, stale generations, disabled sources, " + "and active worker leases. Retry schedules a failed desired state, reconcile compares it with the " + "remote resource, and discard abandons the local desired state after a separate warning so the next " + "full sync can accept remote. Automatic dispatch and due-source execution remain service-account-only " + "worker operations and are not exposed as interactive controls." + ), + documentation_types=("admin", "user"), + audience=("calendar_manager", "operator", "tenant_admin"), + related_modules=("ops", "audit"), + metadata={"kind": "runbook"}, + ), ), capability_factories={ CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider, diff --git a/src/govoplan_calendar/backend/outbox.py b/src/govoplan_calendar/backend/outbox.py index bade245..30cfdef 100644 --- a/src/govoplan_calendar/backend/outbox.py +++ b/src/govoplan_calendar/backend/outbox.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Mapping from datetime import datetime, timedelta, timezone from typing import Any -from sqlalchemy import and_, event, or_ +from sqlalchemy import and_, event, or_, tuple_ from sqlalchemy.orm import Session from govoplan_calendar.backend.caldav import ( @@ -394,6 +394,119 @@ def list_calendar_outbox_operations( ).limit(max(1, min(limit, 500))).all() +def calendar_outbox_operation_action_states( + session: Session, + operations: list[CalendarOutboxOperation], +) -> dict[str, dict[str, dict[str, object]]]: + """Return recovery actions without per-row source or generation queries.""" + + if not operations: + return {} + source_ids = {operation.source_id for operation in operations} + sources = { + source.id: source + for source in session.query(CalendarSyncSource) + .filter(CalendarSyncSource.id.in_(sorted(source_ids))) + .all() + } + resource_keys = { + (operation.source_id, operation.resource_href) for operation in operations + } + generations = ( + session.query( + CalendarOutboxOperation.id, + CalendarOutboxOperation.source_id, + CalendarOutboxOperation.resource_href, + CalendarOutboxOperation.created_at, + ) + .filter( + tuple_( + CalendarOutboxOperation.source_id, + CalendarOutboxOperation.resource_href, + ).in_(sorted(resource_keys)) + ) + .all() + ) + latest_by_resource: dict[tuple[str, str], tuple[datetime, str]] = {} + for operation_id, source_id, resource_href, created_at in generations: + key = (source_id, resource_href) + candidate = (created_at, operation_id) + if key not in latest_by_resource or candidate > latest_by_resource[key]: + latest_by_resource[key] = candidate + + return { + operation.id: _calendar_outbox_action_state( + operation, + source=sources.get(operation.source_id), + latest_operation_id=latest_by_resource[ + (operation.source_id, operation.resource_href) + ][1], + ) + for operation in operations + } + + +def _calendar_outbox_action_state( + operation: CalendarOutboxOperation, + *, + source: CalendarSyncSource | None, + latest_operation_id: str, +) -> dict[str, dict[str, object]]: + actions = { + action: {"allowed": False, "reason": None} + for action in ("retry", "reconcile", "discard") + } + + def block_all(reason: str) -> dict[str, dict[str, object]]: + for state in actions.values(): + state["reason"] = reason + return actions + + if operation.id != latest_operation_id: + return block_all( + "A newer desired state exists for this resource; recover the latest operation instead." + ) + if operation.status in {"succeeded", "superseded", "cancelled"}: + return block_all(f"This operation is already {operation.status}.") + if ( + operation.status == "in_progress" + and operation.lease_expires_at is not None + and _as_utc(operation.lease_expires_at) > utcnow() + ): + return block_all("A worker currently holds the delivery lease.") + + unavailable_reason = _operation_source_unavailable_reason(operation, source) + if operation.status in {"retry", "dead", "conflict"}: + actions["retry"] = { + "allowed": unavailable_reason is None, + "reason": unavailable_reason, + } + else: + actions["retry"]["reason"] = ( + "Retry is available after an automatic attempt reports a retryable or terminal failure." + ) + if operation.status in {"pending", "retry", "dead", "conflict", "in_progress"}: + actions["reconcile"] = { + "allowed": unavailable_reason is None, + "reason": unavailable_reason, + } + + discard_unavailable = ( + "CalDAV source no longer exists" + if source is None + else ( + "Calendar outbox source tenant does not match the operation tenant" + if source.tenant_id != operation.tenant_id + else None + ) + ) + actions["discard"] = { + "allowed": discard_unavailable is None, + "reason": discard_unavailable, + } + return actions + + def get_calendar_outbox_operation( session: Session, *, @@ -1273,7 +1386,13 @@ def retry_calendar_outbox_operation( tenant_id=tenant_id, operation_id=operation_id, ) - if operation.status in {"succeeded", "in_progress", "superseded", "cancelled"}: + if operation.status in { + "pending", + "succeeded", + "in_progress", + "superseded", + "cancelled", + }: raise ValueError(f"A {operation.status} Calendar outbox operation cannot be retried") _assert_latest_resource_generation(session, operation) unavailable_reason = _operation_source_unavailable_reason(operation, source) @@ -1553,7 +1672,11 @@ def calendar_outbox_has_unresolved_desired_state( return False -def calendar_outbox_operation_response(operation: CalendarOutboxOperation) -> dict[str, Any]: +def calendar_outbox_operation_response( + operation: CalendarOutboxOperation, + *, + actions: Mapping[str, Mapping[str, object]] | None = None, +) -> dict[str, Any]: return { "id": operation.id, "tenant_id": operation.tenant_id, @@ -1577,6 +1700,9 @@ def calendar_outbox_operation_response(operation: CalendarOutboxOperation) -> di "created_at": operation.created_at, "updated_at": operation.updated_at, "metadata": dict(operation.metadata_ or {}), + "actions": { + str(action): dict(state) for action, state in (actions or {}).items() + }, } diff --git a/src/govoplan_calendar/backend/router.py b/src/govoplan_calendar/backend/router.py index 3db66e6..c45b456 100644 --- a/src/govoplan_calendar/backend/router.py +++ b/src/govoplan_calendar/backend/router.py @@ -55,6 +55,7 @@ from govoplan_calendar.backend.schemas import ( CalendarViewPreferencesUpdateRequest, ) from govoplan_calendar.backend.outbox import ( + calendar_outbox_operation_action_states, calendar_outbox_operation_response, discard_calendar_outbox_operation, dispatch_calendar_outbox, @@ -127,6 +128,14 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes)) +def _require_worker_principal(principal: ApiPrincipal) -> None: + if principal.auth_method != "service_account": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This endpoint is restricted to service-account workers.", + ) + + def _calendar_response(calendar) -> CalendarCollectionResponse: return CalendarCollectionResponse.model_validate(calendar_response(calendar)) @@ -271,9 +280,16 @@ def _sync_source_response(source) -> CalendarSyncSourceResponse: return CalendarSyncSourceResponse.model_validate(caldav_source_response(source)) -def _outbox_operation_response(operation) -> CalendarOutboxOperationResponse: +def _outbox_operation_response( + session: Session, + operation, +) -> CalendarOutboxOperationResponse: + actions = calendar_outbox_operation_action_states(session, [operation]) return CalendarOutboxOperationResponse.model_validate( - calendar_outbox_operation_response(operation) + calendar_outbox_operation_response( + operation, + actions=actions.get(operation.id), + ) ) @@ -468,6 +484,7 @@ def api_sync_due_sources( session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:import") + _require_worker_principal(principal) results = sync_due_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit) session.commit() return CalendarSyncDueSyncResponse( @@ -616,6 +633,7 @@ def api_sync_due_caldav_sources( session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:import") + _require_worker_principal(principal) results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit) session.commit() return CalendarCalDavDueSyncResponse( @@ -652,8 +670,17 @@ def api_list_caldav_outbox( source_id=source_id, limit=limit, ) + action_states = calendar_outbox_operation_action_states(session, operations) return CalendarOutboxOperationListResponse( - operations=[_outbox_operation_response(operation) for operation in operations] + operations=[ + CalendarOutboxOperationResponse.model_validate( + calendar_outbox_operation_response( + operation, + actions=action_states.get(operation.id), + ) + ) + for operation in operations + ] ) @@ -664,6 +691,7 @@ def api_dispatch_caldav_outbox( session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") + _require_worker_principal(principal) return CalendarOutboxDispatchResponse.model_validate( dispatch_calendar_outbox( session, @@ -691,7 +719,7 @@ def api_retry_caldav_outbox_operation( ) session.commit() session.refresh(operation) - return _outbox_operation_response(operation) + return _outbox_operation_response(session, operation) except ValueError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc @@ -715,7 +743,7 @@ def api_reconcile_caldav_outbox_operation( ) session.commit() session.refresh(operation) - return _outbox_operation_response(operation) + return _outbox_operation_response(session, operation) except (ValueError, CalDAVError, CalendarError) as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @@ -741,7 +769,7 @@ def api_discard_caldav_outbox_operation( ) session.commit() session.refresh(operation) - return _outbox_operation_response(operation) + return _outbox_operation_response(session, operation) except ValueError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc diff --git a/src/govoplan_calendar/backend/schemas.py b/src/govoplan_calendar/backend/schemas.py index 8762246..d92eaab 100644 --- a/src/govoplan_calendar/backend/schemas.py +++ b/src/govoplan_calendar/backend/schemas.py @@ -265,6 +265,11 @@ class CalendarCalDavDueSyncResponse(BaseModel): results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list) +class CalendarOutboxActionAvailability(BaseModel): + allowed: bool = False + reason: str | None = None + + class CalendarOutboxOperationResponse(BaseModel): id: str tenant_id: str @@ -288,6 +293,9 @@ class CalendarOutboxOperationResponse(BaseModel): created_at: datetime updated_at: datetime metadata: dict[str, Any] = Field(default_factory=dict) + actions: dict[str, CalendarOutboxActionAvailability] = Field( + default_factory=dict + ) class CalendarOutboxOperationListResponse(BaseModel): diff --git a/tests/test_http_security.py b/tests/test_http_security.py index 995cf09..cf994f0 100644 --- a/tests/test_http_security.py +++ b/tests/test_http_security.py @@ -7,6 +7,8 @@ from collections.abc import Iterator from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from govoplan_calendar.backend.service import CalendarError, http_request +from govoplan_calendar.backend.router import _require_worker_principal +from fastapi import HTTPException @contextlib.contextmanager @@ -24,6 +26,17 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]: class CalendarHttpSecurityTests(unittest.TestCase): + def test_worker_routes_reject_interactive_principals(self) -> None: + class Principal: + auth_method = "session" + + with self.assertRaises(HTTPException) as raised: + _require_worker_principal(Principal()) # type: ignore[arg-type] + self.assertEqual(403, raised.exception.status_code) + + Principal.auth_method = "service_account" + _require_worker_principal(Principal()) # type: ignore[arg-type] + def test_cross_origin_redirect_does_not_forward_authorization(self) -> None: forwarded_authorization: list[str | None] = [] diff --git a/tests/test_outbox.py b/tests/test_outbox.py index 036eb75..912c355 100644 --- a/tests/test_outbox.py +++ b/tests/test_outbox.py @@ -18,6 +18,7 @@ from govoplan_calendar.backend.caldav import ( from govoplan_calendar.backend.db.models import CalendarOutboxOperation from govoplan_calendar.backend.outbox import ( SqlCalendarOutboxProvider, + calendar_outbox_operation_action_states, claim_next_calendar_outbox_operation, discard_calendar_outbox_operation, dispatch_calendar_outbox, @@ -508,6 +509,66 @@ class CalendarOutboxTests(unittest.TestCase): operation_id=first.id, ) + def test_recovery_action_state_is_batched_and_respects_source_state(self) -> None: + event = self.create_local_event(summary="First") + self.session.commit() + operation = self.session.query(CalendarOutboxOperation).one() + + state = calendar_outbox_operation_action_states( + self.session, + [operation], + )[operation.id] + self.assertFalse(state["retry"]["allowed"]) + self.assertTrue(state["reconcile"]["allowed"]) + self.assertTrue(state["discard"]["allowed"]) + + operation.status = "conflict" + self.source.sync_enabled = False + self.session.flush() + disabled = calendar_outbox_operation_action_states( + self.session, + [operation], + )[operation.id] + self.assertFalse(disabled["retry"]["allowed"]) + self.assertIn("disabled", str(disabled["retry"]["reason"])) + self.assertFalse(disabled["reconcile"]["allowed"]) + self.assertTrue(disabled["discard"]["allowed"]) + + self.source.sync_enabled = True + update_event( + self.session, + tenant_id="tenant-1", + user_id=None, + event_id=event.id, + payload=CalendarEventUpdateRequest(summary="Second"), + ) + self.session.flush() + stale = calendar_outbox_operation_action_states( + self.session, + [operation], + )[operation.id] + self.assertFalse(any(item["allowed"] for item in stale.values())) + self.assertIn("newer desired state", str(stale["discard"]["reason"])) + + def test_manual_retry_cannot_be_repeated_while_pending(self) -> None: + self.create_local_event() + self.session.commit() + operation = self.session.query(CalendarOutboxOperation).one() + operation.status = "dead" + self.session.flush() + + retry_calendar_outbox_operation( + self.session, + tenant_id="tenant-1", + operation_id=operation.id, + ) + with self.assertRaisesRegex(ValueError, "pending"): + retry_calendar_outbox_operation( + self.session, + tenant_id="tenant-1", + operation_id=operation.id, + ) + def test_discard_latest_cancels_attempted_predecessor_chain(self) -> None: event = self.create_local_event(summary="First") self.session.commit() diff --git a/webui/src/api/calendar.ts b/webui/src/api/calendar.ts index 94bc92a..f7898ad 100644 --- a/webui/src/api/calendar.ts +++ b/webui/src/api/calendar.ts @@ -191,6 +191,35 @@ export type CalendarSyncSourceSyncResponse = { export type CalendarCalDavSyncResponse = CalendarSyncSourceSyncResponse; +export type CalendarOutboxActionAvailability = { + allowed: boolean; + reason?: string | null; +}; + +export type CalendarOutboxOperation = { + id: string; + source_id: string; + event_id?: string | null; + operation_kind: "put" | "delete" | string; + resource_href: string; + status: string; + attempt_count: number; + max_attempts: number; + available_at: string; + last_attempt_at?: string | null; + lease_expires_at?: string | null; + completed_at?: string | null; + reconciled_at?: string | null; + last_error?: string | null; + created_at: string; + updated_at: string; + actions: Record; +}; + +export type CalendarOutboxOperationListResponse = { + operations: CalendarOutboxOperation[]; +}; + export type CalendarCollectionCreatePayload = { name: string; slug?: string | null; @@ -345,6 +374,28 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa return apiFetch(settings, `/api/v1/calendar/caldav/sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) }); } +export function listCalendarOutbox( + settings: ApiSettings, + params: { source_id?: string; status?: string; limit?: number } = {}, +): Promise { + const search = new URLSearchParams(); + if (params.source_id) search.set("source_id", params.source_id); + if (params.status) search.set("status", params.status); + if (params.limit) search.set("limit", String(params.limit)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return apiFetch(settings, `/api/v1/calendar/caldav/outbox${suffix}`); +} + +export function recoverCalendarOutboxOperation( + settings: ApiSettings, + operationId: string, + action: "retry" | "reconcile" | "discard", +): Promise { + return apiFetch(settings, `/api/v1/calendar/caldav/outbox/${operationId}/${action}`, { + method: "POST", + }); +} + export function listCalendarEvents( settings: ApiSettings, params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {} diff --git a/webui/src/features/calendar/CalendarCollectionDialogs.tsx b/webui/src/features/calendar/CalendarCollectionDialogs.tsx index 33d55b1..bcc1d0c 100644 --- a/webui/src/features/calendar/CalendarCollectionDialogs.tsx +++ b/webui/src/features/calendar/CalendarCollectionDialogs.tsx @@ -5,7 +5,7 @@ import { type ChangeEvent, type FormEvent, } from "react"; -import { RefreshCw, Trash2 } from "lucide-react"; +import { ListChecks, RefreshCw, Trash2 } from "lucide-react"; import { Button, ColorPickerField, @@ -101,6 +101,7 @@ export function CalendarCollectionDialog({ onSave, onRequestDelete, onSync, + onOpenOutbox, onDiscover @@ -116,7 +117,7 @@ export function CalendarCollectionDialog({ -}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) { +}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise;onOpenOutbox: (source: CalendarSyncSource) => void;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) { const calendar = state.kind === "edit" ? state.calendar : null; const isEdit = Boolean(calendar); const [sourceMode, setSourceMode] = useState(source ? calendarSourceModeForSource(source) : "local"); @@ -559,6 +560,11 @@ export function CalendarCollectionDialog({ + {source.source_kind === "caldav" && canManageSources && ( + + )} } diff --git a/webui/src/features/calendar/CalendarOutboxDialog.tsx b/webui/src/features/calendar/CalendarOutboxDialog.tsx new file mode 100644 index 0000000..6bcc577 --- /dev/null +++ b/webui/src/features/calendar/CalendarOutboxDialog.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react"; +import { + Button, + ConfirmDialog, + Dialog, + DismissibleAlert, + SegmentedControl, + StatusBadge, + type ApiSettings, +} from "@govoplan/core-webui"; +import { + listCalendarOutbox, + recoverCalendarOutboxOperation, + type CalendarCollection, + type CalendarOutboxOperation, + type CalendarSyncSource, +} from "../../api/calendar"; +import { dateTimeLabel, errorText } from "./calendarViewModel"; + +type OutboxFilter = "unresolved" | "all"; +type RecoveryAction = "retry" | "reconcile" | "discard"; + +const RESOLVED_STATUSES = new Set(["succeeded", "superseded", "cancelled"]); + +export function CalendarOutboxDialog({ + settings, + calendar, + source, + onClose, +}: { + settings: ApiSettings; + calendar: CalendarCollection; + source: CalendarSyncSource; + onClose: () => void; +}) { + const [operations, setOperations] = useState([]); + const [filter, setFilter] = useState("unresolved"); + const [loading, setLoading] = useState(true); + const [busyOperationId, setBusyOperationId] = useState(""); + const [error, setError] = useState(""); + const [discardOperation, setDiscardOperation] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const response = await listCalendarOutbox(settings, { + source_id: source.id, + limit: 100, + }); + setOperations(response.operations); + } catch (err) { + setError(errorText(err)); + } finally { + setLoading(false); + } + }, [settings, source.id]); + + useEffect(() => { + void load(); + }, [load]); + + const visibleOperations = useMemo( + () => filter === "all" + ? operations + : operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)), + [filter, operations], + ); + const unresolvedCount = operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)).length; + const conflictCount = operations.filter((operation) => ["conflict", "dead"].includes(operation.status)).length; + + async function recover(operation: CalendarOutboxOperation, action: RecoveryAction) { + setBusyOperationId(operation.id); + setError(""); + try { + await recoverCalendarOutboxOperation(settings, operation.id, action); + setDiscardOperation(null); + await load(); + } catch (err) { + setError(errorText(err)); + } finally { + setBusyOperationId(""); + } + } + + return ( + <> + + + + + } + > +
+

{calendar.name}

+ {error && {error}} +
+ + value={filter} + ariaLabel="i18n:govoplan-calendar.outbox_filter.8305af40" + onChange={setFilter} + options={[ + { id: "unresolved", label: "i18n:govoplan-calendar.unresolved.11c41de4" }, + { id: "all", label: "i18n:govoplan-calendar.all_history.8829c44e" }, + ]} + /> +
+
i18n:govoplan-calendar.unresolved.11c41de4
{unresolvedCount}
+
i18n:govoplan-calendar.conflicts_dead.31252c3a
{conflictCount}
+
i18n:govoplan-calendar.shown.498e85a1
{visibleOperations.length}
+
+
+ {loading && !operations.length + ?

i18n:govoplan-calendar.loading_outbound_changes.3fc59656

+ : visibleOperations.length + ? ( +
    + {visibleOperations.map((operation) => ( +
  1. +
    +
    + + {operation.operation_kind.toUpperCase()} +
    + +
    + {operation.resource_href} +
    +
    i18n:govoplan-calendar.attempts.448fa12e
    {operation.attempt_count}/{operation.max_attempts}
    +
    i18n:govoplan-calendar.available.17bc146b
    {dateTimeLabel(new Date(operation.available_at))}
    +
    + {operation.last_error &&

    {operation.last_error}

    } + action === "discard" + ? setDiscardOperation(operation) + : void recover(operation, action)} + /> +
  2. + ))} +
+ ) + :

i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64

} + {operations.length === 100 && ( +

i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365

+ )} +
+
+ setDiscardOperation(null)} + onConfirm={() => discardOperation && void recover(discardOperation, "discard")} + /> + + ); +} + +function RecoveryActions({ + operation, + busy, + onRecover, +}: { + operation: CalendarOutboxOperation; + busy: boolean; + onRecover: (action: RecoveryAction) => void; +}) { + const available = (["retry", "reconcile", "discard"] as const).filter( + (action) => operation.actions[action]?.allowed, + ); + const unavailableReason = Object.values(operation.actions).find((action) => action.reason)?.reason; + return ( +
+ {available.length > 0 && ( +
+ {available.includes("retry") && ( + + )} + {available.includes("reconcile") && ( + + )} + {available.includes("discard") && ( + + )} +
+ )} + {!available.length && unavailableReason &&

{unavailableReason}

} +
+ ); +} diff --git a/webui/src/features/calendar/CalendarPage.tsx b/webui/src/features/calendar/CalendarPage.tsx index 5606ae8..d8efac2 100644 --- a/webui/src/features/calendar/CalendarPage.tsx +++ b/webui/src/features/calendar/CalendarPage.tsx @@ -57,6 +57,7 @@ import { type CalendarDeleteDialogState, } from "./CalendarCollectionDialogs"; import { CalendarEventDialog } from "./CalendarEventDialog"; +import { CalendarOutboxDialog } from "./CalendarOutboxDialog"; import { CalendarTimeGrid, CalendarWeekRows, @@ -132,6 +133,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; const [eventDialog, setEventDialog] = useState(null); const [calendarDialog, setCalendarDialog] = useState(null); const [calendarDeleteDialog, setCalendarDeleteDialog] = useState(null); + const [outboxDialog, setOutboxDialog] = useState<{ calendar: CalendarCollection; source: CalendarSyncSource } | null>(null); const [syncingSourceId, setSyncingSourceId] = useState(""); const [continuousViewport, setContinuousViewport] = useState({ scrollTop: 0, height: 0 }); const [draggingEventId, setDraggingEventId] = useState(""); @@ -1005,8 +1007,21 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; onSave={handleCalendarSave} onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)} onSync={handleSyncSource} + onOpenOutbox={(source) => { + if (calendarDialog.kind === "edit") { + setOutboxDialog({ calendar: calendarDialog.calendar, source }); + } + }} onDiscover={handleCalDavDiscovery} /> + } + {outboxDialog && + setOutboxDialog(null)} /> + } {calendarDeleteDialog && div, +.calendar-outbox-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.calendar-outbox-item-heading { + justify-content: space-between; +} + +.calendar-outbox-item-heading time { + color: var(--muted); + font-size: 12px; +} + +.calendar-outbox-item code { + overflow: hidden; + color: var(--text); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.calendar-outbox-item-facts { + display: flex; + gap: 16px; + margin: 0; +} + +.calendar-outbox-item-facts div { + display: flex; + gap: 5px; +} + +.calendar-outbox-item-facts dt { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.calendar-outbox-item-facts dd { + margin: 0; + font-size: 12px; +} + +.calendar-outbox-error { + margin: 0; + padding: 8px 9px; + border-left: 3px solid var(--red); + background: var(--calendar-danger-bg); + color: var(--red); + font-size: 12px; +} + +.calendar-outbox-actions { + justify-content: flex-end; + flex-wrap: wrap; +} + .calendar-form-error { margin: 0; padding: 9px 10px;