Skip to main content

canic_backup/restore/apply/journal/reports/
mod.rs

1//! Module: restore::apply::journal::reports
2//!
3//! Responsibility: project restore apply journals into operator reports.
4//! Does not own: journal transitions, command rendering, or receipt validation.
5//! Boundary: returns compact read-only progress and attention summaries.
6
7use super::{
8    RestoreApplyJournal, RestoreApplyJournalOperation, RestoreApplyOperationKind,
9    RestoreApplyOperationKindCounts, RestoreApplyOperationState,
10};
11
12use serde::{Deserialize, Serialize};
13
14///
15/// RestoreApplyJournalReport
16///
17/// Internal operator report for one restore apply journal.
18/// Owned by restore apply journaling and exposed through restore report APIs.
19///
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(deny_unknown_fields)]
23pub(in crate::restore) struct RestoreApplyJournalReport {
24    pub report_version: u16,
25    pub backup_id: String,
26    pub outcome: RestoreApplyReportOutcome,
27    pub attention_required: bool,
28    pub ready: bool,
29    pub complete: bool,
30    pub blocked_reasons: Vec<String>,
31    pub operation_count: usize,
32    pub operation_counts: RestoreApplyOperationKindCounts,
33    pub progress: RestoreApplyProgressSummary,
34    pub pending_summary: RestoreApplyPendingSummary,
35    pub pending_operations: usize,
36    pub ready_operations: usize,
37    pub blocked_operations: usize,
38    pub completed_operations: usize,
39    pub failed_operations: usize,
40    pub next_transition: Option<RestoreApplyReportOperation>,
41    pub pending: Vec<RestoreApplyReportOperation>,
42    pub failed: Vec<RestoreApplyReportOperation>,
43    pub blocked: Vec<RestoreApplyReportOperation>,
44}
45
46impl RestoreApplyJournalReport {
47    /// Build a compact operator report from a restore apply journal.
48    #[must_use]
49    pub(in crate::restore) fn from_journal(journal: &RestoreApplyJournal) -> Self {
50        let complete = journal.is_complete();
51        let outcome = RestoreApplyReportOutcome::from_journal(journal, complete);
52        let pending = report_operations_with_state(journal, RestoreApplyOperationState::Pending);
53        let failed = report_operations_with_state(journal, RestoreApplyOperationState::Failed);
54        let blocked = report_operations_with_state(journal, RestoreApplyOperationState::Blocked);
55
56        Self {
57            report_version: 1,
58            backup_id: journal.backup_id.clone(),
59            outcome: outcome.clone(),
60            attention_required: outcome.attention_required(),
61            ready: journal.ready,
62            complete,
63            blocked_reasons: journal.blocked_reasons.clone(),
64            operation_count: journal.operation_count,
65            operation_counts: journal.operation_kind_counts(),
66            progress: RestoreApplyProgressSummary::from_journal(journal),
67            pending_summary: RestoreApplyPendingSummary::from_journal(journal),
68            pending_operations: journal.pending_operations,
69            ready_operations: journal.ready_operations,
70            blocked_operations: journal.blocked_operations,
71            completed_operations: journal.completed_operations,
72            failed_operations: journal.failed_operations,
73            next_transition: journal
74                .next_transition_operation()
75                .map(RestoreApplyReportOperation::from_journal_operation),
76            pending,
77            failed,
78            blocked,
79        }
80    }
81}
82
83///
84/// RestoreApplyPendingSummary
85///
86/// Read-only summary of pending restore apply work.
87/// Owned by restore apply reporting and embedded in journal reports.
88///
89
90#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
91#[serde(deny_unknown_fields)]
92pub struct RestoreApplyPendingSummary {
93    pub pending_operations: usize,
94    pub pending_operation_available: bool,
95    pub pending_sequence: Option<usize>,
96    pub pending_operation: Option<RestoreApplyOperationKind>,
97    pub pending_updated_at: Option<String>,
98    pub pending_updated_at_known: bool,
99}
100
101impl RestoreApplyPendingSummary {
102    /// Build a compact pending-operation summary from a restore apply journal.
103    #[must_use]
104    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
105        let pending = journal
106            .operations
107            .iter()
108            .filter(|operation| operation.state == RestoreApplyOperationState::Pending)
109            .min_by_key(|operation| operation.sequence);
110        let pending_updated_at = pending.and_then(|operation| operation.state_updated_at.clone());
111        let pending_updated_at_known = pending_updated_at
112            .as_deref()
113            .is_some_and(known_state_update_marker);
114
115        Self {
116            pending_operations: journal.pending_operations,
117            pending_operation_available: pending.is_some(),
118            pending_sequence: pending.map(|operation| operation.sequence),
119            pending_operation: pending.map(|operation| operation.operation.clone()),
120            pending_updated_at,
121            pending_updated_at_known,
122        }
123    }
124}
125
126fn known_state_update_marker(value: &str) -> bool {
127    !value.trim().is_empty() && value != "unknown"
128}
129
130///
131/// RestoreApplyProgressSummary
132///
133/// Read-only restore apply progress counters.
134/// Owned by restore apply reporting and embedded in journal reports.
135///
136
137#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138#[serde(deny_unknown_fields)]
139pub struct RestoreApplyProgressSummary {
140    pub operation_count: usize,
141    pub completed_operations: usize,
142    pub remaining_operations: usize,
143    pub transitionable_operations: usize,
144    pub attention_operations: usize,
145    pub completion_basis_points: usize,
146}
147
148impl RestoreApplyProgressSummary {
149    /// Build a compact progress summary from restore apply journal counters.
150    #[must_use]
151    pub const fn from_journal(journal: &RestoreApplyJournal) -> Self {
152        let remaining_operations = journal
153            .operation_count
154            .saturating_sub(journal.completed_operations);
155        let transitionable_operations = journal.ready_operations + journal.pending_operations;
156        let attention_operations =
157            journal.pending_operations + journal.blocked_operations + journal.failed_operations;
158        let completion_basis_points =
159            completion_basis_points(journal.completed_operations, journal.operation_count);
160
161        Self {
162            operation_count: journal.operation_count,
163            completed_operations: journal.completed_operations,
164            remaining_operations,
165            transitionable_operations,
166            attention_operations,
167            completion_basis_points,
168        }
169    }
170}
171
172const fn completion_basis_points(completed_operations: usize, operation_count: usize) -> usize {
173    if operation_count == 0 {
174        return 0;
175    }
176
177    completed_operations.saturating_mul(10_000) / operation_count
178}
179
180///
181/// RestoreApplyReportOutcome
182///
183/// High-level operator outcome for one restore apply journal.
184/// Owned by restore apply reporting and used to classify attention needs.
185///
186
187#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
188#[serde(rename_all = "kebab-case")]
189pub enum RestoreApplyReportOutcome {
190    Empty,
191    Complete,
192    Failed,
193    Blocked,
194    Pending,
195    InProgress,
196}
197
198impl RestoreApplyReportOutcome {
199    const fn from_journal(journal: &RestoreApplyJournal, complete: bool) -> Self {
200        if journal.operation_count == 0 {
201            return Self::Empty;
202        }
203        if complete {
204            return Self::Complete;
205        }
206        if journal.failed_operations > 0 {
207            return Self::Failed;
208        }
209        if !journal.ready || journal.blocked_operations > 0 {
210            return Self::Blocked;
211        }
212        if journal.pending_operations > 0 {
213            return Self::Pending;
214        }
215        Self::InProgress
216    }
217
218    const fn attention_required(&self) -> bool {
219        matches!(self, Self::Failed | Self::Blocked | Self::Pending)
220    }
221}
222
223///
224/// RestoreApplyReportOperation
225///
226/// Compact report row for one restore apply journal operation.
227/// Owned by restore apply reporting and embedded in state-specific report lists.
228///
229
230#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
231#[serde(deny_unknown_fields)]
232pub struct RestoreApplyReportOperation {
233    pub sequence: usize,
234    pub operation: RestoreApplyOperationKind,
235    pub state: RestoreApplyOperationState,
236    pub member_order: usize,
237    pub role: String,
238    pub source_canister: String,
239    pub target_canister: String,
240    pub state_updated_at: Option<String>,
241    pub reasons: Vec<String>,
242}
243
244impl RestoreApplyReportOperation {
245    fn from_journal_operation(operation: &RestoreApplyJournalOperation) -> Self {
246        Self {
247            sequence: operation.sequence,
248            operation: operation.operation.clone(),
249            state: operation.state.clone(),
250            member_order: operation.member_order,
251            role: operation.role.clone(),
252            source_canister: operation.source_canister.clone(),
253            target_canister: operation.target_canister.clone(),
254            state_updated_at: operation.state_updated_at.clone(),
255            reasons: operation.blocking_reasons.clone(),
256        }
257    }
258}
259
260fn report_operations_with_state(
261    journal: &RestoreApplyJournal,
262    state: RestoreApplyOperationState,
263) -> Vec<RestoreApplyReportOperation> {
264    journal
265        .operations
266        .iter()
267        .filter(|operation| operation.state == state)
268        .map(RestoreApplyReportOperation::from_journal_operation)
269        .collect()
270}