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}