1use serde::Serialize;
11
12use crate::client::GatewayApi;
13use crate::client::query::ListQuery;
14use crate::client::sessions::{DesignerInfo, PerspectiveSession, VisionClient};
15use crate::error::CoreError;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum SessionType {
23 Designer,
25 Perspective,
27 Vision,
29}
30
31impl SessionType {
32 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::Designer => "designer",
36 Self::Perspective => "perspective",
37 Self::Vision => "vision",
38 }
39 }
40}
41
42impl std::fmt::Display for SessionType {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.write_str(self.as_str())
45 }
46}
47
48#[derive(Debug, Serialize)]
50pub struct SessionsResult {
51 pub designers: Vec<DesignerInfo>,
53 pub perspective: Vec<PerspectiveSession>,
55 pub vision: Vec<VisionClient>,
57}
58
59#[derive(Debug, Serialize)]
61pub struct TerminateResult {
62 pub kind: SessionType,
64 pub id: String,
66}
67
68pub async fn sessions(
72 api: &dyn GatewayApi,
73 type_filter: Option<SessionType>,
74) -> Result<SessionsResult, CoreError> {
75 let query = ListQuery::default();
76 let (designers, perspective, vision) = match type_filter {
77 None => (
78 api.designers(&query).await?.items,
79 api.perspective_sessions(&query).await?.items,
80 api.vision_clients(&query).await?.items,
81 ),
82 Some(SessionType::Designer) => (api.designers(&query).await?.items, Vec::new(), Vec::new()),
83 Some(SessionType::Perspective) => (
84 Vec::new(),
85 api.perspective_sessions(&query).await?.items,
86 Vec::new(),
87 ),
88 Some(SessionType::Vision) => (
89 Vec::new(),
90 Vec::new(),
91 api.vision_clients(&query).await?.items,
92 ),
93 };
94 Ok(SessionsResult {
95 designers,
96 perspective,
97 vision,
98 })
99}
100
101pub async fn terminate_session(
107 api: &dyn GatewayApi,
108 kind: SessionType,
109 id: &str,
110 message: Option<&str>,
111) -> Result<TerminateResult, CoreError> {
112 match kind {
113 SessionType::Designer => api.prune_designer(id).await?,
114 SessionType::Perspective => api.terminate_perspective_session(id, message).await?,
115 SessionType::Vision => api.terminate_vision_client(id).await?,
116 }
117 Ok(TerminateResult {
118 kind,
119 id: id.to_string(),
120 })
121}
122
123#[cfg(test)]
124mod tests {
125 use super::{SessionType, TerminateResult, sessions, terminate_session};
126 use crate::client::GatewayApi;
127 use crate::client::query::{ListEnvelope, ListMetadata};
128 use crate::client::sessions::{DesignerInfo, PerspectiveSession, VisionClient};
129 use crate::error::CoreError;
130
131 use std::sync::Mutex;
132
133 #[derive(Default)]
136 struct SessionsRig {
137 list_calls: Mutex<Vec<&'static str>>,
138 terminates: Mutex<Vec<(&'static str, String, Option<String>)>>,
139 }
140
141 fn designer(id: &str) -> DesignerInfo {
142 DesignerInfo {
143 id: id.into(),
144 address: "192.168.1.50:52526".into(),
145 user: "admin".into(),
146 project: "MyProject".into(),
147 memory: serde_json::json!({"used": 1}),
148 uptime: 600000,
149 lastcomm: 1787346747022,
150 timeout: 3600000,
151 timezone: "America/New_York".into(),
152 extra: Default::default(),
153 }
154 }
155
156 fn perspective(id: &str) -> PerspectiveSession {
157 PerspectiveSession {
158 id: id.into(),
159 username: "admin".into(),
160 authorized: true,
161 project: "MyProject".into(),
162 client_address: "10.0.0.5".into(),
163 last_comm: 1787346747022,
164 active_pages: 1,
165 user_agent: "Mozilla/5.0".into(),
166 extra: Default::default(),
167 }
168 }
169
170 fn vision(id: &str) -> VisionClient {
171 VisionClient {
172 id: id.into(),
173 address: "10.0.0.9:443".into(),
174 user: "operator".into(),
175 project: "PlantFloor".into(),
176 memory: serde_json::json!({"used": 1}),
177 uptime: 120000,
178 lastcomm: 1787346747022,
179 timeout: 3600000,
180 timezone: "UTC".into(),
181 tag_count: 1523,
182 extra: Default::default(),
183 }
184 }
185
186 fn page<T>(items: Vec<T>) -> ListEnvelope<T> {
187 ListEnvelope {
188 items,
189 metadata: ListMetadata {
190 total: 1,
191 matching: 1,
192 limit: -1,
193 offset: 0,
194 },
195 }
196 }
197
198 #[async_trait::async_trait]
199 impl GatewayApi for SessionsRig {
200 async fn bundle_generate(
201 &self,
202 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
203 unreachable!("not part of this action")
204 }
205 async fn bundle_status(
206 &self,
207 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
208 unreachable!("not part of this action")
209 }
210 async fn bundle_download(
211 &self,
212 _out: &std::path::Path,
213 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
214 unreachable!("not part of this action")
215 }
216 async fn tag_provider_list(
217 &self,
218 _query: &crate::client::query::ListQuery,
219 ) -> Result<
220 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
221 CoreError,
222 > {
223 unreachable!("not part of this action")
224 }
225 async fn tag_provider_find(
226 &self,
227 _name: &str,
228 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
229 unreachable!("not part of this action")
230 }
231 async fn tag_provider_create(
232 &self,
233 _body: &[crate::client::tags::TagProviderCreate],
234 ) -> Result<(), CoreError> {
235 unreachable!("not part of this action")
236 }
237 async fn tag_provider_delete(
238 &self,
239 _name: &str,
240 _signature: &str,
241 ) -> Result<(), CoreError> {
242 unreachable!("not part of this action")
243 }
244 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
245 unreachable!("not part of this action")
246 }
247 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
248 unreachable!("not part of this action")
249 }
250 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
251 unreachable!("not part of this action")
252 }
253 async fn backup_download(
254 &self,
255 _out: &std::path::Path,
256 _backup_type: crate::client::backup::BackupType,
257 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
258 unreachable!("not part of this action")
259 }
260 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
261 unreachable!("not part of this action")
262 }
263 async fn eam_task_history(
264 &self,
265 _limit: Option<u32>,
266 _search: Option<&str>,
267 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
268 {
269 unreachable!("not part of this action")
270 }
271 async fn eam_task_definitions(
272 &self,
273 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
274 {
275 unreachable!("not part of this action")
276 }
277 async fn eam_task_find(
278 &self,
279 _name: &str,
280 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
281 unreachable!("not part of this action")
282 }
283 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
284 unreachable!("not part of this action")
285 }
286 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
287 unreachable!("not part of this action")
288 }
289 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
290 unreachable!("not part of this action")
291 }
292 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
293 unreachable!("not part of this action")
294 }
295 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
296 unreachable!("not part of this action")
297 }
298 async fn eam_tasks_scheduled(
299 &self,
300 _running: bool,
301 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
302 unreachable!("not part of this action")
303 }
304 async fn eam_task_modify(
305 &self,
306 _definition: &serde_json::Value,
307 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
308 unreachable!("not part of this action")
309 }
310 async fn eam_task_delete(
311 &self,
312 _name: &str,
313 _signature: &str,
314 _confirm: bool,
315 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
316 unreachable!("not part of this action")
317 }
318 async fn api_call(
319 &self,
320 _call: &crate::client::apicall::ApiCallRequest,
321 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
322 unreachable!("not part of this action")
323 }
324 async fn license_status(
325 &self,
326 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
327 unreachable!("not part of this action")
328 }
329 async fn redundancy_status(
330 &self,
331 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
332 unreachable!("not part of this action")
333 }
334 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
335 unreachable!("not part of this action")
336 }
337 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
338 unreachable!("not part of this action")
339 }
340 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
341 unreachable!("not part of this action")
342 }
343 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
344 unreachable!("not part of this action")
345 }
346 async fn modules(
347 &self,
348 _quarantined: bool,
349 _query: &crate::client::query::ListQuery,
350 ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
351 unreachable!("not part of this action")
352 }
353 async fn metrics_current(
354 &self,
355 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
356 unreachable!("not part of this action")
357 }
358 async fn metrics_historic(
359 &self,
360 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
361 unreachable!("not part of this action")
362 }
363 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
364 unreachable!("not part of this action")
365 }
366 async fn designers(
367 &self,
368 _query: &crate::client::query::ListQuery,
369 ) -> Result<ListEnvelope<DesignerInfo>, CoreError> {
370 self.list_calls.lock().unwrap().push("designers");
371 Ok(page(vec![designer("d-1")]))
372 }
373 async fn perspective_sessions(
374 &self,
375 _query: &crate::client::query::ListQuery,
376 ) -> Result<ListEnvelope<PerspectiveSession>, CoreError> {
377 self.list_calls.lock().unwrap().push("perspective");
378 Ok(page(vec![perspective("psess-1")]))
379 }
380 async fn vision_clients(
381 &self,
382 _query: &crate::client::query::ListQuery,
383 ) -> Result<ListEnvelope<VisionClient>, CoreError> {
384 self.list_calls.lock().unwrap().push("vision");
385 Ok(page(vec![vision("v-1")]))
386 }
387 async fn terminate_perspective_session(
388 &self,
389 id: &str,
390 message: Option<&str>,
391 ) -> Result<(), CoreError> {
392 self.terminates.lock().unwrap().push((
393 "perspective",
394 id.into(),
395 message.map(str::to_string),
396 ));
397 Ok(())
398 }
399 async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError> {
400 self.terminates
401 .lock()
402 .unwrap()
403 .push(("vision", id.into(), None));
404 Ok(())
405 }
406 async fn prune_designer(&self, id: &str) -> Result<(), CoreError> {
407 self.terminates
408 .lock()
409 .unwrap()
410 .push(("designer", id.into(), None));
411 Ok(())
412 }
413 async fn database_connections(
414 &self,
415 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
416 {
417 unreachable!("not part of this action")
418 }
419 async fn opc_connections(
420 &self,
421 ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
422 {
423 unreachable!("not part of this action")
424 }
425
426 async fn logs(
427 &self,
428 _filter: &crate::client::logs::LogQuery,
429 ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
430 unreachable!("not part of this double's actions")
431 }
432 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
433 unreachable!("not part of this double's actions")
434 }
435 async fn loggers(
436 &self,
437 _query: &crate::client::query::ListQuery,
438 ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
439 unreachable!("not part of this double's actions")
440 }
441 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
442 unreachable!("not part of this double's actions")
443 }
444 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
445 unreachable!("not part of this double's actions")
446 }
447 async fn restart(&self) -> Result<(), CoreError> {
448 unreachable!("not part of this action")
449 }
450 async fn scan_projects(&self) -> Result<(), CoreError> {
451 unreachable!("not part of this action")
452 }
453 async fn security_properties(
454 &self,
455 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
456 unreachable!("not part of this action")
457 }
458 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
459 unreachable!("not part of this action")
460 }
461 async fn webdev_route_call(
462 &self,
463 _project: &str,
464 _route: &str,
465 _body: &serde_json::Value,
466 _extra_headers: &[(&str, &str)],
467 ) -> Result<serde_json::Value, CoreError> {
468 unreachable!("not part of this action")
469 }
470 async fn webdev_route_probe(
471 &self,
472 _project: &str,
473 _route: &str,
474 _extra_headers: &[(&str, &str)],
475 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
476 unreachable!("not part of this action")
477 }
478 async fn projects(
479 &self,
480 _query: &crate::client::query::ListQuery,
481 ) -> Result<
482 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
483 CoreError,
484 > {
485 unreachable!("not part of this action")
486 }
487 async fn project_find(
488 &self,
489 _name: &str,
490 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
491 unreachable!("not part of this action")
492 }
493 async fn project_create(
494 &self,
495 _body: &crate::client::projects::ProjectCreate,
496 ) -> Result<(), CoreError> {
497 unreachable!("not part of this action")
498 }
499 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
500 unreachable!("not part of this action")
501 }
502 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
503 unreachable!("not part of this action")
504 }
505 async fn project_modify(
506 &self,
507 _name: &str,
508 _body: &crate::client::projects::ProjectModify,
509 ) -> Result<(), CoreError> {
510 unreachable!("not part of this action")
511 }
512 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
513 unreachable!("not part of this action")
514 }
515 async fn project_export_to_file(
516 &self,
517 _name: &str,
518 _out: &std::path::Path,
519 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
520 unreachable!("not part of this action")
521 }
522 async fn project_import(
523 &self,
524 _name: &str,
525 _zip: Vec<u8>,
526 _overwrite: bool,
527 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
528 unreachable!("not part of this action")
529 }
530 }
531
532 #[tokio::test]
536 async fn sessions_filter_calls_only_the_requested_family() {
537 let rig = SessionsRig::default();
538 let merged = sessions(&rig, None).await.expect("merged list");
539 assert_eq!(merged.designers.len(), 1);
540 assert_eq!(merged.perspective.len(), 1);
541 assert_eq!(merged.vision.len(), 1);
542 assert_eq!(
543 *rig.list_calls.lock().unwrap(),
544 vec!["designers", "perspective", "vision"]
545 );
546
547 let rig = SessionsRig::default();
548 let filtered = sessions(&rig, Some(SessionType::Perspective))
549 .await
550 .expect("filtered list");
551 assert!(filtered.designers.is_empty(), "excluded key stays present");
552 assert_eq!(filtered.perspective.len(), 1);
553 assert!(filtered.vision.is_empty());
554 assert_eq!(
555 *rig.list_calls.lock().unwrap(),
556 vec!["perspective"],
557 "no round-trips for excluded families"
558 );
559
560 let json = serde_json::to_value(&filtered).expect("serialize");
562 let mut keys: Vec<&str> = json
563 .as_object()
564 .unwrap()
565 .keys()
566 .map(String::as_str)
567 .collect();
568 keys.sort_unstable();
569 assert_eq!(keys, ["designers", "perspective", "vision"]);
570 }
571
572 #[tokio::test]
576 async fn terminate_maps_each_kind_to_its_endpoint() {
577 let rig = SessionsRig::default();
578 let result: TerminateResult =
579 terminate_session(&rig, SessionType::Perspective, "psess-1", Some("bye"))
580 .await
581 .expect("perspective terminates");
582 assert_eq!(result.kind, SessionType::Perspective);
583 assert_eq!(result.id, "psess-1");
584 assert_eq!(
585 serde_json::to_value(&result).unwrap()["kind"],
586 "perspective",
587 "kind serializes kebab-case"
588 );
589
590 terminate_session(&rig, SessionType::Designer, "d-1", Some("ignored"))
591 .await
592 .expect("designer prunes (message not applicable)");
593 terminate_session(&rig, SessionType::Vision, "v-1", None)
594 .await
595 .expect("vision terminates");
596 assert_eq!(
597 *rig.terminates.lock().unwrap(),
598 vec![
599 ("perspective", "psess-1".into(), Some("bye".into())),
600 ("designer", "d-1".into(), None),
601 ("vision", "v-1".into(), None),
602 ]
603 );
604 }
605}