@@ -81,15 +81,20 @@ fn tabStrip(ui: *Ui, model: *const Model) Node {
8181fn activeBody (ui : * Ui , model : * const Model ) Node {
8282 const t = model .activeTrace ().? ;
8383 if (t .isError ()) return errorPanel (ui , t );
84- // Timeline (left) / detail (right), a model-owned splitter that echoes each
85- // drag back through `timeline_split`.
86- return ui .split (.{
87- .grow = 1 ,
88- .value = model .timeline_split ,
89- .on_resize = Ui .valueMsg (.timeline_resized ),
90- }, .{
91- timelinePane (ui , t ),
92- detailPane (ui , t ),
84+ // Timeline (left) / detail (right) fill the space above a fixed-height
85+ // failure drawer. `split` is horizontal-only, so the vertical stack is a
86+ // column: the split grows, the drawer keeps its height.
87+ return ui .column (.{ .grow = 1 }, .{
88+ ui .split (.{
89+ .grow = 1 ,
90+ .value = model .timeline_split ,
91+ .on_resize = Ui .valueMsg (.timeline_resized ),
92+ }, .{
93+ timelinePane (ui , t ),
94+ detailPane (ui , t ),
95+ }),
96+ ui .separator (.{}),
97+ failuresPanel (ui , t ),
9398 });
9499}
95100
@@ -675,19 +680,228 @@ fn append(a: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) void {
675680 buf .appendSlice (a , s ) catch {};
676681}
677682
683+ // --- failure panel (#31) ---------------------------------------------------
684+ //
685+ // Every detected failure for the active trace, ranked critical -> warning ->
686+ // info (then by first event), in a fixed-height drawer beneath the timeline.
687+ // Selecting a failure expands its remediation steps (accordion, at most one
688+ // open) and jumps to its primary event so the failure and its evidence align.
689+
690+ const failures_panel_height : f32 = 240 ;
691+ const max_failures_shown : usize = 50 ;
692+
693+ fn failuresPanel (ui : * Ui , t : * const LoadedTrace ) Node {
694+ return ui .column (.{ .height = failures_panel_height }, .{
695+ failuresHeader (ui , t ),
696+ ui .separator (.{}),
697+ failuresBody (ui , t ),
698+ });
699+ }
700+
701+ fn failuresHeader (ui : * Ui , t : * const LoadedTrace ) Node {
702+ return ui .row (.{ .padding = 8 , .gap = 8 , .cross = .center }, .{
703+ ui .text (.{}, "Failures" ),
704+ ui .spacer (1 ),
705+ ui .text (.{ .style_tokens = .{ .foreground = .text_muted } }, failuresSummary (ui .arena , t .failures )),
706+ });
707+ }
708+
709+ fn failuresBody (ui : * Ui , t : * const LoadedTrace ) Node {
710+ if (t .detection_skipped )
711+ return centeredNote (ui , "Failure detection was skipped for this large trace" );
712+ if (t .failures .len == 0 )
713+ return centeredNote (ui , "No failures detected" );
714+
715+ const order = sortedFailureIndices (ui .arena , t .failures );
716+ const shown = @min (order .len , max_failures_shown );
717+ const extra : usize = if (order .len > shown ) 1 else 0 ;
718+ const rows = ui .arena .alloc (Node , shown + extra ) catch {
719+ ui .failed = true ;
720+ return centeredNote (ui , "" );
721+ };
722+ for (0.. shown ) | k | rows [k ] = failureRow (ui , t , order [k ]);
723+ if (extra == 1 ) {
724+ const label = std .fmt .allocPrint (ui .arena , "... {d} more" , .{order .len - shown }) catch "... more" ;
725+ rows [shown ] = moreRow (ui , label , 0 );
726+ }
727+ return ui .scroll (.{ .grow = 1 }, .{
728+ ui .column (.{ .gap = 4 , .padding = 8 }, rows ),
729+ });
730+ }
731+
732+ fn centeredNote (ui : * Ui , text : []const u8 ) Node {
733+ return ui .column (.{ .grow = 1 , .main = .center , .cross = .center , .padding = 16 }, .{
734+ ui .text (.{ .style_tokens = .{ .foreground = .text_muted } }, text ),
735+ });
736+ }
737+
738+ fn failureRow (ui : * Ui , t : * const LoadedTrace , i : usize ) Node {
739+ const f = t .failures [i ];
740+ const expanded = (t .expanded_failure == i );
741+ const header = ui .row (.{
742+ .on_press = .{ .select_failure = i },
743+ .selected = expanded ,
744+ .padding = 6 ,
745+ .gap = 8 ,
746+ .cross = .center ,
747+ .semantics = .{ .label = f .code .toWire () },
748+ }, .{
749+ severityDot (ui , f .severity ),
750+ ui .text (.{ .style_tokens = .{ .foreground = severityColor (f .severity ) } }, f .code .toWire ()),
751+ ui .text (.{ .grow = 1 }, f .description ),
752+ disclosureGlyph (ui , true , expanded ),
753+ });
754+ if (! expanded ) return header ;
755+ return ui .column (.{ .gap = 4 }, .{
756+ header ,
757+ failureDetail (ui , f ),
758+ });
759+ }
760+
761+ fn failureDetail (ui : * Ui , f : types.Failure ) Node {
762+ var items : std .ArrayList (Node ) = .empty ;
763+ items .append (ui .arena , detailRow (ui , "Affected" , joinEventIds (ui .arena , f .event_ids ))) catch {
764+ ui .failed = true ;
765+ };
766+ if (f .suggested_steps .len > 0 ) {
767+ items .append (ui .arena , ui .text (.{ .style_tokens = .{ .foreground = .text_muted } }, "Suggested steps" )) catch {};
768+ for (f .suggested_steps ) | step | {
769+ items .append (ui .arena , stepRow (ui , step )) catch {};
770+ }
771+ }
772+ return ui .column (.{ .gap = 4 , .padding = 8 }, items .items );
773+ }
774+
775+ fn stepRow (ui : * Ui , step : []const u8 ) Node {
776+ return ui .row (.{ .gap = 6 , .cross = .start }, .{
777+ ui .text (.{ .width = 16 , .style_tokens = .{ .foreground = .text_muted } }, "\u{00B7} " ),
778+ ui .text (.{ .grow = 1 }, step ),
779+ });
780+ }
781+
782+ /// Affected event ids as a compact comma-joined list, capped so a many-event
783+ /// failure stays readable.
784+ fn joinEventIds (arena : std.mem.Allocator , ids : []const []const u8 ) []const u8 {
785+ if (ids .len == 0 ) return "none" ;
786+ const cap : usize = 6 ;
787+ const shown = @min (ids .len , cap );
788+ var buf : std .ArrayList (u8 ) = .empty ;
789+ for (ids [0.. shown ], 0.. ) | id , i | {
790+ if (i > 0 ) buf .appendSlice (arena , ", " ) catch {};
791+ buf .appendSlice (arena , id ) catch {};
792+ }
793+ if (ids .len > shown ) {
794+ buf .appendSlice (arena , std .fmt .allocPrint (arena , ", +{d} more" , .{ids .len - shown }) catch "" ) catch {};
795+ }
796+ return buf .items ;
797+ }
798+
799+ /// Display order for the failure list: severity (critical -> warning -> info),
800+ /// then first affected event id. Returns indices into `failures`; the engine's
801+ /// slice is never mutated.
802+ fn sortedFailureIndices (arena : std.mem.Allocator , failures : []const types.Failure ) []usize {
803+ const idx = arena .alloc (usize , failures .len ) catch return &.{};
804+ for (idx , 0.. ) | * v , i | v .* = i ;
805+ std .mem .sort (usize , idx , failures , lessFailure );
806+ return idx ;
807+ }
808+
809+ fn lessFailure (failures : []const types.Failure , a : usize , b : usize ) bool {
810+ const fa = failures [a ];
811+ const fb = failures [b ];
812+ const ra = severityRank (fa .severity );
813+ const rb = severityRank (fb .severity );
814+ if (ra != rb ) return ra < rb ;
815+ const ea = if (fa .event_ids .len > 0 ) fa .event_ids [0 ] else "" ;
816+ const eb = if (fb .event_ids .len > 0 ) fb .event_ids [0 ] else "" ;
817+ return std .mem .order (u8 , ea , eb ) == .lt ;
818+ }
819+
820+ /// "N failures: C critical, W warning, I info" (nonzero severities only), or
821+ /// "no failures". Shared by the failure-panel header and the status bar.
822+ fn failuresSummary (arena : std.mem.Allocator , failures : []const types.Failure ) []const u8 {
823+ if (failures .len == 0 ) return "no failures" ;
824+ var counts = [_ ]usize { 0 , 0 , 0 }; // critical, warning, info
825+ for (failures ) | f | counts [severityRank (f .severity )] += 1 ;
826+
827+ var buf : std .ArrayList (u8 ) = .empty ;
828+ buf .appendSlice (arena , std .fmt .allocPrint (arena , "{d} failure{s}" , .{ failures .len , if (failures .len == 1 ) "" else "s" }) catch "" ) catch {};
829+ var first = true ;
830+ for ([_ ][]const u8 { "critical" , "warning" , "info" }, 0.. ) | word , rank | {
831+ if (counts [rank ] > 0 ) {
832+ const sep = if (first ) ": " else ", " ;
833+ buf .appendSlice (arena , std .fmt .allocPrint (arena , "{s}{d} {s}" , .{ sep , counts [rank ], word }) catch "" ) catch {};
834+ first = false ;
835+ }
836+ }
837+ return buf .items ;
838+ }
839+
678840// --- status bar ------------------------------------------------------------
679841
680842fn statusBar (ui : * Ui , model : * const Model ) Node {
681843 const t = model .activeTrace ().? ;
682844 const text = if (t .isError ())
683845 std .fmt .allocPrint (ui .arena , "Failed to load {s}: {s}" , .{ t .name , t .load_error orelse "unknown error" }) catch "load failed"
684846 else if (t .detection_skipped )
685- std .fmt .allocPrint (ui .arena , "{d} events \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} warnings" , .{
847+ std .fmt .allocPrint (ui .arena , "{d} events \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} parse warnings" , .{
686848 t .eventCount (), t .sessionCount (), t .warningCount (),
687849 }) catch ""
688850 else
689- std .fmt .allocPrint (ui .arena , "{d} events \u{00B7} {d} sessions \u{00B7} {d} failures \u{00B7} {d} warnings" , .{
690- t .eventCount (), t .sessionCount (), t . failureCount ( ), t .warningCount (),
851+ std .fmt .allocPrint (ui .arena , "{d} events \u{00B7} {d} sessions \u{00B7} {s} \u{00B7} {d} parse warnings" , .{
852+ t .eventCount (), t .sessionCount (), failuresSummary ( ui . arena , t . failures ), t .warningCount (),
691853 }) catch "" ;
692854 return ui .statusBar (.{}, text );
693855}
856+
857+ // ---------------------------------------------------------------------------
858+ // Tests — pure view helpers (widget-level behavior is covered in tests.zig)
859+ // ---------------------------------------------------------------------------
860+
861+ const testing = std .testing ;
862+
863+ fn testFailure (code : types.FailureCode , severity : types.FailureSeverity , comptime first_event : []const u8 ) types.Failure {
864+ return .{
865+ .code = code ,
866+ .description = "test" ,
867+ .severity = severity ,
868+ // `first_event` is comptime so `&.{first_event}` is a static array, not
869+ // a dangling pointer into this frame.
870+ .event_ids = &.{first_event },
871+ .suggested_steps = &.{},
872+ };
873+ }
874+
875+ test "failures sort critical -> warning -> info, then by first event" {
876+ const failures = [_ ]types.Failure {
877+ testFailure (.slow_response , .warning , "evt-0005" ),
878+ testFailure (.connector_fault , .critical , "evt-0009" ),
879+ testFailure (.heartbeat_interval_violation , .info , "evt-0002" ),
880+ testFailure (.failed_authorization , .warning , "evt-0003" ),
881+ testFailure (.station_offline_during_session , .critical , "evt-0001" ),
882+ };
883+ var arena_state = std .heap .ArenaAllocator .init (testing .allocator );
884+ defer arena_state .deinit ();
885+
886+ const order = sortedFailureIndices (arena_state .allocator (), & failures );
887+ // critical (evt-0001, evt-0009), then warning (evt-0003, evt-0005), then info.
888+ try testing .expectEqualSlices (usize , &.{ 4 , 1 , 3 , 0 , 2 }, order );
889+ }
890+
891+ test "failuresSummary breaks down nonzero severities" {
892+ var arena_state = std .heap .ArenaAllocator .init (testing .allocator );
893+ defer arena_state .deinit ();
894+ const a = arena_state .allocator ();
895+
896+ try testing .expectEqualStrings ("no failures" , failuresSummary (a , &.{}));
897+
898+ const failures = [_ ]types.Failure {
899+ testFailure (.connector_fault , .critical , "evt-0001" ),
900+ testFailure (.slow_response , .warning , "evt-0002" ),
901+ testFailure (.failed_authorization , .warning , "evt-0003" ),
902+ };
903+ try testing .expectEqualStrings ("3 failures: 1 critical, 2 warning" , failuresSummary (a , & failures ));
904+
905+ const one = [_ ]types.Failure {testFailure (.heartbeat_interval_violation , .info , "evt-0001" )};
906+ try testing .expectEqualStrings ("1 failure: 1 info" , failuresSummary (a , & one ));
907+ }
0 commit comments