Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
022fa62
feat(ui): add actor_type to Check interface (DRC-2641)
iamcxa Mar 23, 2026
2dc1af2
feat(ui): add actor badge to CheckCard (DRC-2641)
iamcxa Mar 23, 2026
6ad890a
feat(ui): show actor badge in check detail header (DRC-2641)
iamcxa Mar 23, 2026
5271b32
feat(ui): add RunTimelineEntry component for activity timeline (DRC-2…
iamcxa Mar 23, 2026
a831ed1
feat(ui): integrate run history into Activity timeline (DRC-2641)
iamcxa Mar 23, 2026
d086655
feat(api): run triggered_by + error field + name generation (DRC-2641)
iamcxa Mar 24, 2026
593f877
feat(mcp): create Activity runs for metadata-only checks (DRC-2641)
iamcxa Mar 24, 2026
c9a5bd1
fix(ui): actor badge preset skip + run status mapping (DRC-2641)
iamcxa Mar 24, 2026
8386106
feat(state): export checks in cloud session state upload (DRC-2641)
iamcxa Mar 24, 2026
6d20b41
test: add triggered_by and MCP tool tests (DRC-2641)
iamcxa Mar 25, 2026
a8b5362
fix: include HTTP status and reason in RecceCloudException message (D…
iamcxa Mar 25, 2026
e47ee52
fix(cloud): write-through CheckDAO.create() to keep local state in sy…
iamcxa Mar 25, 2026
c96934f
fix: address PR review findings — status case mismatch, error handlin…
iamcxa Mar 26, 2026
abdfb17
fix(test): update export_state assertion to match checks copy behavio…
iamcxa Mar 26, 2026
4e6292d
fix(DRC-2641): address PR review — test assertions, a11y, error handl…
iamcxa Mar 27, 2026
8548464
test(DRC-2641): add coverage for generate_run_name and metadata error…
iamcxa Mar 27, 2026
30deecc
fix(DRC-2641): address Copilot round 2 — schema param convention, che…
iamcxa Mar 27, 2026
43347a1
fix(DRC-2641): add node_ids fallback to row_count_diff run naming
iamcxa Mar 27, 2026
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
1 change: 1 addition & 0 deletions js/packages/ui/src/api/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface Check<PT = unknown, VO = unknown> {
is_preset?: boolean;
last_run?: Run;
is_outdated?: boolean;
actor_type?: string; // "recce_ai" | "user" | "preset_system" — Cloud-only, undefined in OSS
}

/**
Expand Down
2 changes: 2 additions & 0 deletions js/packages/ui/src/api/types/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export interface BaseRun {
error?: string;
/** Current status of the run */
status?: RunStatus;
/** Who triggered this run: "user" | "recce_ai" */
triggered_by?: string;
}

// ============================================================================
Expand Down
38 changes: 38 additions & 0 deletions js/packages/ui/src/components/check/CheckCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export interface CheckCardData {
* dbt manifest or related artifacts are regenerated (shown in outdated tooltip).
*/
lastRunAt?: string;
/** Who created this check: "recce_ai" | "user" | "preset_system" */
actorType?: string;
}

/**
Expand Down Expand Up @@ -176,6 +178,23 @@ function formatOutdatedTooltip(lastRunAt?: string): string {
}
}

/**
* Badge config for actor type display.
* Exported so downstream components (e.g. CheckDetailOss) can reuse it.
*/
export const ACTOR_BADGE_CONFIG: Record<
string,
{ label: string; color: string; bg: string }
> = {
recce_ai: { label: "AI", color: "#7c3aed", bg: "rgb(124 58 237 / 0.12)" },
user: { label: "User", color: "#059669", bg: "rgb(5 150 105 / 0.12)" },
preset_system: {
label: "Preset",
color: "#2563eb",
bg: "rgb(37 99 235 / 0.12)",
},
};

/**
* CheckCard Component
*
Expand Down Expand Up @@ -332,6 +351,25 @@ function CheckCardComponent({
</Tooltip>
)}

{/* Actor badge — skip for presets (already shown by Preset chip) */}
{check.actorType &&
!check.isPreset &&
ACTOR_BADGE_CONFIG[check.actorType] && (
<Chip
label={ACTOR_BADGE_CONFIG[check.actorType].label}
size="small"
sx={{
height: 20,
fontSize: "0.65rem",
fontWeight: 500,
backgroundColor: ACTOR_BADGE_CONFIG[check.actorType].bg,
color: ACTOR_BADGE_CONFIG[check.actorType].color,
flexShrink: 0,
"& .MuiChip-label": { px: 0.75 },
}}
/>
)}

{/* Preset badge */}
{check.isPreset && (
<Chip
Expand Down
22 changes: 22 additions & 0 deletions js/packages/ui/src/components/check/CheckDetailOss.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import MuiDialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
Expand Down Expand Up @@ -90,6 +91,7 @@ import {
ViewOptionTypes,
} from "../run";
import { toaster } from "../ui";
import { ACTOR_BADGE_CONFIG } from "./CheckCard";
import { LineageDiffViewOss as LineageDiffView } from "./LineageDiffViewOss";
import { SchemaDiffView } from "./SchemaDiffView";
import { CheckTimelineOss as CheckTimeline } from "./timeline/CheckTimelineOss";
Expand Down Expand Up @@ -477,6 +479,26 @@ export function CheckDetailOss({
</MuiTooltip>
)}

{/* Actor badge — skip for presets (already shown by Preset chip) */}
{check?.actor_type &&
!check.is_preset &&
ACTOR_BADGE_CONFIG[check.actor_type] && (
<Chip
label={ACTOR_BADGE_CONFIG[check.actor_type].label}
size="small"
sx={{
height: 20,
fontSize: "0.65rem",
fontWeight: 500,
backgroundColor:
ACTOR_BADGE_CONFIG[check.actor_type].bg,
color: ACTOR_BADGE_CONFIG[check.actor_type].color,
flexShrink: 0,
"& .MuiChip-label": { px: 0.75 },
}}
/>
)}

<IconButton size="small" onClick={handleMenuClick}>
<VscKebabVertical />
</IconButton>
Expand Down
1 change: 1 addition & 0 deletions js/packages/ui/src/components/check/CheckListOss.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const ChecklistItem = ({
isPreset: check.is_preset,
isOutdated: check.is_outdated,
lastRunAt: check.last_run?.run_at,
actorType: check.actor_type,
};

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CheckCard, type CheckCardData } from "../CheckCard";

const baseCheck: CheckCardData = {
id: "check-1",
name: "Row Count Diff of orders",
type: "row_count_diff",
};

describe("CheckCard actor badge", () => {
it("renders AI badge when actorType is recce_ai", () => {
render(<CheckCard check={{ ...baseCheck, actorType: "recce_ai" }} />);
expect(screen.getByText("AI")).toBeInTheDocument();
});

it("skips actor badge when isPreset is true (Preset chip already shown)", () => {
render(
<CheckCard
check={{ ...baseCheck, actorType: "preset_system", isPreset: true }}
/>,
);
// The outlined Preset chip (from isPreset) should exist
const presetChips = screen.getAllByText("Preset");
expect(presetChips).toHaveLength(1); // Only the isPreset chip, no actor badge
});

it("does not render badge when actorType is undefined", () => {
render(<CheckCard check={baseCheck} />);
expect(screen.queryByText("AI")).not.toBeInTheDocument();
expect(screen.queryByText("User")).not.toBeInTheDocument();
expect(screen.queryByText("Preset")).not.toBeInTheDocument();
});

it("renders both AI and Outdated badges together", () => {
render(
<CheckCard
check={{ ...baseCheck, actorType: "recce_ai", isOutdated: true }}
/>,
);
expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByText("Outdated")).toBeInTheDocument();
});
});
132 changes: 121 additions & 11 deletions js/packages/ui/src/components/check/timeline/CheckTimelineOss.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,97 @@ import Divider from "@mui/material/Divider";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { cacheKeys } from "../../../api";
import type { CheckEvent } from "../../../api/checkEvents";
import { listRuns } from "../../../api/runs";
import { useApiConfig, useCheckEvents, useIsDark } from "../../../hooks";
import { fetchUser } from "../../../lib/api/user";
import { CommentInput } from "../../../primitives";
import { type RunEntry, RunTimelineEntry } from "./RunTimelineEntry";
import { TimelineEventOss as TimelineEvent } from "./TimelineEventOss";

// ============================================================================
// Helpers
// ============================================================================

/**
* Map OSS run status to display status using the error field.
* RunStatus enum values are capitalized: "Finished", "Failed", "Running", "Cancelled".
*/
function deriveRunStatus(
status: string | undefined,
error: string | undefined | null,
): string {
const s = status?.toLowerCase();
if (s === "finished") {
return error ? "error" : "success";
}
if (s === "failed") {
return "error";
}
// "running", "cancelled" pass through
return status ?? "unknown";
}

// ============================================================================
// Types
// ============================================================================

export type TimelineEntry =
| { kind: "event"; event: CheckEvent; at: string }
| { kind: "run"; run: RunEntry; index: number; at: string };

// ============================================================================
// Pure merge function (exported for testing)
// ============================================================================

/**
* Merges check events and run entries into a single chronologically sorted
* list (descending — newest first). Runs are numbered from oldest (#1) to
* newest (#N) so the index reflects execution order.
*/
export function mergeTimelineEntries(
events: CheckEvent[],
runs: RunEntry[] | undefined,
): TimelineEntry[] {
const entries: TimelineEntry[] = [];

for (const event of events) {
entries.push({ kind: "event", event, at: event.created_at });
}

if (runs) {
// Sort ascending to assign indices from oldest (#1) to newest (#N)
const sortedRuns = [...runs].sort(
(a, b) => new Date(a.run_at).getTime() - new Date(b.run_at).getTime(),
);
for (let i = 0; i < sortedRuns.length; i++) {
entries.push({
kind: "run",
run: sortedRuns[i],
index: i + 1,
at: sortedRuns[i].run_at,
});
}
}

// Sort descending (newest first) for display
entries.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
return entries;
}

// ============================================================================
// Component
// ============================================================================

interface CheckTimelineProps {
checkId: string;
}

export function CheckTimelineOss({ checkId }: CheckTimelineProps) {
const isDark = useIsDark();
const { apiClient } = useApiConfig();
const { apiClient, authToken } = useApiConfig();
const {
events,
isLoading,
Expand All @@ -45,6 +123,32 @@ export function CheckTimelineOss({ checkId }: CheckTimelineProps) {
retry: false,
});

// Fetch runs only in cloudMode (authToken present = Cloud)
const { data: checkRuns } = useQuery({
queryKey: ["check-runs", checkId],
queryFn: async () => {
const allRuns = await listRuns(apiClient);
return allRuns
.filter((r) => r.check_id === checkId)
.map(
(r): RunEntry => ({
run_id: r.run_id,
run_at: r.run_at,
status: deriveRunStatus(r.status, r.error),
summary: r.error || undefined,
triggered_by: r.triggered_by,
}),
);
},
Comment on lines +126 to +142
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CheckTimelineOss fetches all runs via listRuns() and then filters client-side by check_id. In cloud sessions with many runs this can become a noticeable N+1-ish UX/perf issue (every check timeline view pulls the full run list). Prefer adding an API filter (e.g. /api/runs?check_id=...) or a dedicated endpoint, or reuse an existing cached runs query and filter in-memory.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid concern. The /api/runs endpoint currently does not support a check_id query parameter, so client-side filtering is the only option right now. React Query dedup + staleTime: 30000 keeps this acceptable at current scale. Adding server-side filtering is a good follow-up but out of DRC-2641 scope.

enabled: !!authToken,
staleTime: 30000,
});

const timelineEntries = useMemo(
() => mergeTimelineEntries(events, checkRuns),
[events, checkRuns],
);

const handleCreateComment = (content: string) => {
createComment(content);
};
Expand Down Expand Up @@ -117,21 +221,27 @@ export function CheckTimelineOss({ checkId }: CheckTimelineProps) {

{/* Events List - Scrollable */}
<Box sx={{ flex: 1, overflowY: "auto", px: 3, py: 2 }}>
{events.length === 0 ? (
{timelineEntries.length === 0 ? (
<Typography sx={{ fontSize: "0.875rem", color: "grey.500" }}>
No activity yet
</Typography>
) : (
<Stack sx={{ alignItems: "stretch" }} spacing={0}>
{events.map((event, index) => (
<Box key={event.id}>
<TimelineEvent
event={event}
currentUserId={currentUser?.id}
onEdit={handleEditComment}
onDelete={handleDeleteComment}
/>
{index < events.length - 1 && (
{timelineEntries.map((entry, index) => (
<Box
key={entry.kind === "event" ? entry.event.id : entry.run.run_id}
>
{entry.kind === "event" ? (
<TimelineEvent
event={entry.event}
currentUserId={currentUser?.id}
onEdit={handleEditComment}
onDelete={handleDeleteComment}
/>
) : (
<RunTimelineEntry run={entry.run} index={entry.index} />
)}
{index < timelineEntries.length - 1 && (
<Divider
sx={{ borderColor: isDark ? "grey.700" : "grey.100" }}
/>
Expand Down
Loading
Loading