Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import SettingsModal from './components/SettingsModal';
import { SerialPortInfo, SerialConfig, LogEntry, ConnectionStatus, DataFormat, ChecksumConfig, QuickCommandList, QuickCommand, LineEnding, TextEncoding, FrameSegmentationConfig } from './types';
import { useTheme } from './contexts/ThemeContext';
import { useTranslation } from './i18n';
import { useSerialLogs } from './hooks/useSerialLogs';
import { appendChecksum } from './utils/checksum';
import { loadTimezone, formatDateForFilename, getSystemTimezoneOffset, parseUtcOffset } from './utils/timezone';
import { Toaster } from './components/ui/sonner';
Expand Down Expand Up @@ -135,7 +136,12 @@ function App() {
bytes_received: 0,
connection_time: null,
});
const [logs, setLogs] = useState<LogEntry[]>([]);
const [polledLogs, setPolledLogs] = useState<LogEntry[]>([]);
// RFC #3 Step 4: event push is the default log path; set
// localStorage 'serialEventPush' = '0' to roll back to 100ms polling.
const [eventPush] = useState(() => localStorage.getItem('serialEventPush') !== '0');
const { logs: pushedLogs, clearLogs: clearPushedLogs } = useSerialLogs(eventPush);
const logs = eventPush ? pushedLogs : polledLogs;
const [sendText, setSendText] = useState('');
const [sendFormat, setSendFormat] = useState<DataFormat>('Text');
const [checksumConfig, setChecksumConfig] = useState<ChecksumConfig>({
Expand Down Expand Up @@ -417,15 +423,17 @@ function App() {

// Set up intervals for updating status, logs, and ports
const statusInterval = setInterval(updateStatus, 1000);
const logsInterval = setInterval(updateLogs, 100); // More frequent log updates
// Event push (RFC #3 Step 4) replaces the 100ms full-clone log polling;
// the polling interval only runs in rollback mode.
const logsInterval = eventPush ? null : setInterval(updateLogs, 100);
const portsInterval = setInterval(loadPorts, 3000); // Check for new ports every 3 seconds

return () => {
clearInterval(statusInterval);
clearInterval(logsInterval);
if (logsInterval !== null) clearInterval(logsInterval);
clearInterval(portsInterval);
};
}, [loadPorts]); // 依赖loadPorts函数
}, [loadPorts, eventPush]); // 依赖loadPorts函数

const handlePortSelect = (port: string) => {
setSelectedPort(port);
Expand All @@ -451,7 +459,7 @@ function App() {
const updateLogs = async () => {
try {
const newLogs = await invoke<LogEntry[]>('get_logs');
setLogs(newLogs);
setPolledLogs(newLogs);
} catch (error) {
console.error('Failed to get logs:', error);
}
Expand Down Expand Up @@ -552,9 +560,13 @@ function App() {
};

const handleClearLogs = async () => {
if (eventPush) {
await clearPushedLogs();
return;
}
try {
await invoke('clear_logs');
setLogs([]);
setPolledLogs([]);
} catch (error) {
console.error('Failed to clear logs:', error);
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/LogViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, onClear, onExport, isConnec
<div className="space-y-0.5">
{logs.map((log, index) => (
<div
key={index}
key={log.gap_key ?? (log.seq ? `${log.session}-${log.seq}` : index)}
ref={(el) => { logEntryRefs.current[index] = el; }}
className="py-1 px-2 rounded-[4px] transition-colors duration-150"
style={{
Expand Down
172 changes: 172 additions & 0 deletions frontend/src/hooks/useSerialLogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { LogEntry } from '../types';
import { useTranslation } from '../i18n';

/**
* Event-driven serial log state (RFC #3 Step 4): replaces the 100 ms
* full-clone polling of `get_logs` with a one-shot snapshot + incremental
* `serial://frames` batches.
*
* Invariants:
* - Snapshot carries (epoch, session); a snapshot that raced a `clear_logs`
* (epoch mismatch) or a newer snapshot (ticket) is discarded — cleared
* logs never resurrect.
* - Batches dedupe by seq: frames already covered by the snapshot or a
* prior batch are skipped; frames from before a clear are skipped.
* - A batch whose session differs from the current one triggers a resync
* snapshot instead of an append (seq restarts at 1 per session).
* - `dropped_before > 0` inserts a placeholder row so channel overload is
* visible instead of silent.
*/

interface FrameDto {
session: number;
seq: number;
direction: 'Sent' | 'Received';
len: number;
timestamp: string;
display_text: string;
timestamp_formatted: string | null;
}

interface FrameBatchDto {
session: number;
first_seq: number;
dropped_before: number;
frames: FrameDto[];
}

interface LogsSnapshot {
epoch: number;
session: number;
entries: LogEntry[];
}

/** Mirrors the backend default (`SerialManager::new`, clamped 100..10000). */
const MAX_ENTRIES = 1000;

export function useSerialLogs(enabled: boolean) {
const { t } = useTranslation();
const [logs, setLogs] = useState<LogEntry[]>([]);

const epochRef = useRef(0);
const sessionRef = useRef(0);
const lastSeqRef = useRef(0);
const clearedSeqRef = useRef(0);
const pendingRef = useRef<LogEntry[]>([]);
const rafRef = useRef<number | null>(null);
const snapTicketRef = useRef(0);

const flushPending = useCallback(() => {
rafRef.current = null;
if (pendingRef.current.length === 0) return;
const batch = pendingRef.current;
pendingRef.current = [];
setLogs((prev) => {
const next = [...prev, ...batch];
return next.length > MAX_ENTRIES ? next.slice(next.length - MAX_ENTRIES) : next;
});
}, []);

const scheduleFlush = useCallback(() => {
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(flushPending);
}
}, [flushPending]);

const takeSnapshot = useCallback(async () => {
const ticket = ++snapTicketRef.current;
try {
const snap = await invoke<LogsSnapshot>('get_logs_snapshot');
if (ticket !== snapTicketRef.current) return; // superseded by a newer snapshot
if (snap.epoch !== epochRef.current) return; // raced a clear: discard
epochRef.current = snap.epoch;
sessionRef.current = snap.session;
lastSeqRef.current = snap.entries.reduce((m, e) => Math.max(m, e.seq ?? 0), 0);
pendingRef.current = [];
setLogs(snap.entries);
} catch (error) {
console.error('Failed to take logs snapshot:', error);
}
}, []);

useEffect(() => {
if (!enabled) return;
let disposed = false;
let unlisten: (() => void) | undefined;

void takeSnapshot();

listen<FrameBatchDto>('serial://frames', (event) => {
if (disposed) return;
const b = event.payload;
if (b.session !== sessionRef.current) {
void takeSnapshot(); // session changed: seq restarted, resync instead of append
return;
}

const fresh: LogEntry[] = [];
if (b.dropped_before > 0) {
fresh.push({
timestamp: new Date().toISOString(),
direction: 'Received',
data: [],
format: 'Text',
port_name: '',
display_text: t('logViewer.framesDropped').replace('{n}', String(b.dropped_before)),
timestamp_formatted: undefined,
session: b.session,
gap_key: `gap-${b.session}-${b.first_seq}`,
});
}
for (const f of b.frames) {
// Skip frames already covered by the snapshot/prior batches, and
// frames that predate the last clear.
if (f.seq <= lastSeqRef.current || f.seq <= clearedSeqRef.current) continue;
fresh.push({
timestamp: f.timestamp,
direction: f.direction,
data: [],
format: 'Text',
port_name: '',
display_text: f.display_text,
timestamp_formatted: f.timestamp_formatted ?? undefined,
seq: f.seq,
session: f.session,
});
lastSeqRef.current = f.seq;
}
if (fresh.length > 0) {
pendingRef.current.push(...fresh);
scheduleFlush(); // rAF-throttled append
}
})
.then((u) => {
unlisten = u;
})
.catch((error) => console.error('Failed to listen serial://frames:', error));

return () => {
disposed = true;
unlisten?.();
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, takeSnapshot, scheduleFlush]);

const clearLogs = useCallback(async () => {
try {
const epoch = await invoke<number>('clear_logs');
epochRef.current = epoch;
clearedSeqRef.current = lastSeqRef.current;
pendingRef.current = [];
setLogs([]);
} catch (error) {
console.error('Failed to clear logs:', error);
}
}, []);

return { logs, clearLogs };
}
3 changes: 2 additions & 1 deletion frontend/src/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
"tx": "TX",
"rx": "RX",
"total": "Total",
"bytes": "Bytes"
"bytes": "Bytes",
"framesDropped": "⋯ dropped {n} frames (channel overloaded) ⋯"
},
"sendPanel": {
"payload": "Payload",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/translations/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
"tx": "发送",
"rx": "接收",
"total": "总计",
"bytes": "字节"
"bytes": "字节",
"framesDropped": "⋯ 丢失 {n} 帧(通道过载) ⋯"
},
"sendPanel": {
"payload": "发送数据",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export interface LogEntry {
display_text: string;
/** Pre-formatted timestamp string (undefined if timestamps were disabled when entry was created) */
timestamp_formatted?: string;
/** Session-scoped sequence number (RFC #3 Step 4); 0/undefined for legacy entries */
seq?: number;
/** Owning session id */
session?: number;
/** Frontend-only marker for synthesized "frames dropped" placeholder rows */
gap_key?: string;
}

export interface ConnectionStatus {
Expand Down
Loading
Loading