feat(webapp): expand the sample tree and let it page through recent runs
The smart-column sample now renders fully expanded (no collapse), and a run picker steps through the most recent runs so you can find one that has the value you're after when the newest run doesn't.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { BoltIcon } from "@heroicons/react/20/solid";
|
||||
import { BoltIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
@@ -60,6 +60,7 @@ export function AddSmartColumnDialog({
|
||||
const [label, setLabel] = useState("");
|
||||
const [labelEdited, setLabelEdited] = useState(false);
|
||||
const [displayAs, setDisplayAs] = useState<SmartColumnDisplay>("text");
|
||||
const [sampleIndex, setSampleIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -68,6 +69,7 @@ export function AddSmartColumnDialog({
|
||||
setLabel(editing?.label ?? "");
|
||||
setLabelEdited(editing !== null);
|
||||
setDisplayAs(editing?.displayAs ?? "text");
|
||||
setSampleIndex(0);
|
||||
}, [open, editing]);
|
||||
|
||||
const sampleUrl = useMemo(() => {
|
||||
@@ -84,7 +86,9 @@ export function AddSmartColumnDialog({
|
||||
|
||||
const effectiveLabel = labelEdited ? label : labelFromPath(path);
|
||||
|
||||
const sampleRun = sample.data?.run ?? null;
|
||||
const sampleRuns = sample.data?.runs ?? [];
|
||||
const clampedIndex = sampleRuns.length > 0 ? Math.min(sampleIndex, sampleRuns.length - 1) : 0;
|
||||
const sampleRun = sampleRuns[clampedIndex] ?? null;
|
||||
|
||||
const parsed = useMemo(() => {
|
||||
if (!sampleRun) return undefined;
|
||||
@@ -195,9 +199,17 @@ export function AddSmartColumnDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 self-start rounded-lg border border-grid-dimmed bg-background-dimmed p-3">
|
||||
<Paragraph variant="extra-extra-small/dimmed/caps">
|
||||
Sample — {source} of the newest run
|
||||
</Paragraph>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Paragraph variant="extra-extra-small/dimmed/caps">Sample — {source}</Paragraph>
|
||||
{sampleRuns.length > 0 && (
|
||||
<SampleRunPicker
|
||||
index={clampedIndex}
|
||||
total={sampleRuns.length}
|
||||
onPrev={() => setSampleIndex((i) => Math.max(0, i - 1))}
|
||||
onNext={() => setSampleIndex((i) => Math.min(sampleRuns.length - 1, i + 1))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{sample.state === "loading" ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Loading…
|
||||
@@ -231,12 +243,6 @@ export function AddSmartColumnDialog({
|
||||
Resolves to
|
||||
</Paragraph>
|
||||
<SmartColumnResolvedPreview label={effectiveLabel} resolved={resolved} />
|
||||
{sampleRun && (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Against {sampleRun.friendlyId}
|
||||
{sampleRun.hasFinished ? "" : " · still running"}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,6 +259,44 @@ export function AddSmartColumnDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function SampleRunPicker({
|
||||
index,
|
||||
total,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
index: number;
|
||||
total: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-none items-center gap-1 text-xs text-text-dimmed">
|
||||
<span className="tabular-nums">
|
||||
{index + 1}/{total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
aria-label="Newer run"
|
||||
className="flex size-5 items-center justify-center rounded hover:bg-charcoal-750 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={index >= total - 1}
|
||||
aria-label="Older run"
|
||||
className="flex size-5 items-center justify-center rounded hover:bg-charcoal-750 disabled:opacity-30"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCard({
|
||||
label,
|
||||
description,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
/** Max children rendered per node so a large blob can't blow up the DOM. */
|
||||
const MAX_CHILDREN = 200;
|
||||
/** Levels auto-expanded; deeper nodes start collapsed and open on click. */
|
||||
const AUTO_OPEN_DEPTH = 2;
|
||||
const MAX_STRING = 80;
|
||||
|
||||
/**
|
||||
* A clickable, syntax-colored JSON tree for the smart-column sample. Only leaf
|
||||
* values are selectable: clicking one fills the JSON path field via
|
||||
* `onSelectPath` and highlights it. Object/array rows only expand and collapse,
|
||||
* so you drill into a container and pick a leaf inside it.
|
||||
* A clickable, syntax-colored JSON tree for the smart-column sample, rendered
|
||||
* fully expanded. Only leaf values are selectable: clicking one fills the JSON
|
||||
* path field via `onSelectPath` and highlights it. Objects and arrays are shown
|
||||
* inline (not clickable) so you can see the shape and pick a leaf inside them.
|
||||
*/
|
||||
export function SmartColumnSample({
|
||||
value,
|
||||
@@ -28,7 +25,6 @@ export function SmartColumnSample({
|
||||
name={undefined}
|
||||
path="$"
|
||||
value={value}
|
||||
depth={0}
|
||||
activePath={activePath}
|
||||
onSelectPath={onSelectPath}
|
||||
/>
|
||||
@@ -46,18 +42,15 @@ function JsonNode({
|
||||
name,
|
||||
path,
|
||||
value,
|
||||
depth,
|
||||
activePath,
|
||||
onSelectPath,
|
||||
}: {
|
||||
name: string | number | undefined;
|
||||
path: string;
|
||||
value: unknown;
|
||||
depth: number;
|
||||
activePath: string;
|
||||
onSelectPath: (path: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(depth < AUTO_OPEN_DEPTH);
|
||||
const isObject = value !== null && typeof value === "object";
|
||||
const selected = path === activePath;
|
||||
const keyLabel = name === undefined ? null : typeof name === "number" ? name : `"${name}"`;
|
||||
@@ -90,43 +83,27 @@ function JsonNode({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-start whitespace-pre rounded px-0.5 text-left hover:bg-charcoal-750"
|
||||
>
|
||||
<span className="mr-1 w-3 shrink-0 text-text-dimmed">{open ? "▾" : "▸"}</span>
|
||||
<div className="whitespace-pre px-0.5">
|
||||
{keyLabel !== null && <span className="text-sky-300">{keyLabel}</span>}
|
||||
{keyLabel !== null && <span className="text-text-dimmed">: </span>}
|
||||
<span className="text-text-dimmed">
|
||||
{openBrace}
|
||||
{!open && `… ${closeBrace}`}
|
||||
{!open && entries.length > 0 && (
|
||||
<span className="ml-1 text-faint">{`${entries.length} ${isArray ? "items" : "keys"}`}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ml-[0.4rem] border-l border-grid-dimmed/50 pl-3">
|
||||
{shown.map(([key, childValue]) => (
|
||||
<JsonNode
|
||||
key={String(key)}
|
||||
name={key}
|
||||
path={childPath(path, key)}
|
||||
value={childValue}
|
||||
depth={depth + 1}
|
||||
activePath={activePath}
|
||||
onSelectPath={onSelectPath}
|
||||
/>
|
||||
))}
|
||||
{entries.length > MAX_CHILDREN && (
|
||||
<div className="text-text-dimmed">… {entries.length - MAX_CHILDREN} more</div>
|
||||
)}
|
||||
<div className="text-text-dimmed">{closeBrace}</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-text-dimmed">{openBrace}</span>
|
||||
</div>
|
||||
<div className="ml-[0.4rem] border-l border-grid-dimmed/50 pl-3">
|
||||
{shown.map(([key, childValue]) => (
|
||||
<JsonNode
|
||||
key={String(key)}
|
||||
name={key}
|
||||
path={childPath(path, key)}
|
||||
value={childValue}
|
||||
activePath={activePath}
|
||||
onSelectPath={onSelectPath}
|
||||
/>
|
||||
))}
|
||||
{entries.length > MAX_CHILDREN && (
|
||||
<div className="text-text-dimmed">… {entries.length - MAX_CHILDREN} more</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-pre px-0.5 text-text-dimmed">{closeBrace}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-11
@@ -7,10 +7,14 @@ import { RunsRepository } from "~/services/runsRepository/runsRepository.server"
|
||||
import { $replica } from "~/db.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
|
||||
/** How many recent runs the smart-column preview can page through. */
|
||||
const SAMPLE_RUN_COUNT = 10;
|
||||
|
||||
/**
|
||||
* Newest run for the current filters, with its raw payload/metadata/output
|
||||
* packets, feeding the "Add smart column" live preview. The client parses and
|
||||
* resolves the JSON path; the server never parses (same rule as the list).
|
||||
* The most recent runs for the current filters, with their raw
|
||||
* payload/metadata/output packets, feeding the "Add smart column" preview. The
|
||||
* client picks which run to sample, parses, and resolves the JSON path; the
|
||||
* server never parses (same rule as the list).
|
||||
*/
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const { project, environment } = await loadProjectEnvironmentFromRequest(request, params);
|
||||
@@ -42,16 +46,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
machines: filters.machines,
|
||||
errorId: filters.errorId,
|
||||
runSelect: deriveRunSelect([], ["payload", "metadata", "output"]),
|
||||
page: { size: 1 },
|
||||
page: { size: SAMPLE_RUN_COUNT },
|
||||
});
|
||||
|
||||
const run = runs[0];
|
||||
if (!run) {
|
||||
return { run: null };
|
||||
}
|
||||
|
||||
return {
|
||||
run: {
|
||||
runs: runs.map((run) => ({
|
||||
friendlyId: run.friendlyId,
|
||||
status: run.status,
|
||||
hasFinished: isFinalRunStatus(run.status),
|
||||
@@ -63,6 +62,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
metadataType: run.metadataType,
|
||||
output: run.output,
|
||||
outputType: run.outputType,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user