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