1#![allow(clippy::must_use_candidate)]
2#![cfg_attr(docsrs, feature(doc_auto_cfg))]
3
4#[cfg(any(
5 all(feature = "ssr", feature = "hydrate", not(feature = "docs-only")),
6 not(any(feature = "ssr", feature = "hydrate"))
7))]
8compile_error!("exactly one of the features \"ssr\" or \"hydrate\" must be enabled");
9
10#[cfg(feature = "ssr")]
11use flams_router_base::LoginState;
12use flams_router_base::require_login;
13use flams_router_base::ws;
14use flams_utils::logs::{LogFileLine, LogLevel, LogMessage, LogTree};
15use flams_utils::time::Timestamp;
16use flams_utils::vecmap::VecMap;
17use flams_web_utils::{
18 components::{Header, LazySubtree, Leaf, Spinner, Tree},
19 inject_css,
20};
21use leptos::{either::Either, prelude::*};
22use std::num::NonZeroU64;
23use thaw::Caption1Strong;
24
25#[cfg(feature = "ssr")]
26async fn full_log() -> Result<flams_utils::logs::LogTree, ()> {
27 use tokio::io::AsyncBufReadExt;
28
29 let path = flams_system::logging::logger().log_file();
30
31 let reader = tokio::io::BufReader::new(tokio::fs::File::open(path).await.map_err(|_| ())?);
32 let mut lines = reader.lines();
33 let mut tree = flams_utils::logs::LogTree::default();
34 while let Ok(Some(line)) = lines.next_line().await {
35 if !line.is_empty() {
36 if let Ok(line) = serde_json::from_str(&line) {
37 tree.add_line(line);
38 }
39 }
40 }
41
42 Ok(tree)
43}
44
45#[component]
46pub fn Logger() -> impl IntoView {
47 require_login(|| {
48 inject_css("flams-logging", include_str!("logs.css"));
49 let signals = LogSignals {
50 top: RwSignal::new(Vec::new()),
51 open_span_paths: RwSignal::new(VecMap::default()),
52 warnings: RwSignal::new(Vec::new()),
53 };
54 Effect::new(move |_| {
55 #[cfg(feature = "hydrate")]
56 {
57 use ws::WebSocketClient;
58 let _ = LogSocket::start(move |msg| {
59 LogSocket::ws(signals, msg);
60 None
61 });
62 }
63 });
64 view! {
65 <div class="flams-log-frame">{ move || {
66 if signals.top.with(Vec::is_empty) {
67 Either::Left(view!(<div class="flams-spinner-frame"><Spinner/></div>))
68 } else {Either::Right(view!{<Tree>
69 {do_ls(signals.top)}
70 </Tree>})}
71 }}</div>
72 <div class="flams-warn-frame">
73 <Caption1Strong><span style="color:var(--colorPaletteRedForeground1)">"Warnings"</span></Caption1Strong>{ move || {
74 if signals.top.with(Vec::is_empty) {
75 Either::Left(view!(<div class="flams-spinner-frame"><Spinner/></div>))
76 } else {Either::Right(view!{<Tree>
77 <For each=move || signals.warnings.get() key=|e| e.0 children=move |e| view!(
78 <Leaf><LogLine e=e.1/></Leaf>
79 )/>
80 </Tree>})}
81 }}</div>
82 }
83 })
84}
85
86fn do_ls(v: RwSignal<Vec<LogEntrySignal>>) -> impl IntoView {
87 view! {
88 <For each=move || v.get() key=|e| e.id().to_string() children=|e| {
89 match e {
90 LogEntrySignal::Simple(_,e) => view!(<Leaf><span class="flams-log-elem"><LogLine e/></span></Leaf>).into_any(),
91 LogEntrySignal::Span(_,e) => do_span(e).into_any()
92 }
93 }/>
94 }
95}
96fn do_span(s: SpanSignal) -> impl IntoView {
97 let children = s.children;
98 view! {<LazySubtree>
99 <Header slot>{move || {let s = s.clone(); match s.message.get() {
100 SpanMessage::Open {name,timestamp} => view!(<LogLineHelper message=name timestamp target=s.target level=s.level args=s.args spinner=true/>),
101 SpanMessage::Closed(message) => view!(<LogLineHelper message target=s.target level=s.level args=s.args spinner=false />)
102 }}}</Header>
103 {move || do_ls(children)}
104 </LazySubtree>}
105}
106
107#[component]
108fn LogLine(e: LogMessage) -> impl IntoView {
109 let LogMessage {
110 message,
111 timestamp,
112 target,
113 level,
114 args,
115 } = e;
116 view!(<LogLineHelper message timestamp target level args/>)
117}
118
119#[component]
120fn LogLineHelper(
121 message: String,
122 #[prop(optional)] timestamp: Option<Timestamp>,
123 target: Option<String>,
124 level: LogLevel,
125 args: VecMap<String, String>,
126 #[prop(optional)] spinner: bool,
127) -> impl IntoView {
128 use flams_web_utils::components::SpinnerSize;
129 use std::fmt::Write;
130 let cls = class_from_level(level);
131 let mut str = timestamp.map_or_else(
132 || format!("<{level}> "),
133 |timestamp| format!("{timestamp} <{level}> "),
134 );
135 if let Some(target) = target {
136 write!(str, "[{target}] ").unwrap();
137 }
138 str.push_str(&message);
139 if !args.is_empty() {
140 str.push_str(" (");
141 for (k, v) in args {
142 write!(str, "{k}:{v} ").unwrap();
143 }
144 str.push(')');
145 }
146 if spinner {
147 Either::Left(view!(<span class=cls>
148 <span class="flams-spinner-inline">
149 <Spinner size=SpinnerSize::Tiny/>
150 </span>{str}
151 </span>))
152 } else {
153 Either::Right(view!(<span class=cls>{str}</span>))
154 }
155}
156
157const fn class_from_level(lvl: LogLevel) -> &'static str {
158 match lvl {
159 LogLevel::ERROR => "flams-log-error",
160 LogLevel::WARN => "flams-log-warn",
161 LogLevel::INFO => "flams-log-info",
162 LogLevel::DEBUG => "flams-log-debug",
163 LogLevel::TRACE => "flams-log-trace",
164 }
165}
166pub struct LogSocket {
167 #[cfg(feature = "ssr")]
168 listener: flams_utils::change_listener::ChangeListener<LogFileLine>,
169 #[cfg(all(feature = "hydrate", not(feature = "docs-only")))]
170 socket: leptos::web_sys::WebSocket,
171 #[cfg(all(feature = "hydrate", feature = "docs-only"))]
172 socket: (),
173}
174
175impl ws::WebSocket<(), Log> for LogSocket {
177 const SERVER_ENDPOINT: &'static str = "/ws/log";
178}
179
180#[cfg(feature = "ssr")]
181#[async_trait::async_trait]
182impl ws::WebSocketServer<(), Log> for LogSocket {
183 async fn new(account: LoginState, _db: ws::DBBackend) -> Option<Self> {
184 match account {
185 LoginState::Admin
186 | LoginState::NoAccounts
187 | LoginState::User { is_admin: true, .. } => {
188 let listener = flams_system::logging::logger().listener();
189 Some(Self {
190 listener,
191 #[cfg(feature = "hydrate")]
192 socket: unreachable!(),
193 })
194 }
195 _ => None,
196 }
197 }
198 async fn next(&mut self) -> Option<Log> {
199 self.listener.read().await.map(Log::Update)
200 }
201 async fn handle_message(&mut self, _msg: ()) -> Option<Log> {
202 None
203 }
204 async fn on_start(&mut self, socket: &mut ws::AxumWS) {
205 if let Ok(init) = full_log().await {
206 let _ = socket
207 .send(ws::WSMessage::Text({
208 let Ok(s) = serde_json::to_string(&Log::Initial(init)) else {
209 return;
210 };
211 s.into()
212 }))
213 .await;
214 }
215 }
216}
217
218#[cfg(feature = "hydrate")]
219impl ws::WebSocketClient<(), Log> for LogSocket {
220 fn new(ws: leptos::web_sys::WebSocket) -> Self {
221 Self {
222 #[cfg(not(feature = "docs-only"))]
223 socket: ws,
224 #[cfg(feature = "docs-only")]
225 socket: (),
226 #[cfg(feature = "ssr")]
227 listener: unreachable!(),
228 }
229 }
230 fn socket(&mut self) -> &mut leptos::web_sys::WebSocket {
231 #[cfg(not(feature = "docs-only"))]
232 {
233 &mut self.socket
234 }
235 #[cfg(feature = "docs-only")]
236 {
237 unreachable!()
238 }
239 }
240}
241
242#[cfg(feature = "hydrate")]
243impl LogSocket {
244 fn ws(signals: LogSignals, l: Log) {
245 match l {
246 Log::Initial(tree) => Self::populate(signals, tree),
247 Log::Update(up) => Self::update(signals, up),
248 }
249 }
250
251 fn convert(
252 e: flams_utils::logs::LogTreeElem,
253 warnings: &mut Vec<(NonZeroU64, LogMessage)>,
254 ) -> LogEntrySignal {
255 use flams_utils::logs::{LogSpan, LogTreeElem};
256 match e {
257 LogTreeElem::Message(e) => {
258 let id = next_id();
259 if e.level >= LogLevel::WARN {
260 warnings.push((id, e.clone()));
261 }
262 LogEntrySignal::Simple(next_id(), e)
263 }
264 LogTreeElem::Span(e @ LogSpan { closed: None, .. }) => LogEntrySignal::Span(
265 next_id(),
266 SpanSignal {
267 message: RwSignal::new(SpanMessage::Open {
268 name: e.name,
269 timestamp: e.timestamp,
270 }),
271 target: e.target,
272 level: e.level,
273 args: e.args,
274 children: RwSignal::new(
275 e.children
276 .into_iter()
277 .map(|e| Self::convert(e, warnings))
278 .collect(),
279 ),
280 },
281 ),
282 LogTreeElem::Span(e) => {
283 let closed = e.closed.unwrap_or_else(|| unreachable!());
284 LogEntrySignal::Span(
285 next_id(),
286 SpanSignal {
287 message: RwSignal::new(SpanMessage::Closed(format!(
288 "{} (finished after {})",
289 e.name,
290 closed.since(e.timestamp)
291 ))),
292 target: e.target,
293 level: e.level,
294 args: e.args,
295 children: RwSignal::new(
296 e.children
297 .into_iter()
298 .map(|e| Self::convert(e, warnings))
299 .collect(),
300 ),
301 },
302 )
303 }
304 }
305 }
306
307 fn populate(signals: LogSignals, tree: LogTree) {
308 signals
309 .open_span_paths
310 .try_update_untracked(|v| *v = tree.open_span_paths);
311 signals.warnings.try_update(|ws| {
312 signals.top.try_update(|v| {
313 *v = tree
314 .children
315 .into_iter()
316 .map(|e| Self::convert(e, ws))
317 .collect();
318 })
319 });
320 }
321 fn update(signals: LogSignals, update: LogFileLine) {
322 let (msg, parent, is_span) = match update {
323 LogFileLine::Message {
324 message,
325 timestamp,
326 target,
327 level,
328 args,
329 span,
330 } => {
331 let id = next_id();
332 let message = LogMessage {
333 message,
334 timestamp,
335 target,
336 level,
337 args,
338 };
339 if level >= LogLevel::WARN {
340 signals
341 .warnings
342 .try_update(|v| v.push((id, message.clone())));
343 }
344 (LogEntrySignal::Simple(id, message), span, None)
345 }
346 LogFileLine::SpanOpen {
347 name,
348 id,
349 timestamp,
350 target,
351 level,
352 args,
353 parent,
354 } => {
355 let span = SpanSignal {
356 message: RwSignal::new(SpanMessage::Open { name, timestamp }),
357 target,
358 level,
359 args,
360 children: RwSignal::new(Vec::new()),
361 };
362 (LogEntrySignal::Span(next_id(), span), parent, Some(id))
363 }
364 LogFileLine::SpanClose { timestamp, id, .. } => {
365 signals.close(id, timestamp);
366 return;
367 }
368 };
369 signals.merge(msg, parent, is_span);
370 }
371}
372
373#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
374pub enum Log {
375 Initial(LogTree),
376 Update(LogFileLine),
377}
378
379#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize)]
380struct LogSignals {
381 top: RwSignal<Vec<LogEntrySignal>>,
382 open_span_paths: RwSignal<VecMap<NonZeroU64, Vec<usize>>>,
383 warnings: RwSignal<Vec<(NonZeroU64, LogMessage)>>,
384}
385
386#[cfg(feature = "hydrate")]
387impl LogSignals {
388 fn close(&self, id: NonZeroU64, timestamp: Timestamp) {
389 if let Some(path) = self
390 .open_span_paths
391 .try_update_untracked(|v| v.remove(&id))
392 .flatten()
393 {
394 Self::close_i(self.top, &path, timestamp);
395 };
396 }
397 fn close_i(sigs: RwSignal<Vec<LogEntrySignal>>, path: &[usize], timestamp: Timestamp) {
398 if path.is_empty() {
399 return;
400 }
401 let i = path[0];
402 let path = &path[1..];
403
404 sigs.try_with_untracked(|v| {
405 if let Some(LogEntrySignal::Span(_, s)) = v.get(i) {
406 if path.is_empty() {
407 s.message.try_update(|m| {
408 if let SpanMessage::Open {
409 name,
410 timestamp: old,
411 } = &m
412 {
413 let msg =
414 format!("{} (finished after {})", name, timestamp.since(*old));
415 *m = SpanMessage::Closed(msg);
416 }
417 });
418 } else {
419 Self::close_i(s.children, path, timestamp);
420 }
421 }
422 });
423 }
424
425 fn merge(&self, line: LogEntrySignal, parent: Option<NonZeroU64>, is_span: Option<NonZeroU64>) {
426 match parent {
427 None => {
428 if let Some(id) = is_span {
429 self.top.try_update(|ch| {
430 self.open_span_paths
431 .try_update_untracked(|v| v.insert(id, vec![ch.len()]));
432 ch.push(line);
433 });
434 } else {
435 self.top.try_update(|ch| ch.push(line));
436 }
437 }
438 Some(parent) => {
439 let mut path = Vec::new();
440 self.open_span_paths.try_update_untracked(|v| {
441 if let Some(p) = v.get(&parent) {
442 path.clone_from(p);
443 } });
447 self.merge_i(self.top, 0, path, is_span, line);
448 }
449 }
450 }
451 fn merge_i(
452 &self,
453 sigs: RwSignal<Vec<LogEntrySignal>>,
454 i: usize,
455 mut path: Vec<usize>,
456 is_span: Option<NonZeroU64>,
457 line: LogEntrySignal,
458 ) {
459 if i == path.len() {
460 return;
461 }
462 let e = path[i];
463 if path.len() == i + 1 {
464 sigs.try_update(|v| {
465 if let Some(s) = is_span {
466 path.push(v.len());
467 self.open_span_paths
468 .try_update_untracked(|v| v.insert(s, path));
469 }
470 v.push(line);
471 });
472 } else {
473 sigs.try_with_untracked(|v| {
474 let LogEntrySignal::Span(_, s) = &v[e] else {
475 unreachable!()
476 };
477 self.merge_i(s.children, i + 1, path, is_span, line);
478 });
479 }
480 }
481}
482
483#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
484enum LogEntrySignal {
485 Simple(NonZeroU64, LogMessage),
486 Span(NonZeroU64, SpanSignal),
487}
488impl LogEntrySignal {
489 #[inline]
490 const fn id(&self) -> NonZeroU64 {
491 match self {
492 Self::Simple(id, _) | Self::Span(id, _) => *id,
493 }
494 }
495}
496
497#[cfg(feature = "hydrate")]
498static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
499#[cfg(feature = "hydrate")]
500fn next_id() -> NonZeroU64 {
501 NonZeroU64::new(COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
502 .unwrap_or_else(|| unreachable!())
503}
504
505#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
506enum SpanMessage {
507 Open { name: String, timestamp: Timestamp },
508 Closed(String),
509}
510#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
511struct SpanSignal {
512 pub message: RwSignal<SpanMessage>,
513 pub target: Option<String>,
514 pub level: LogLevel,
515 pub args: VecMap<String, String>,
516 pub children: RwSignal<Vec<LogEntrySignal>>,
517}