feat(tauri): make unpack logs viewer usable for debugging
The original unpack logs window was a flat, monolithic scroll of every
unrar invocation glued together as one continuous textfield. That is
fine for a sanity check but hostile to actually finding a failed
extraction in a session with 30+ games: empty lines from unrar bloated
the view, there was no way to focus on a single game, no filtering, and
no way to narrow in on the entries that actually failed.
This rewrites the viewer to be a proper debugging surface while keeping
the backend untouched -- it still consumes the existing
`get_unpack_logs` command and `unpack-logs-updated` event.
User-visible changes:
* Empty / whitespace-only lines are stripped from stdout and stderr
before rendering, so unrar's padding no longer drowns out real output.
* Two-pane layout: a sidebar lists every captured invocation (badge,
archive basename, finish time); the right pane shows the selected
entry's metadata, stdout and stderr.
* "Errors only" checkbox filters the sidebar to entries whose `success`
flag is false (sidecar exit != 0 or one of the pre-spawn failure
paths). This is the primary affordance for "find the unpack that
broke".
* Regex input filters lines (not entries) -- both per-log when viewing
one, and across the list: entries that contribute zero matching lines
are hidden, and the remaining ones display a per-entry match counter
next to the badge. Regex is case-insensitive; a bad pattern reddens
the input and renders the parser error inline rather than silently
dropping all matches.
* Prev / Next buttons plus arrow keys (and j/k) step through the
filtered list one entry at a time, with the active row auto-scrolled
into view. Selection is tracked by the entry's index in the full log
ring so it survives filter toggles and live appends.
Code organization:
The component, its types, helpers (`basename`, `nonEmptyLines`,
`formatLogTime`, `isUnpackLogsView`) and its CSS are moved out of
`App.tsx` / `App.css` into a dedicated `UnpackLogsWindow.tsx` +
`UnpackLogsWindow.css` pair. The viewer has no shared state with the
main window and lives behind its own `?view=unpack-logs` route, so
keeping ~200 lines of debug-UI plumbing inside `App.tsx` was just
noise. `App.tsx` now imports `UnpackLogsWindow` and `isUnpackLogsView`
and otherwise looks the same as before.
Intentionally out of scope:
* No backend changes. The Rust side already records everything needed;
this is purely a presentation improvement.
* No "view all logs concatenated" mode. The flat view was what we just
replaced -- if it is ever wanted back, it can be added as a third
pane mode.
* Regex is applied to displayed lines only, not to archive paths or
meta. Filtering by archive name is easy enough via the basename in
the sidebar; adding a second filter for it now would be premature.
* Logs are still process-local and capped at `MAX_UNPACK_LOGS` (100)
in the Rust state -- unchanged from b35755f.
Test plan:
* `tsc --noEmit` and `vite build` are clean.
* Manual: trigger several successful and failed unpacks (rename one
archive between handshake and extraction to force a canonicalize
failure), open Unpack Logs, and verify:
- empty lines are gone from stdout/stderr,
- sidebar lists every entry with the right OK/FAIL badge,
- "Errors only" hides the OK rows,
- typing a regex narrows lines in the open entry, hides entries
with no matches, and shows the per-entry match counts,
- an invalid regex (e.g. `[`) reddens the field and shows the
parser error rather than crashing,
- arrow keys / j / k step through the filtered list and the
active row scrolls into view,
- new entries arriving via `unpack-logs-updated` while the window
is open keep the current selection rather than jumping.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -349,80 +349,3 @@ h1.align-center {
|
||||
left: 20px;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.unpack-log-window {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 18px;
|
||||
background: #000313;
|
||||
color: #D5DBFE;
|
||||
}
|
||||
|
||||
.unpack-log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.unpack-log-header h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.unpack-log-textfield {
|
||||
height: calc(100vh - 84px);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
background: #050813;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.unpack-log-empty {
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.unpack-log-entry {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #26304f;
|
||||
}
|
||||
|
||||
.unpack-log-entry:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.unpack-log-meta {
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.unpack-log-meta.success {
|
||||
color: #8ee6a6;
|
||||
}
|
||||
|
||||
.unpack-log-meta.error,
|
||||
.unpack-log-stream.stderr {
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.unpack-log-path {
|
||||
color: #aeb7df;
|
||||
}
|
||||
|
||||
.unpack-log-stream {
|
||||
margin: 8px 0 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.unpack-log-stream.stdout {
|
||||
color: #D5DBFE;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { load } from '@tauri-apps/plugin-store';
|
||||
|
||||
import "./App.css";
|
||||
import { UnpackLogsWindow, isUnpackLogsView } from './UnpackLogsWindow';
|
||||
|
||||
const FILE_STORAGE = 'launcher-settings.json';
|
||||
const GAME_DIR_KEY = 'game-directory';
|
||||
@@ -72,17 +73,6 @@ interface GamesListPayload {
|
||||
active_operations?: ActiveOperation[];
|
||||
}
|
||||
|
||||
interface UnpackLogEntry {
|
||||
archive: string;
|
||||
destination: string;
|
||||
status_code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
started_at_ms: number;
|
||||
finished_at_ms: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
interface GameThumbnailProps {
|
||||
gameId: string;
|
||||
alt: string;
|
||||
@@ -163,72 +153,6 @@ const normalizeGamesListPayload = (payload: GamesListPayload | Game[]): GamesLis
|
||||
return payload;
|
||||
};
|
||||
|
||||
const isUnpackLogsView = (): boolean => {
|
||||
return new URLSearchParams(window.location.search).get('view') === 'unpack-logs';
|
||||
};
|
||||
|
||||
const formatLogTime = (timestampMs: number): string => {
|
||||
if (timestampMs <= 0) {
|
||||
return 'unknown time';
|
||||
}
|
||||
return new Date(timestampMs).toLocaleString();
|
||||
};
|
||||
|
||||
const UnpackLogsWindow = () => {
|
||||
const [logs, setLogs] = useState<UnpackLogEntry[]>([]);
|
||||
|
||||
const refreshLogs = useCallback(async () => {
|
||||
const unpackLogs = await invoke<UnpackLogEntry[]>('get_unpack_logs');
|
||||
setLogs(unpackLogs);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
await refreshLogs();
|
||||
unlisten = await listen('unpack-logs-updated', () => {
|
||||
void refreshLogs();
|
||||
});
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, [refreshLogs]);
|
||||
|
||||
return (
|
||||
<main className="unpack-log-window">
|
||||
<div className="unpack-log-header">
|
||||
<h1>Unpack Logs</h1>
|
||||
<button className="settings-button" onClick={() => void refreshLogs()}>Refresh</button>
|
||||
</div>
|
||||
<div className="unpack-log-textfield" role="textbox" aria-readonly="true" tabIndex={0}>
|
||||
{logs.length === 0 ? (
|
||||
<div className="unpack-log-empty">No unpack logs recorded yet.</div>
|
||||
) : logs.map((entry, index) => (
|
||||
<section className="unpack-log-entry" key={`${entry.finished_at_ms}-${index}`}>
|
||||
<div className={`unpack-log-meta ${entry.success ? 'success' : 'error'}`}>
|
||||
[{formatLogTime(entry.finished_at_ms)}] {entry.success ? 'OK' : 'FAILED'}
|
||||
{' '}status={entry.status_code ?? 'none'}
|
||||
</div>
|
||||
<div className="unpack-log-path">archive: {entry.archive}</div>
|
||||
<div className="unpack-log-path">dest: {entry.destination}</div>
|
||||
<pre className="unpack-log-stream stdout">
|
||||
{entry.stdout.trim() ? entry.stdout : '(stdout empty)'}
|
||||
</pre>
|
||||
{entry.stderr.trim() && (
|
||||
<pre className="unpack-log-stream stderr">{entry.stderr}</pre>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const mergeGameUpdate = (
|
||||
game: Game,
|
||||
previous?: Game,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
.unpack-log-window {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 18px;
|
||||
background: #000313;
|
||||
color: #D5DBFE;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.unpack-log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unpack-log-header h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.unpack-log-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.unpack-log-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #aeb7df;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.unpack-log-regex {
|
||||
flex: 0 1 320px;
|
||||
padding: 7px 12px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
color: #D5DBFE;
|
||||
background: #050813;
|
||||
border: 1px solid #2a3252;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.unpack-log-regex:focus {
|
||||
border-color: #4866b9;
|
||||
}
|
||||
|
||||
.unpack-log-regex.invalid {
|
||||
border-color: #ff6666;
|
||||
}
|
||||
|
||||
.unpack-log-regex-error {
|
||||
flex-shrink: 0;
|
||||
color: #ff8a8a;
|
||||
font-size: 12px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.unpack-log-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 320px) 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.unpack-log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid #2a3252;
|
||||
border-radius: 6px;
|
||||
background: #050813;
|
||||
}
|
||||
|
||||
.unpack-log-list-stats {
|
||||
color: #8892b0;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px 6px;
|
||||
border-bottom: 1px solid #26304f;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.unpack-log-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
color: #D5DBFE;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unpack-log-list-item:hover {
|
||||
background: #0d1530;
|
||||
}
|
||||
|
||||
.unpack-log-list-item.active {
|
||||
background: #14224d;
|
||||
border-color: #4866b9;
|
||||
}
|
||||
|
||||
.unpack-log-list-badge {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.unpack-log-list-item.success .unpack-log-list-badge {
|
||||
color: #050813;
|
||||
background: #8ee6a6;
|
||||
}
|
||||
|
||||
.unpack-log-list-item.error .unpack-log-list-badge {
|
||||
color: #050813;
|
||||
background: #ff8a8a;
|
||||
}
|
||||
|
||||
.unpack-log-list-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unpack-log-list-time {
|
||||
color: #8892b0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.unpack-log-list-matches {
|
||||
color: #aeb7df;
|
||||
background: #14224d;
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unpack-log-detail {
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid #2a3252;
|
||||
border-radius: 6px;
|
||||
background: #050813;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.unpack-log-detail-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unpack-log-detail-pos {
|
||||
color: #8892b0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.unpack-log-empty {
|
||||
color: #8892b0;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.unpack-log-empty-stream {
|
||||
color: #8892b0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.unpack-log-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.unpack-log-meta {
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.unpack-log-meta.success {
|
||||
color: #8ee6a6;
|
||||
}
|
||||
|
||||
.unpack-log-meta.error,
|
||||
.unpack-log-stream.stderr {
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.unpack-log-path {
|
||||
color: #aeb7df;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.unpack-log-stream {
|
||||
margin: 8px 0 0;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.unpack-log-stream.stdout {
|
||||
color: #D5DBFE;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
import './UnpackLogsWindow.css';
|
||||
|
||||
interface UnpackLogEntry {
|
||||
archive: string;
|
||||
destination: string;
|
||||
status_code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
started_at_ms: number;
|
||||
finished_at_ms: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
interface FilteredEntry {
|
||||
entry: UnpackLogEntry;
|
||||
originalIndex: number;
|
||||
stdoutLines: string[];
|
||||
stderrLines: string[];
|
||||
matchCount: number;
|
||||
}
|
||||
|
||||
export const isUnpackLogsView = (): boolean =>
|
||||
new URLSearchParams(window.location.search).get('view') === 'unpack-logs';
|
||||
|
||||
const formatLogTime = (timestampMs: number): string => {
|
||||
if (timestampMs <= 0) {
|
||||
return 'unknown time';
|
||||
}
|
||||
return new Date(timestampMs).toLocaleString();
|
||||
};
|
||||
|
||||
const basename = (path: string): string => {
|
||||
const segments = path.split(/[\\/]/);
|
||||
return segments[segments.length - 1] || path;
|
||||
};
|
||||
|
||||
const nonEmptyLines = (text: string): string[] =>
|
||||
text.split(/\r?\n/).filter(line => line.trim().length > 0);
|
||||
|
||||
export const UnpackLogsWindow = () => {
|
||||
const [logs, setLogs] = useState<UnpackLogEntry[]>([]);
|
||||
const [errorsOnly, setErrorsOnly] = useState(false);
|
||||
const [regexInput, setRegexInput] = useState('');
|
||||
const [selectedOriginalIndex, setSelectedOriginalIndex] = useState<number | null>(null);
|
||||
|
||||
const refreshLogs = useCallback(async () => {
|
||||
const unpackLogs = await invoke<UnpackLogEntry[]>('get_unpack_logs');
|
||||
setLogs(unpackLogs);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
await refreshLogs();
|
||||
unlisten = await listen('unpack-logs-updated', () => {
|
||||
void refreshLogs();
|
||||
});
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, [refreshLogs]);
|
||||
|
||||
const { regex, regexError } = useMemo(() => {
|
||||
if (!regexInput) {
|
||||
return { regex: null as RegExp | null, regexError: null as string | null };
|
||||
}
|
||||
try {
|
||||
return { regex: new RegExp(regexInput, 'i'), regexError: null };
|
||||
} catch (e) {
|
||||
return { regex: null, regexError: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}, [regexInput]);
|
||||
|
||||
const filteredLogs = useMemo<FilteredEntry[]>(() => {
|
||||
const out: FilteredEntry[] = [];
|
||||
logs.forEach((entry, originalIndex) => {
|
||||
if (errorsOnly && entry.success) return;
|
||||
|
||||
const stdoutClean = nonEmptyLines(entry.stdout);
|
||||
const stderrClean = nonEmptyLines(entry.stderr);
|
||||
|
||||
const stdoutLines = regex ? stdoutClean.filter(line => regex.test(line)) : stdoutClean;
|
||||
const stderrLines = regex ? stderrClean.filter(line => regex.test(line)) : stderrClean;
|
||||
const matchCount = stdoutLines.length + stderrLines.length;
|
||||
|
||||
// With an active regex, hide entries that contribute no matching lines.
|
||||
if (regex && matchCount === 0) return;
|
||||
|
||||
out.push({ entry, originalIndex, stdoutLines, stderrLines, matchCount });
|
||||
});
|
||||
return out;
|
||||
}, [logs, errorsOnly, regex]);
|
||||
|
||||
const selectedListIndex = useMemo(() => {
|
||||
if (filteredLogs.length === 0) return -1;
|
||||
if (selectedOriginalIndex === null) return 0;
|
||||
const idx = filteredLogs.findIndex(item => item.originalIndex === selectedOriginalIndex);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [filteredLogs, selectedOriginalIndex]);
|
||||
|
||||
const current = selectedListIndex >= 0 ? filteredLogs[selectedListIndex] : null;
|
||||
|
||||
const goTo = useCallback((listIndex: number) => {
|
||||
if (listIndex < 0 || listIndex >= filteredLogs.length) return;
|
||||
setSelectedOriginalIndex(filteredLogs[listIndex].originalIndex);
|
||||
}, [filteredLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
||||
if (e.key === 'ArrowUp' || e.key === 'k') {
|
||||
e.preventDefault();
|
||||
goTo(selectedListIndex - 1);
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'j') {
|
||||
e.preventDefault();
|
||||
goTo(selectedListIndex + 1);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [goTo, selectedListIndex]);
|
||||
|
||||
const activeItemRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
activeItemRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedListIndex]);
|
||||
|
||||
return (
|
||||
<main className="unpack-log-window">
|
||||
<div className="unpack-log-header">
|
||||
<h1>Unpack Logs</h1>
|
||||
<div className="unpack-log-controls">
|
||||
<label className="unpack-log-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={errorsOnly}
|
||||
onChange={(e) => setErrorsOnly(e.target.checked)}
|
||||
/>
|
||||
Errors only
|
||||
</label>
|
||||
<input
|
||||
className={`unpack-log-regex ${regexError ? 'invalid' : ''}`}
|
||||
type="text"
|
||||
placeholder="Filter lines by regex (case-insensitive)..."
|
||||
value={regexInput}
|
||||
onChange={(e) => setRegexInput(e.target.value)}
|
||||
title={regexError ?? ''}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button className="settings-button" onClick={() => void refreshLogs()}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
{regexError && (
|
||||
<div className="unpack-log-regex-error">regex error: {regexError}</div>
|
||||
)}
|
||||
<div className="unpack-log-body">
|
||||
<aside className="unpack-log-list">
|
||||
<div className="unpack-log-list-stats">
|
||||
showing {filteredLogs.length} of {logs.length}
|
||||
</div>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="unpack-log-empty">
|
||||
{logs.length === 0 ? 'No unpack logs recorded yet.' : 'No logs match the current filters.'}
|
||||
</div>
|
||||
) : filteredLogs.map((item, listIndex) => {
|
||||
const isActive = listIndex === selectedListIndex;
|
||||
return (
|
||||
<button
|
||||
key={`${item.originalIndex}-${item.entry.finished_at_ms}`}
|
||||
ref={isActive ? activeItemRef : undefined}
|
||||
className={`unpack-log-list-item ${isActive ? 'active' : ''} ${item.entry.success ? 'success' : 'error'}`}
|
||||
onClick={() => goTo(listIndex)}
|
||||
>
|
||||
<span className="unpack-log-list-badge">
|
||||
{item.entry.success ? 'OK' : 'FAIL'}
|
||||
</span>
|
||||
<span className="unpack-log-list-name" title={item.entry.archive}>
|
||||
{basename(item.entry.archive)}
|
||||
</span>
|
||||
<span className="unpack-log-list-time">
|
||||
{item.entry.finished_at_ms > 0
|
||||
? new Date(item.entry.finished_at_ms).toLocaleTimeString()
|
||||
: '--:--:--'}
|
||||
</span>
|
||||
{regex && (
|
||||
<span className="unpack-log-list-matches">{item.matchCount}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
<section className="unpack-log-detail">
|
||||
{current ? (
|
||||
<>
|
||||
<div className="unpack-log-detail-nav">
|
||||
<button
|
||||
className="settings-button"
|
||||
onClick={() => goTo(selectedListIndex - 1)}
|
||||
disabled={selectedListIndex <= 0}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="unpack-log-detail-pos">
|
||||
{selectedListIndex + 1} / {filteredLogs.length}
|
||||
{regex && ` · ${current.matchCount} matching lines`}
|
||||
</span>
|
||||
<button
|
||||
className="settings-button"
|
||||
onClick={() => goTo(selectedListIndex + 1)}
|
||||
disabled={selectedListIndex >= filteredLogs.length - 1}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
<article className="unpack-log-entry">
|
||||
<div className={`unpack-log-meta ${current.entry.success ? 'success' : 'error'}`}>
|
||||
[{formatLogTime(current.entry.finished_at_ms)}] {current.entry.success ? 'OK' : 'FAILED'}
|
||||
{' '}status={current.entry.status_code ?? 'none'}
|
||||
</div>
|
||||
<div className="unpack-log-path">archive: {current.entry.archive}</div>
|
||||
<div className="unpack-log-path">dest: {current.entry.destination}</div>
|
||||
{current.stdoutLines.length > 0 ? (
|
||||
<pre className="unpack-log-stream stdout">{current.stdoutLines.join('\n')}</pre>
|
||||
) : (
|
||||
<div className="unpack-log-empty-stream">
|
||||
{regex ? '(stdout: no matching lines)' : '(stdout empty)'}
|
||||
</div>
|
||||
)}
|
||||
{current.stderrLines.length > 0 && (
|
||||
<pre className="unpack-log-stream stderr">{current.stderrLines.join('\n')}</pre>
|
||||
)}
|
||||
</article>
|
||||
</>
|
||||
) : (
|
||||
<div className="unpack-log-empty">Nothing to show.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user