story-kit: merge 292_story_show_server_logs_in_web_ui
This commit is contained in:
@@ -85,7 +85,9 @@ export type WsResponse =
|
|||||||
/** Streaming token from a /btw side question response. */
|
/** Streaming token from a /btw side question response. */
|
||||||
| { type: "side_question_token"; content: string }
|
| { type: "side_question_token"; content: string }
|
||||||
/** Final signal that the /btw side question has been fully answered. */
|
/** Final signal that the /btw side question has been fully answered. */
|
||||||
| { type: "side_question_done"; response: string };
|
| { type: "side_question_done"; response: string }
|
||||||
|
/** A single server log entry (bulk on connect, then live). */
|
||||||
|
| { type: "log_entry"; timestamp: string; level: string; message: string };
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -376,6 +378,11 @@ export class ChatWebSocket {
|
|||||||
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
private onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
private onSideQuestionToken?: (content: string) => void;
|
private onSideQuestionToken?: (content: string) => void;
|
||||||
private onSideQuestionDone?: (response: string) => void;
|
private onSideQuestionDone?: (response: string) => void;
|
||||||
|
private onLogEntry?: (
|
||||||
|
timestamp: string,
|
||||||
|
level: string,
|
||||||
|
message: string,
|
||||||
|
) => void;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private closeTimer?: number;
|
private closeTimer?: number;
|
||||||
private wsPath = DEFAULT_WS_PATH;
|
private wsPath = DEFAULT_WS_PATH;
|
||||||
@@ -461,6 +468,8 @@ export class ChatWebSocket {
|
|||||||
this.onSideQuestionToken?.(data.content);
|
this.onSideQuestionToken?.(data.content);
|
||||||
if (data.type === "side_question_done")
|
if (data.type === "side_question_done")
|
||||||
this.onSideQuestionDone?.(data.response);
|
this.onSideQuestionDone?.(data.response);
|
||||||
|
if (data.type === "log_entry")
|
||||||
|
this.onLogEntry?.(data.timestamp, data.level, data.message);
|
||||||
if (data.type === "pong") {
|
if (data.type === "pong") {
|
||||||
window.clearTimeout(this.heartbeatTimeout);
|
window.clearTimeout(this.heartbeatTimeout);
|
||||||
this.heartbeatTimeout = undefined;
|
this.heartbeatTimeout = undefined;
|
||||||
@@ -516,6 +525,7 @@ export class ChatWebSocket {
|
|||||||
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
onOnboardingStatus?: (needsOnboarding: boolean) => void;
|
||||||
onSideQuestionToken?: (content: string) => void;
|
onSideQuestionToken?: (content: string) => void;
|
||||||
onSideQuestionDone?: (response: string) => void;
|
onSideQuestionDone?: (response: string) => void;
|
||||||
|
onLogEntry?: (timestamp: string, level: string, message: string) => void;
|
||||||
},
|
},
|
||||||
wsPath = DEFAULT_WS_PATH,
|
wsPath = DEFAULT_WS_PATH,
|
||||||
) {
|
) {
|
||||||
@@ -533,6 +543,7 @@ export class ChatWebSocket {
|
|||||||
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
this.onOnboardingStatus = handlers.onOnboardingStatus;
|
||||||
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
this.onSideQuestionToken = handlers.onSideQuestionToken;
|
||||||
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
this.onSideQuestionDone = handlers.onSideQuestionDone;
|
||||||
|
this.onLogEntry = handlers.onLogEntry;
|
||||||
this.wsPath = wsPath;
|
this.wsPath = wsPath;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { ChatInput } from "./ChatInput";
|
|||||||
import { HelpOverlay } from "./HelpOverlay";
|
import { HelpOverlay } from "./HelpOverlay";
|
||||||
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
||||||
import { MessageItem } from "./MessageItem";
|
import { MessageItem } from "./MessageItem";
|
||||||
|
import type { LogEntry } from "./ServerLogsPanel";
|
||||||
|
import { ServerLogsPanel } from "./ServerLogsPanel";
|
||||||
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
import { SideQuestionOverlay } from "./SideQuestionOverlay";
|
||||||
import { StagePanel } from "./StagePanel";
|
import { StagePanel } from "./StagePanel";
|
||||||
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||||
@@ -214,6 +216,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
const [serverLogs, setServerLogs] = useState<LogEntry[]>([]);
|
||||||
// Ref so stale WebSocket callbacks can read the current queued messages
|
// Ref so stale WebSocket callbacks can read the current queued messages
|
||||||
const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]);
|
const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]);
|
||||||
const queueIdCounterRef = useRef(0);
|
const queueIdCounterRef = useRef(0);
|
||||||
@@ -402,6 +405,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
prev ? { ...prev, response, loading: false } : prev,
|
prev ? { ...prev, response, loading: false } : prev,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onLogEntry: (timestamp, level, message) => {
|
||||||
|
setServerLogs((prev) => [...prev, { timestamp, level, message }]);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1021,6 +1027,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
items={pipeline.backlog}
|
items={pipeline.backlog}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
/>
|
/>
|
||||||
|
<ServerLogsPanel logs={serverLogs} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</LozengeFlyProvider>
|
</LozengeFlyProvider>
|
||||||
|
|||||||
246
frontend/src/components/ServerLogsPanel.tsx
Normal file
246
frontend/src/components/ServerLogsPanel.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const { useCallback, useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerLogsPanelProps {
|
||||||
|
logs: LogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelColor(level: string): string {
|
||||||
|
switch (level.toUpperCase()) {
|
||||||
|
case "ERROR":
|
||||||
|
return "#e06c75";
|
||||||
|
case "WARN":
|
||||||
|
return "#e5c07b";
|
||||||
|
default:
|
||||||
|
return "#98c379";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<string>("ALL");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const userScrolledUpRef = useRef(false);
|
||||||
|
const lastScrollTopRef = useRef(0);
|
||||||
|
|
||||||
|
const filteredLogs = logs.filter((entry) => {
|
||||||
|
const matchesSeverity =
|
||||||
|
severityFilter === "ALL" || entry.level.toUpperCase() === severityFilter;
|
||||||
|
const matchesFilter =
|
||||||
|
filter === "" ||
|
||||||
|
entry.message.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
entry.timestamp.includes(filter);
|
||||||
|
return matchesSeverity && matchesFilter;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
lastScrollTopRef.current = el.scrollTop;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-scroll when new entries arrive (unless user scrolled up).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (!userScrolledUpRef.current) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [filteredLogs.length, isOpen, scrollToBottom]);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 5;
|
||||||
|
if (el.scrollTop < lastScrollTopRef.current) {
|
||||||
|
userScrolledUpRef.current = true;
|
||||||
|
}
|
||||||
|
if (isAtBottom) {
|
||||||
|
userScrolledUpRef.current = false;
|
||||||
|
}
|
||||||
|
lastScrollTopRef.current = el.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityButtons = ["ALL", "INFO", "WARN", "ERROR"] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="server-logs-panel"
|
||||||
|
style={{
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header / toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="server-logs-panel-toggle"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "#1e1e1e",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Server Logs</span>
|
||||||
|
<span style={{ color: "#666", fontSize: "0.85em" }}>
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<span style={{ marginRight: "8px", color: "#555" }}>
|
||||||
|
{logs.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isOpen ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div style={{ background: "#0d1117" }}>
|
||||||
|
{/* Filter controls */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "8px",
|
||||||
|
borderBottom: "1px solid #1e1e1e",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
data-testid="server-logs-filter-input"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="Filter logs..."
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: "80px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: "#161b22",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{severityButtons.map((sev) => (
|
||||||
|
<button
|
||||||
|
key={sev}
|
||||||
|
type="button"
|
||||||
|
data-testid={`server-logs-severity-${sev.toLowerCase()}`}
|
||||||
|
onClick={() => setSeverityFilter(sev)}
|
||||||
|
style={{
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor:
|
||||||
|
severityFilter === sev ? levelColor(sev) : "#333",
|
||||||
|
background:
|
||||||
|
severityFilter === sev
|
||||||
|
? "rgba(255,255,255,0.06)"
|
||||||
|
: "transparent",
|
||||||
|
color:
|
||||||
|
sev === "ALL"
|
||||||
|
? severityFilter === "ALL"
|
||||||
|
? "#ccc"
|
||||||
|
: "#555"
|
||||||
|
: levelColor(sev),
|
||||||
|
fontSize: "0.75em",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: severityFilter === sev ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sev}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log entries */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
data-testid="server-logs-entries"
|
||||||
|
style={{
|
||||||
|
maxHeight: "240px",
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "4px 0",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.75em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredLogs.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
color: "#444",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No log entries
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredLogs.map((entry, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.timestamp}-${idx}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "1px 8px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
borderBottom: "1px solid #111",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ color: "#444", flexShrink: 0, minWidth: "70px" }}
|
||||||
|
>
|
||||||
|
{entry.timestamp.replace("T", " ").replace("Z", "")}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: levelColor(entry.level),
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: "38px",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.level}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#c9d1d9",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use crate::io::onboarding;
|
|||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
use crate::llm::chat;
|
use crate::llm::chat;
|
||||||
use crate::llm::types::Message;
|
use crate::llm::types::Message;
|
||||||
|
use crate::log_buffer;
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use poem::handler;
|
use poem::handler;
|
||||||
use poem::web::Data;
|
use poem::web::Data;
|
||||||
@@ -132,6 +133,13 @@ enum WsResponse {
|
|||||||
SideQuestionDone {
|
SideQuestionDone {
|
||||||
response: String,
|
response: String,
|
||||||
},
|
},
|
||||||
|
/// A single server log entry. Sent in bulk on connect (recent history),
|
||||||
|
/// then streamed live as new entries arrive.
|
||||||
|
LogEntry {
|
||||||
|
timestamp: String,
|
||||||
|
level: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<WatcherEvent> for Option<WsResponse> {
|
impl From<WatcherEvent> for Option<WsResponse> {
|
||||||
@@ -208,6 +216,42 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push recent server log entries so the client has history on connect.
|
||||||
|
{
|
||||||
|
let entries = log_buffer::global().get_recent_entries(100, None, None);
|
||||||
|
for entry in entries {
|
||||||
|
let _ = tx.send(WsResponse::LogEntry {
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
level: entry.level.as_str().to_string(),
|
||||||
|
message: entry.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to live log entries and forward them to the client.
|
||||||
|
let tx_logs = tx.clone();
|
||||||
|
let mut log_rx = log_buffer::global().subscribe();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match log_rx.recv().await {
|
||||||
|
Ok(entry) => {
|
||||||
|
if tx_logs
|
||||||
|
.send(WsResponse::LogEntry {
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
level: entry.level.as_str().to_string(),
|
||||||
|
message: entry.message,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Subscribe to filesystem watcher events and forward them to the client.
|
// Subscribe to filesystem watcher events and forward them to the client.
|
||||||
// After each work-item event, also push the updated pipeline state.
|
// After each work-item event, also push the updated pipeline state.
|
||||||
// Config-changed events are forwarded as-is without a pipeline refresh.
|
// Config-changed events are forwarded as-is without a pipeline refresh.
|
||||||
@@ -1136,10 +1180,30 @@ mod tests {
|
|||||||
"expected onboarding_status, got: {onboarding}"
|
"expected onboarding_status, got: {onboarding}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Drain any log_entry messages sent as initial history on connect.
|
||||||
|
// These are buffered before tests send their own requests.
|
||||||
|
loop {
|
||||||
|
// Use a very short timeout: if nothing arrives quickly, the burst is done.
|
||||||
|
let Ok(Some(Ok(msg))) =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(200), stream.next()).await
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let val: serde_json::Value = match msg {
|
||||||
|
tungstenite::Message::Text(t) => serde_json::from_str(t.as_ref()).unwrap(),
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
if val["type"] != "log_entry" {
|
||||||
|
// Unexpected non-log message during drain — this shouldn't happen.
|
||||||
|
panic!("unexpected message during log drain: {val}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
(sink, stream, initial)
|
(sink, stream, initial)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read next text message from the stream with a timeout.
|
/// Read next non-log_entry text message from the stream with a timeout.
|
||||||
|
/// Skips any `log_entry` messages that arrive between events.
|
||||||
async fn next_msg(
|
async fn next_msg(
|
||||||
stream: &mut futures::stream::SplitStream<
|
stream: &mut futures::stream::SplitStream<
|
||||||
tokio_tungstenite::WebSocketStream<
|
tokio_tungstenite::WebSocketStream<
|
||||||
@@ -1147,14 +1211,19 @@ mod tests {
|
|||||||
>,
|
>,
|
||||||
>,
|
>,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
|
loop {
|
||||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next())
|
let msg = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next())
|
||||||
.await
|
.await
|
||||||
.expect("timeout waiting for message")
|
.expect("timeout waiting for message")
|
||||||
.expect("stream ended")
|
.expect("stream ended")
|
||||||
.expect("ws error");
|
.expect("ws error");
|
||||||
match msg {
|
let val: serde_json::Value = match msg {
|
||||||
tungstenite::Message::Text(t) => serde_json::from_str(t.as_ref()).unwrap(),
|
tungstenite::Message::Text(t) => serde_json::from_str(t.as_ref()).unwrap(),
|
||||||
other => panic!("expected text message, got: {other:?}"),
|
other => panic!("expected text message, got: {other:?}"),
|
||||||
|
};
|
||||||
|
if val["type"] != "log_entry" {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use std::fs::OpenOptions;
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
const CAPACITY: usize = 1000;
|
const CAPACITY: usize = 1000;
|
||||||
|
|
||||||
@@ -72,16 +73,25 @@ impl LogEntry {
|
|||||||
pub struct LogBuffer {
|
pub struct LogBuffer {
|
||||||
entries: Mutex<VecDeque<LogEntry>>,
|
entries: Mutex<VecDeque<LogEntry>>,
|
||||||
log_file: Mutex<Option<PathBuf>>,
|
log_file: Mutex<Option<PathBuf>>,
|
||||||
|
/// Broadcast channel for live log streaming to WebSocket subscribers.
|
||||||
|
broadcast_tx: broadcast::Sender<LogEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LogBuffer {
|
impl LogBuffer {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
|
let (broadcast_tx, _) = broadcast::channel(512);
|
||||||
Self {
|
Self {
|
||||||
entries: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
entries: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
||||||
log_file: Mutex::new(None),
|
log_file: Mutex::new(None),
|
||||||
|
broadcast_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribe to live log entries as they are pushed.
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<LogEntry> {
|
||||||
|
self.broadcast_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the persistent log file path. Call once at startup after the
|
/// Set the persistent log file path. Call once at startup after the
|
||||||
/// project root is known.
|
/// project root is known.
|
||||||
pub fn set_log_file(&self, path: PathBuf) {
|
pub fn set_log_file(&self, path: PathBuf) {
|
||||||
@@ -112,8 +122,11 @@ impl LogBuffer {
|
|||||||
if buf.len() >= CAPACITY {
|
if buf.len() >= CAPACITY {
|
||||||
buf.pop_front();
|
buf.pop_front();
|
||||||
}
|
}
|
||||||
buf.push_back(entry);
|
buf.push_back(entry.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort broadcast to WebSocket subscribers.
|
||||||
|
let _ = self.broadcast_tx.send(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return up to `count` recent log lines as formatted strings,
|
/// Return up to `count` recent log lines as formatted strings,
|
||||||
@@ -140,6 +153,31 @@ impl LogBuffer {
|
|||||||
let start = filtered.len().saturating_sub(count);
|
let start = filtered.len().saturating_sub(count);
|
||||||
filtered[start..].to_vec()
|
filtered[start..].to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return up to `count` recent `LogEntry` structs (not formatted strings),
|
||||||
|
/// optionally filtered by substring and/or severity level.
|
||||||
|
/// Entries are returned in chronological order (oldest first).
|
||||||
|
pub fn get_recent_entries(
|
||||||
|
&self,
|
||||||
|
count: usize,
|
||||||
|
filter: Option<&str>,
|
||||||
|
severity: Option<&LogLevel>,
|
||||||
|
) -> Vec<LogEntry> {
|
||||||
|
let buf = match self.entries.lock() {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
let filtered: Vec<LogEntry> = buf
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
severity.is_none_or(|s| &entry.level == s)
|
||||||
|
&& filter.is_none_or(|f| entry.message.contains(f) || entry.formatted().contains(f))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let start = filtered.len().saturating_sub(count);
|
||||||
|
filtered[start..].to_vec()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static GLOBAL: OnceLock<LogBuffer> = OnceLock::new();
|
static GLOBAL: OnceLock<LogBuffer> = OnceLock::new();
|
||||||
@@ -208,10 +246,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn evicts_oldest_at_capacity() {
|
fn evicts_oldest_at_capacity() {
|
||||||
let buf = LogBuffer {
|
let buf = LogBuffer::new();
|
||||||
entries: Mutex::new(VecDeque::with_capacity(CAPACITY)),
|
|
||||||
log_file: Mutex::new(None),
|
|
||||||
};
|
|
||||||
// Fill past capacity
|
// Fill past capacity
|
||||||
for i in 0..=CAPACITY {
|
for i in 0..=CAPACITY {
|
||||||
buf.push_entry(LogLevel::Info, format!("line {i}"));
|
buf.push_entry(LogLevel::Info, format!("line {i}"));
|
||||||
|
|||||||
@@ -2116,6 +2116,7 @@ mod tests {
|
|||||||
out.contains("**Pipeline Status**"),
|
out.contains("**Pipeline Status**"),
|
||||||
"missing bold title: {out}"
|
"missing bold title: {out}"
|
||||||
);
|
);
|
||||||
|
assert!(out.contains("**Pipeline Status**"), "missing bold title: {out}");
|
||||||
assert!(out.contains("**Backlog**"), "stage should use bold: {out}");
|
assert!(out.contains("**Backlog**"), "stage should use bold: {out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user