Skip to main content

wekan_cli/command_result/admin/
problems.rs

1use crate::client::admin::problems as wire;
2use serde::Serialize;
3use serde_json::Number;
4use std::collections::BTreeMap;
5
6#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
7pub struct ProgressProblem {
8    pub kind: String,
9    pub active: bool,
10    pub message: String,
11}
12
13impl From<wire::ProgressProblem> for ProgressProblem {
14    fn from(problem: wire::ProgressProblem) -> Self {
15        Self {
16            kind: problem.kind,
17            active: problem.active,
18            message: problem.message,
19        }
20    }
21}
22
23#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
24pub struct DetectedProblem {
25    pub id: String,
26    pub severity: String,
27    pub title: String,
28    pub detail: String,
29    pub count: Option<Number>,
30}
31
32impl From<wire::DetectedProblem> for DetectedProblem {
33    fn from(problem: wire::DetectedProblem) -> Self {
34        Self {
35            id: problem.id,
36            severity: problem.severity,
37            title: problem.title,
38            detail: problem.detail,
39            count: problem.count,
40        }
41    }
42}
43
44#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
45pub struct NewProblemCount {
46    pub stream: String,
47    pub count: u64,
48}
49
50impl From<wire::NewProblemCount> for NewProblemCount {
51    fn from(problem: wire::NewProblemCount) -> Self {
52        Self {
53            stream: problem.stream,
54            count: problem.count,
55        }
56    }
57}
58
59#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
60pub struct ProblemsOverview {
61    pub any_in_progress: bool,
62    pub in_progress: Vec<ProgressProblem>,
63    pub any_problems: bool,
64    pub problems: Vec<DetectedProblem>,
65    pub new_problems: Vec<NewProblemCount>,
66    pub streams: Vec<String>,
67}
68
69impl From<wire::ProblemsOverview> for ProblemsOverview {
70    fn from(overview: wire::ProblemsOverview) -> Self {
71        Self {
72            any_in_progress: overview.any_in_progress,
73            in_progress: overview.in_progress.into_iter().map(Into::into).collect(),
74            any_problems: overview.any_problems,
75            problems: overview.problems.into_iter().map(Into::into).collect(),
76            new_problems: overview.new_problems.into_iter().map(Into::into).collect(),
77            streams: overview.streams,
78        }
79    }
80}
81
82#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
83pub struct EventActor {
84    pub kind: String,
85    pub value: String,
86    pub count: Number,
87    pub at: String,
88    pub family: Option<String>,
89}
90
91impl From<wire::EventActor> for EventActor {
92    fn from(actor: wire::EventActor) -> Self {
93        Self {
94            kind: actor.kind,
95            value: actor.value,
96            count: actor.count,
97            at: actor.at,
98            family: actor.family,
99        }
100    }
101}
102
103#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
104pub struct EventLocation {
105    pub country: Option<String>,
106    pub city: Option<String>,
107    pub region: Option<String>,
108    pub latitude: Option<Number>,
109    pub longitude: Option<Number>,
110    pub via: Option<String>,
111}
112
113impl From<wire::EventLocation> for EventLocation {
114    fn from(location: wire::EventLocation) -> Self {
115        Self {
116            country: location.country,
117            city: location.city,
118            region: location.region,
119            latitude: location.latitude,
120            longitude: location.longitude,
121            via: location.via,
122        }
123    }
124}
125
126#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
127pub struct ProblemEvent {
128    pub event_id: String,
129    pub stream: String,
130    pub at: String,
131    pub severity: Option<String>,
132    pub category: Option<String>,
133    pub bleed: Option<String>,
134    pub action: Option<String>,
135    pub source: Option<String>,
136    pub cwe: Option<String>,
137    pub user_id: Option<String>,
138    pub username: Option<String>,
139    pub ip: Option<String>,
140    pub ipv4: Option<String>,
141    pub ipv6: Option<String>,
142    pub location: Option<EventLocation>,
143    pub count: Option<Number>,
144    pub first_at: Option<String>,
145    pub actors: Option<BTreeMap<String, EventActor>>,
146    pub actors_overflow: Option<Number>,
147    pub detail: Option<String>,
148    pub event_type: Option<String>,
149    pub db: Option<String>,
150    pub kind: Option<String>,
151    pub message: Option<String>,
152    pub api: Option<String>,
153    pub api_user_id: Option<String>,
154}
155
156impl From<wire::ProblemEvent> for ProblemEvent {
157    fn from(event: wire::ProblemEvent) -> Self {
158        Self {
159            event_id: event.event_id,
160            stream: event.stream,
161            at: event.at,
162            severity: event.severity,
163            category: event.category,
164            bleed: event.bleed,
165            action: event.action,
166            source: event.source,
167            cwe: event.cwe,
168            user_id: event.user_id,
169            username: event.username,
170            ip: event.ip,
171            ipv4: event.ipv4,
172            ipv6: event.ipv6,
173            location: event.location.map(Into::into),
174            count: event.count,
175            first_at: event.first_at,
176            actors: event.actors.map(|actors| {
177                actors
178                    .into_iter()
179                    .map(|(key, actor)| (key, actor.into()))
180                    .collect()
181            }),
182            actors_overflow: event.actors_overflow,
183            detail: event.detail,
184            event_type: event.event_type,
185            db: event.db,
186            kind: event.kind,
187            message: event.message,
188            api: event.api,
189            api_user_id: event.api_user_id,
190        }
191    }
192}
193
194#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
195pub struct ProblemPage {
196    pub stream: String,
197    pub total: u64,
198    pub limit: u64,
199    pub skip: u64,
200    pub rows: Vec<ProblemEvent>,
201}
202
203impl From<wire::ProblemPage> for ProblemPage {
204    fn from(page: wire::ProblemPage) -> Self {
205        Self {
206            stream: page.stream,
207            total: page.total,
208            limit: page.limit,
209            skip: page.skip,
210            rows: page.rows.into_iter().map(Into::into).collect(),
211        }
212    }
213}
214
215#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
216pub struct ProblemAcknowledgment {
217    pub acknowledged: String,
218}
219
220impl From<wire::ProblemAcknowledgment> for ProblemAcknowledgment {
221    fn from(response: wire::ProblemAcknowledgment) -> Self {
222        Self {
223            acknowledged: response.acknowledged,
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use serde_json::json;
232
233    #[test]
234    fn overview_and_acknowledgment_keep_stable_output_names_and_value_types() {
235        let response: wire::ProblemsOverview = serde_json::from_value(json!({
236            "anyInProgress":true,
237            "inProgress":[{"kind":"schema","active":false,"message":"Checking"}],
238            "anyProblems":true,
239            "problems":[
240                {"id":"p1","severity":"warning","title":"Fractional","detail":"Details","count":1.5},
241                {"id":"p2","severity":"info","title":"Uncounted","detail":"Details"}
242            ],
243            "newProblems":[{"stream":"integrity","count":0}],
244            "streams":["integrity"]
245        }))
246        .unwrap();
247        assert_eq!(
248            serde_json::to_value(ProblemsOverview::from(response)).unwrap(),
249            json!({
250                "any_in_progress":true,
251                "in_progress":[{"kind":"schema","active":false,"message":"Checking"}],
252                "any_problems":true,
253                "problems":[
254                    {"id":"p1","severity":"warning","title":"Fractional","detail":"Details","count":1.5},
255                    {"id":"p2","severity":"info","title":"Uncounted","detail":"Details","count":null}
256                ],
257                "new_problems":[{"stream":"integrity","count":0}],
258                "streams":["integrity"]
259            })
260        );
261        assert_eq!(
262            serde_json::to_value(ProblemAcknowledgment::from(wire::ProblemAcknowledgment {
263                acknowledged: "integrity".into(),
264            }))
265            .unwrap(),
266            json!({"acknowledged":"integrity"})
267        );
268    }
269
270    #[test]
271    fn stream_results_preserve_event_names_nested_maps_numbers_and_nulls() {
272        let at = "2026-10-21T08:00:00Z";
273        let response: wire::ProblemPage = serde_json::from_value(json!({
274            "stream":"security","total":2,"limit":50,"skip":0,
275            "rows":[{
276                "_id":"event1","stream":"security","at":at,
277                "severity":"warning","category":"auth","bleed":"none","action":"read",
278                "source":"api","cwe":"CWE-1","userId":"user1","username":"alice",
279                "ip":"127.0.0.1","ipv4":"127.0.0.1","ipv6":"::1",
280                "location":{"country":"KE","city":"Nairobi","latitude":-1.5,"longitude":36,"via":"fixture"},
281                "count":2,"firstAt":at,
282                "actors":{"actor1":{"kind":"user","value":"user1","count":1.25,"at":at}},
283                "actorsOverflow":0,"detail":"Details","type":"attempt","db":"wekan",
284                "kind":"access","message":"Message","api":"/api/user","apiUserId":"user1"
285            },{"_id":"event2","stream":"security","at":at}]
286        }))
287        .unwrap();
288        let mut absent_event = json!({"event_id":"event2","stream":"security","at":at});
289        for field in [
290            "severity",
291            "category",
292            "bleed",
293            "action",
294            "source",
295            "cwe",
296            "user_id",
297            "username",
298            "ip",
299            "ipv4",
300            "ipv6",
301            "location",
302            "count",
303            "first_at",
304            "actors",
305            "actors_overflow",
306            "detail",
307            "event_type",
308            "db",
309            "kind",
310            "message",
311            "api",
312            "api_user_id",
313        ] {
314            absent_event[field] = serde_json::Value::Null;
315        }
316        assert_eq!(
317            serde_json::to_value(ProblemPage::from(response)).unwrap(),
318            json!({
319                "stream":"security","total":2,"limit":50,"skip":0,
320                "rows":[{
321                    "event_id":"event1","stream":"security","at":at,
322                    "severity":"warning","category":"auth","bleed":"none","action":"read",
323                    "source":"api","cwe":"CWE-1","user_id":"user1","username":"alice",
324                    "ip":"127.0.0.1","ipv4":"127.0.0.1","ipv6":"::1",
325                    "location":{"country":"KE","city":"Nairobi","region":null,"latitude":-1.5,"longitude":36,"via":"fixture"},
326                    "count":2,"first_at":at,
327                    "actors":{"actor1":{"kind":"user","value":"user1","count":1.25,"at":at,"family":null}},
328                    "actors_overflow":0,"detail":"Details","event_type":"attempt","db":"wekan",
329                    "kind":"access","message":"Message","api":"/api/user","api_user_id":"user1"
330                },absent_event]
331            })
332        );
333    }
334}