mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2025-12-21 08:40:10 -06:00
Feat: Show statuses of run filters with colors (#2325)
* feat: hover / selected + unselected states to indicate applied filters * feat: implement the rest of the states * fix: handle unselect + equality checks properly * fix: font weight on selected filters
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
flattenDAGsKey,
|
||||
createdAfterKey,
|
||||
finishedBeforeKey,
|
||||
statusKey,
|
||||
} from '@/pages/main/v1/workflow-runs-v1/components/v1/task-runs-columns';
|
||||
import { ToolbarFilters } from './data-table-toolbar';
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
import { DateTimePicker } from '@/components/v1/molecules/time-picker/date-time-picker';
|
||||
import { XCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { V1TaskStatus } from '@/lib/api';
|
||||
|
||||
interface FilterControlProps<TData> {
|
||||
column?: Column<TData, any>;
|
||||
@@ -433,6 +435,15 @@ interface DataTableOptionsProps<TData> {
|
||||
onResetFilters?: () => void;
|
||||
}
|
||||
|
||||
function arraysEqual<T>(a: T[], b: T[]) {
|
||||
return (
|
||||
Array.isArray(a) &&
|
||||
Array.isArray(b) &&
|
||||
a.length === b.length &&
|
||||
a.every((val, index) => val === b[index])
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableOptions<TData>({
|
||||
table,
|
||||
filters,
|
||||
@@ -444,6 +455,13 @@ export function DataTableOptions<TData>({
|
||||
const activeFiltersCount = React.useMemo(
|
||||
() =>
|
||||
cf?.filter((f) => {
|
||||
if (
|
||||
f.id === statusKey &&
|
||||
arraysEqual(f.value as V1TaskStatus[], Object.values(V1TaskStatus))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (f.id === createdAfterKey || f.id === finishedBeforeKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ function Sidebar({ className, memberships }: SidebarProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-full border-r overflow-y-auto w-full md:min-w-80 md:w-80 top-16 absolute z-[100] md:relative md:top-0 md:bg-[unset] md:dark:bg-[unset] bg-slate-100 dark:bg-slate-900',
|
||||
'h-full border-r overflow-y-auto w-full md:min-w-64 md:w-64 top-16 absolute z-[100] md:relative md:top-0 md:bg-[unset] md:dark:bg-[unset] bg-slate-100 dark:bg-slate-900',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { V1TaskRunMetrics, V1TaskStatus } from '@/lib/api';
|
||||
import { Badge, badgeVariants } from '@/components/v1/ui/badge';
|
||||
import { VariantProps } from 'class-variance-authority';
|
||||
import { V1TaskStatus } from '@/lib/api';
|
||||
import { Badge } from '@/components/v1/ui/badge';
|
||||
import { useRunsContext } from '../hooks/runs-provider';
|
||||
import { getStatusesFromFilters } from '../hooks/use-runs-table-state';
|
||||
import { CheckCircleIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
import { PlayIcon, X, Ban, ChartColumn } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
function statusToFriendlyName(status: V1TaskStatus) {
|
||||
switch (status) {
|
||||
@@ -44,18 +44,47 @@ function statusToIcon(status: V1TaskStatus) {
|
||||
}
|
||||
|
||||
function MetricBadge({
|
||||
metrics,
|
||||
status,
|
||||
onClick,
|
||||
variant,
|
||||
className,
|
||||
}: {
|
||||
metrics: V1TaskRunMetrics;
|
||||
status: V1TaskStatus;
|
||||
onClick?: (status: V1TaskStatus) => void;
|
||||
variant: VariantProps<typeof badgeVariants>['variant'];
|
||||
className?: string;
|
||||
}) {
|
||||
const { filters, metrics } = useRunsContext();
|
||||
const currentStatuses = getStatusesFromFilters(filters.columnFilters);
|
||||
const isSelected = currentStatuses.includes(status);
|
||||
const { setStatuses } = filters;
|
||||
|
||||
const handleStatusClick = useCallback(
|
||||
(status: V1TaskStatus) => {
|
||||
const currentStatuses = getStatusesFromFilters(filters.columnFilters);
|
||||
const isSelected = currentStatuses.includes(status);
|
||||
|
||||
const allStatuses = Object.values(V1TaskStatus);
|
||||
|
||||
const isAllSelected =
|
||||
currentStatuses.length === allStatuses.length &&
|
||||
allStatuses.every((s) => currentStatuses.includes(s));
|
||||
|
||||
if (isSelected) {
|
||||
if (isAllSelected) {
|
||||
setStatuses([status]);
|
||||
} else {
|
||||
const newStatuses = currentStatuses.filter((s) => s !== status);
|
||||
|
||||
if (newStatuses.length === 0) {
|
||||
setStatuses(allStatuses);
|
||||
} else {
|
||||
setStatuses(newStatuses);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setStatuses([...currentStatuses, status]);
|
||||
}
|
||||
},
|
||||
[filters.columnFilters, setStatuses],
|
||||
);
|
||||
|
||||
const metric = metrics.find((m) => m.status === status);
|
||||
|
||||
if (!metric) {
|
||||
@@ -68,9 +97,13 @@ function MetricBadge({
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn('cursor-pointer text-sm px-3 py-1 w-fit h-8', className)}
|
||||
onClick={() => onClick?.(status)}
|
||||
data-is-selected={isSelected}
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'cursor-pointer text-sm px-3 py-1 w-fit h-8 data-[is-selected=false]:font-light',
|
||||
className,
|
||||
)}
|
||||
onClick={() => handleStatusClick(status)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{formattedCount}</span>
|
||||
@@ -83,65 +116,91 @@ function MetricBadge({
|
||||
|
||||
export const V1WorkflowRunsMetricsView = () => {
|
||||
const {
|
||||
metrics,
|
||||
filters,
|
||||
display: { hideMetrics },
|
||||
actions: { updateUIState },
|
||||
} = useRunsContext();
|
||||
|
||||
const { setStatuses } = filters;
|
||||
|
||||
const onViewQueueMetricsClick = () => {
|
||||
updateUIState({ viewQueueMetrics: true });
|
||||
};
|
||||
|
||||
const handleStatusClick = (status: V1TaskStatus) => {
|
||||
const currentStatuses = getStatusesFromFilters(filters.columnFilters);
|
||||
const isSelected = currentStatuses.includes(status);
|
||||
|
||||
if (isSelected) {
|
||||
setStatuses(currentStatuses.filter((s) => s !== status));
|
||||
} else {
|
||||
setStatuses([...currentStatuses, status]);
|
||||
}
|
||||
};
|
||||
|
||||
// format of className strings is:
|
||||
// default, then unselected, then selected, then hover+selected, then hover+unselected
|
||||
return (
|
||||
<dl className="flex flex-row justify-start gap-2">
|
||||
<div className="flex flex-row justify-start gap-2">
|
||||
<MetricBadge
|
||||
metrics={metrics}
|
||||
status={V1TaskStatus.COMPLETED}
|
||||
onClick={handleStatusClick}
|
||||
variant="successful"
|
||||
className={`
|
||||
text-green-800 dark:text-green-300
|
||||
|
||||
data-[is-selected=false]:border data-[is-selected=false]:border-green-500/20
|
||||
|
||||
data-[is-selected=true]:bg-green-500/20
|
||||
|
||||
hover:data-[is-selected=true]:bg-green-500/20
|
||||
|
||||
hover:data-[is-selected=false]:bg-green-500/20 hover:data-[is-selected=false]:border-transparent
|
||||
`}
|
||||
/>
|
||||
|
||||
<MetricBadge
|
||||
metrics={metrics}
|
||||
status={V1TaskStatus.RUNNING}
|
||||
onClick={handleStatusClick}
|
||||
variant="inProgress"
|
||||
className={`
|
||||
text-yellow-800 dark:text-yellow-300
|
||||
|
||||
data-[is-selected=false]:border data-[is-selected=false]:border-yellow-500/20
|
||||
|
||||
data-[is-selected=true]:bg-yellow-500/20
|
||||
|
||||
hover:data-[is-selected=true]:bg-yellow-500/20
|
||||
|
||||
hover:data-[is-selected=false]:bg-yellow-500/20 hover:data-[is-selected=false]:border-transparent
|
||||
`}
|
||||
/>
|
||||
|
||||
<MetricBadge
|
||||
metrics={metrics}
|
||||
status={V1TaskStatus.FAILED}
|
||||
onClick={handleStatusClick}
|
||||
variant="failed"
|
||||
className={`
|
||||
text-red-800 dark:text-red-300
|
||||
|
||||
data-[is-selected=false]:border data-[is-selected=false]:border-red-500/20
|
||||
|
||||
data-[is-selected=true]:bg-red-500/20
|
||||
|
||||
hover:data-[is-selected=true]:bg-red-500/20
|
||||
|
||||
hover:data-[is-selected=false]:bg-red-500/20 hover:data-[is-selected=false]:border-transparent
|
||||
`}
|
||||
/>
|
||||
|
||||
<MetricBadge
|
||||
metrics={metrics}
|
||||
status={V1TaskStatus.CANCELLED}
|
||||
onClick={handleStatusClick}
|
||||
variant="outlineDestructive"
|
||||
className={`
|
||||
text-orange-800 dark:text-orange-300
|
||||
|
||||
data-[is-selected=false]:border data-[is-selected=false]:border-orange-500/20
|
||||
|
||||
data-[is-selected=true]:bg-orange-500/20
|
||||
|
||||
hover:data-[is-selected=true]:bg-orange-500/20
|
||||
|
||||
hover:data-[is-selected=false]:bg-orange-500/20 hover:data-[is-selected=false]:border-transparent
|
||||
`}
|
||||
/>
|
||||
|
||||
<MetricBadge
|
||||
metrics={metrics}
|
||||
status={V1TaskStatus.QUEUED}
|
||||
onClick={handleStatusClick}
|
||||
variant="outline"
|
||||
className="rounded-sm font-normal"
|
||||
className={`
|
||||
text-fuchsia-800 dark:text-fuchsia-300
|
||||
|
||||
data-[is-selected=false]:border data-[is-selected=false]:border-fuchsia-500/20
|
||||
|
||||
data-[is-selected=true]:bg-fuchsia-500/20
|
||||
|
||||
hover:data-[is-selected=true]:bg-fuchsia-500/20
|
||||
|
||||
hover:data-[is-selected=false]:bg-fuchsia-500/20 hover:data-[is-selected=false]:border-transparent
|
||||
`}
|
||||
/>
|
||||
|
||||
{!hideMetrics && (
|
||||
@@ -154,6 +213,6 @@ export const V1WorkflowRunsMetricsView = () => {
|
||||
<ChartColumn className="size-4 cq-xl:hidden" />
|
||||
</Badge>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,7 +45,9 @@ const createApiFilterSchema = (initialValues?: { workflowIds?: string[] }) =>
|
||||
z.object({
|
||||
s: z.string().default(() => getCreatedAfterFromTimeRange('1d')), // since
|
||||
u: z.string().optional(), // until
|
||||
st: z.array(z.nativeEnum(V1TaskStatus)).optional(), // statuses
|
||||
st: z
|
||||
.array(z.nativeEnum(V1TaskStatus))
|
||||
.default(() => Object.values(V1TaskStatus)), // statuses
|
||||
w: z // workflow ids
|
||||
.array(z.string())
|
||||
.optional()
|
||||
@@ -123,12 +125,12 @@ export const useRunsTableFilters = (
|
||||
|
||||
const setStatuses = useCallback(
|
||||
(statuses: V1TaskStatus[]) => {
|
||||
const newColumnFilters =
|
||||
statuses.length > 0
|
||||
? columnFilters
|
||||
.filter((f) => f.id !== statusKey)
|
||||
.concat([{ id: statusKey, value: statuses }])
|
||||
: columnFilters.filter((f) => f.id !== statusKey);
|
||||
const finalStatuses =
|
||||
statuses.length > 0 ? statuses : Object.values(V1TaskStatus);
|
||||
|
||||
const newColumnFilters = columnFilters
|
||||
.filter((f) => f.id !== statusKey)
|
||||
.concat([{ id: statusKey, value: finalStatuses }]);
|
||||
|
||||
setColumnFilters(newColumnFilters);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user