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