Skip to content

Commit f5048e2

Browse files
committed
View logs
1 parent f4a3b27 commit f5048e2

2 files changed

Lines changed: 265 additions & 1 deletion

File tree

website/src/app/globals.css

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,163 @@ body {
861861
display: none;
862862
}
863863

864+
/* ── "View Logs" button & log viewer panel ──────────────── */
865+
.tc-logs-btn {
866+
display: inline-flex;
867+
align-items: center;
868+
gap: 4px;
869+
padding: 2px 10px;
870+
margin-left: 6px;
871+
border: 1px solid rgba(248, 113, 113, 0.3);
872+
border-radius: 10px;
873+
background: rgba(127, 29, 29, 0.4);
874+
color: #f87171;
875+
font-size: 0.625rem;
876+
font-weight: 600;
877+
font-family: inherit;
878+
cursor: pointer;
879+
transition: all 0.2s;
880+
white-space: nowrap;
881+
}
882+
883+
.tc-logs-btn:hover {
884+
background: rgba(127, 29, 29, 0.7);
885+
border-color: rgba(248, 113, 113, 0.6);
886+
color: #fca5a5;
887+
}
888+
889+
.tc-logs-btn.tc-logs-btn-active {
890+
background: rgba(127, 29, 29, 0.7);
891+
border-color: #f87171;
892+
}
893+
894+
.tc-logs-btn:disabled {
895+
opacity: 0.6;
896+
cursor: wait;
897+
}
898+
899+
.tc-logs-spinner {
900+
display: inline-block;
901+
width: 10px;
902+
height: 10px;
903+
border: 2px solid rgba(248, 113, 113, 0.3);
904+
border-top-color: #f87171;
905+
border-radius: 50%;
906+
animation: tc-spin 0.6s linear infinite;
907+
}
908+
909+
@keyframes tc-spin {
910+
to { transform: rotate(360deg); }
911+
}
912+
913+
/* Log viewer panel */
914+
.tc-logs-panel {
915+
border-top: 1px solid rgba(248, 113, 113, 0.2);
916+
background: #110a0a;
917+
max-height: 400px;
918+
overflow-y: auto;
919+
animation: tc-logs-slide 0.2s ease-out;
920+
}
921+
922+
@keyframes tc-logs-slide {
923+
from {
924+
max-height: 0;
925+
opacity: 0;
926+
}
927+
to {
928+
max-height: 400px;
929+
opacity: 1;
930+
}
931+
}
932+
933+
.tc-logs-panel::-webkit-scrollbar {
934+
width: 6px;
935+
}
936+
937+
.tc-logs-panel::-webkit-scrollbar-track {
938+
background: transparent;
939+
}
940+
941+
.tc-logs-panel::-webkit-scrollbar-thumb {
942+
background: #333;
943+
border-radius: 3px;
944+
}
945+
946+
.tc-logs-panel::-webkit-scrollbar-thumb:hover {
947+
background: #555;
948+
}
949+
950+
.tc-logs-error {
951+
display: flex;
952+
align-items: center;
953+
gap: 8px;
954+
padding: 0.75rem 1rem;
955+
color: #f87171;
956+
font-size: 0.8125rem;
957+
}
958+
959+
.tc-logs-error-message {
960+
padding: 0.5rem 0;
961+
}
962+
963+
.tc-logs-section {
964+
padding: 0.5rem 0;
965+
}
966+
967+
.tc-logs-section + .tc-logs-section {
968+
border-top: 1px solid rgba(255, 255, 255, 0.05);
969+
}
970+
971+
.tc-logs-section-label {
972+
display: inline-block;
973+
padding: 2px 8px;
974+
margin: 0 1rem 0.375rem;
975+
border-radius: 4px;
976+
font-size: 0.625rem;
977+
font-weight: 700;
978+
text-transform: uppercase;
979+
letter-spacing: 0.5px;
980+
}
981+
982+
.tc-logs-stderr .tc-logs-section-label,
983+
.tc-logs-error-message .tc-logs-section-label {
984+
background: rgba(127, 29, 29, 0.4);
985+
color: #f87171;
986+
}
987+
988+
.tc-logs-stdout .tc-logs-section-label {
989+
background: rgba(255, 255, 255, 0.06);
990+
color: #888;
991+
}
992+
993+
.tc-logs-pre {
994+
margin: 0;
995+
padding: 0.5rem 1rem;
996+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
997+
font-size: 0.75rem;
998+
line-height: 1.6;
999+
color: #ccc;
1000+
white-space: pre-wrap;
1001+
word-break: break-all;
1002+
overflow-x: auto;
1003+
}
1004+
1005+
.tc-logs-empty {
1006+
padding: 1rem;
1007+
color: #666;
1008+
font-size: 0.8125rem;
1009+
text-align: center;
1010+
font-style: italic;
1011+
}
1012+
1013+
/* Hide log viewer in user view */
1014+
.tc-user-view .tc-logs-btn {
1015+
display: none;
1016+
}
1017+
.tc-user-view .tc-logs-panel {
1018+
display: none;
1019+
}
1020+
8641021
/* ──────────────────────────────────────────────────────────
8651022
Coverage Sidebar — playbook list (mirrors Python preview)
8661023
────────────────────────────────────────────────────────── */

website/src/app/playbooks/[id]/page.tsx

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ function HaloPreinstalledDropdown({
261261
setup={divProps['data-setup'] || ''}
262262
code={decodeURIComponent(divProps['data-code'] || '')}
263263
testResult={testInfo?.result}
264+
playbookId={playbookId}
264265
/>
265266
);
266267
}
@@ -641,17 +642,24 @@ function transformSetupBlocks(content: string): string {
641642
/**
642643
* Test coverage badge block — renders a badge header on top of a code block.
643644
* Only shown when running in dev:coverage mode.
645+
* When a test fails, shows a "View Logs" button to inspect stdout/stderr.
644646
*/
645647
function TestCoverageBlock({
646-
testId, timeout, isHidden, setup, code, testResult,
648+
testId, timeout, isHidden, setup, code, testResult, playbookId,
647649
}: {
648650
testId: string;
649651
timeout: string;
650652
isHidden: boolean;
651653
setup: string;
652654
code: string;
653655
testResult?: TestResultInfo;
656+
playbookId?: string;
654657
}) {
658+
const [logsOpen, setLogsOpen] = useState(false);
659+
const [logs, setLogs] = useState<{ stdout: string; stderr: string } | null>(null);
660+
const [logsLoading, setLogsLoading] = useState(false);
661+
const [logsError, setLogsError] = useState<string | null>(null);
662+
655663
// Parse the fenced code block: ```lang\ncontent\n```
656664
const langMatch = code.match(/```(\w+)?\s*\n/);
657665
const language = langMatch?.[1] || "";
@@ -668,6 +676,45 @@ function TestCoverageBlock({
668676
else { resultStatus = "fail"; resultLabel = "Failed"; }
669677
}
670678

679+
// Show logs button for failed or skipped tests
680+
const showLogsButton = testResult && !testResult.success;
681+
682+
const handleViewLogs = useCallback(async () => {
683+
if (logsOpen) {
684+
setLogsOpen(false);
685+
return;
686+
}
687+
688+
// If logs already fetched, just toggle open
689+
if (logs) {
690+
setLogsOpen(true);
691+
return;
692+
}
693+
694+
// Fetch logs from API
695+
if (!playbookId) return;
696+
setLogsLoading(true);
697+
setLogsError(null);
698+
699+
try {
700+
const res = await fetch(`/api/playbooks/${playbookId}/logs/${testId}`);
701+
if (!res.ok) {
702+
const data = await res.json().catch(() => ({}));
703+
setLogsError(data.error || "Failed to load logs");
704+
setLogsOpen(true);
705+
return;
706+
}
707+
const data = await res.json();
708+
setLogs(data);
709+
setLogsOpen(true);
710+
} catch {
711+
setLogsError("Failed to fetch logs");
712+
setLogsOpen(true);
713+
} finally {
714+
setLogsLoading(false);
715+
}
716+
}, [logsOpen, logs, playbookId, testId]);
717+
671718
return (
672719
<div className={`tc-block ${isHidden ? "tc-hidden" : ""} ${resultStatus ? `tc-result-${resultStatus}` : ""}`}>
673720
<div className={`tc-badge-header ${isHidden ? "tc-badge-hidden" : ""} ${resultStatus === "fail" ? "tc-badge-fail" : ""} ${resultStatus === "skip" ? "tc-badge-skip" : ""}`}>
@@ -679,12 +726,71 @@ function TestCoverageBlock({
679726
{setup && (
680727
<span className="tc-pill tc-pill-setup">{setup}</span>
681728
)}
729+
{showLogsButton && (
730+
<button
731+
className={`tc-logs-btn ${logsOpen ? "tc-logs-btn-active" : ""}`}
732+
onClick={handleViewLogs}
733+
disabled={logsLoading}
734+
title={logsOpen ? "Hide logs" : "View test logs"}
735+
>
736+
{logsLoading ? (
737+
<span className="tc-logs-spinner" />
738+
) : (
739+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
740+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
741+
<polyline points="14 2 14 8 20 8" />
742+
<line x1="16" y1="13" x2="8" y2="13" />
743+
<line x1="16" y1="17" x2="8" y2="17" />
744+
</svg>
745+
)}
746+
{logsOpen ? "Hide Logs" : "View Logs"}
747+
</button>
748+
)}
682749
{testResult && (
683750
<span className={`tc-pill tc-pill-result tc-pill-result-${resultStatus}`}>
684751
{resultLabel}{testResult.duration ? ` (${testResult.duration.toFixed(1)}s)` : ""}
685752
</span>
686753
)}
687754
</div>
755+
{/* Collapsible log viewer */}
756+
{logsOpen && (
757+
<div className="tc-logs-panel">
758+
{logsError && !logs ? (
759+
<div className="tc-logs-error">
760+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
761+
<circle cx="12" cy="12" r="10" />
762+
<line x1="12" y1="8" x2="12" y2="12" />
763+
<line x1="12" y1="16" x2="12.01" y2="16" />
764+
</svg>
765+
{logsError}
766+
</div>
767+
) : (
768+
<>
769+
{testResult?.error && (
770+
<div className="tc-logs-error-message">
771+
<span className="tc-logs-section-label">Error</span>
772+
<pre className="tc-logs-pre">{testResult.error}</pre>
773+
</div>
774+
)}
775+
{logs?.stderr && (
776+
<div className="tc-logs-section tc-logs-stderr">
777+
<span className="tc-logs-section-label">stderr</span>
778+
<pre className="tc-logs-pre">{logs.stderr}</pre>
779+
</div>
780+
)}
781+
{logs?.stdout && (
782+
<div className="tc-logs-section tc-logs-stdout">
783+
<span className="tc-logs-section-label">stdout</span>
784+
<pre className="tc-logs-pre">{logs.stdout}</pre>
785+
</div>
786+
)}
787+
{logs && !logs.stdout && !logs.stderr && !testResult?.error && (
788+
<div className="tc-logs-empty">No log output available for this test.</div>
789+
)}
790+
</>
791+
)}
792+
</div>
793+
)}
688794
{hasHideLines ? (
689795
<div className="code-block-wrapper">
690796
<pre className="code-block tc-code-with-hide">
@@ -1093,6 +1199,7 @@ export default function PlaybookPage({ params }: { params: Promise<{ id: string
10931199
setup={props['data-setup'] || ''}
10941200
code={decodeURIComponent(props['data-code'] || '')}
10951201
testResult={testInfo?.result}
1202+
playbookId={id}
10961203
/>
10971204
);
10981205
}

0 commit comments

Comments
 (0)