Просмотр исходного кода

feat(dashboard): Add system views (#3426)

David Höck 10 месяцев назад
Родитель
Сommit
02b115bf0f

+ 7 - 0
.cursor/mcp.json

@@ -0,0 +1,7 @@
+{
+  "mcpServers": {
+    "nx-mcp": {
+      "url": "http://localhost:9921/sse"
+    }
+  }
+}

+ 36 - 0
package-lock.json

@@ -27602,6 +27602,19 @@
       "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
       "license": "MIT"
     },
+    "node_modules/json-edit-react": {
+      "version": "1.23.1",
+      "resolved": "https://registry.npmjs.org/json-edit-react/-/json-edit-react-1.23.1.tgz",
+      "integrity": "sha512-UZvSvnuxT40IBedSH7NAPjiNq91foZFmfzUervV/HTJ04EPtXc6MpB/1VoHpeosMJOT+wXiIUEACflSagJC5DQ==",
+      "license": "MIT",
+      "dependencies": {
+        "object-property-assigner": "^1.3.5",
+        "object-property-extractor": "^1.0.13"
+      },
+      "peerDependencies": {
+        "react": ">=16.0.0"
+      }
+    },
     "node_modules/json-parse-better-errors": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
@@ -32918,6 +32931,18 @@
         "node": ">= 10.12.0"
       }
     },
+    "node_modules/object-property-assigner": {
+      "version": "1.3.5",
+      "resolved": "https://registry.npmjs.org/object-property-assigner/-/object-property-assigner-1.3.5.tgz",
+      "integrity": "sha512-DIzHzNSTnpoG8QPQCDNrHa6O3vLMhktK3Igirqpk523UYIVe8JNCKcn5C9WyLQxJc58EGsAIiiEu10gqPrud8w==",
+      "license": "MIT"
+    },
+    "node_modules/object-property-extractor": {
+      "version": "1.0.13",
+      "resolved": "https://registry.npmjs.org/object-property-extractor/-/object-property-extractor-1.0.13.tgz",
+      "integrity": "sha512-9kgEjTWDhTPuPn7nyof+5mLmCKBPKdU0c7IVpTbOvYKYSdXQ5skH4Pa/8MPbZXeyXBGrqS82JyWecsh6tMxiLw==",
+      "license": "MIT"
+    },
     "node_modules/object.assign": {
       "version": "4.1.7",
       "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
@@ -39882,6 +39907,15 @@
         "node": "*"
       }
     },
+    "node_modules/tw-animate-css": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.4.tgz",
+      "integrity": "sha512-yt+HkJB41NAvOffe4NweJU6fLqAlVx/mBX6XmHRp15kq0JxTtOKaIw8pVSWM1Z+n2nXtyi7cW6C9f0WG/F/QAQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Wombosvideo"
+      }
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -46081,6 +46115,7 @@
         "date-fns": "^4.1.0",
         "gql.tada": "^1.8.10",
         "graphql": "~16.10.0",
+        "json-edit-react": "^1.23.1",
         "lucide-react": "^0.475.0",
         "next-themes": "^0.4.6",
         "react": "^19.0.0",
@@ -46092,6 +46127,7 @@
         "tailwind-merge": "^3.0.1",
         "tailwindcss": "^4.0.6",
         "tailwindcss-animate": "^1.0.7",
+        "tw-animate-css": "^1.2.4",
         "unplugin-swc": "^1.5.1",
         "use-debounce": "^10.0.4",
         "zod": "^3.24.2"

+ 2 - 0
packages/dashboard/package.json

@@ -55,6 +55,7 @@
     "date-fns": "^4.1.0",
     "gql.tada": "^1.8.10",
     "graphql": "~16.10.0",
+    "json-edit-react": "^1.23.1",
     "lucide-react": "^0.475.0",
     "next-themes": "^0.4.6",
     "react": "^19.0.0",
@@ -66,6 +67,7 @@
     "tailwind-merge": "^3.0.1",
     "tailwindcss": "^4.0.6",
     "tailwindcss-animate": "^1.0.7",
+    "tw-animate-css": "^1.2.4",
     "unplugin-swc": "^1.5.1",
     "use-debounce": "^10.0.4",
     "zod": "^3.24.2"

+ 5 - 3
packages/dashboard/src/components/data-table/data-table-column-header.tsx

@@ -42,11 +42,11 @@ export function DataTableColumnHeader({ headerContext, customConfig }: DataTable
     const columSort = column.getIsSorted();
     const columnFilter = column.getFilterValue();
     const nextSort = columSort === 'asc' ? true : columSort === 'desc' ? undefined : false;
+
     return (
         <div className="flex items-center">
-            <div>{display}</div>
             {isSortable && (
-                <Button size="icon" variant="ghost" onClick={() => column.toggleSorting(nextSort)}>
+                <Button size="icon-sm" variant="ghost" onClick={() => column.toggleSorting(nextSort)}>
                     {columSort === 'desc' ? (
                         <ArrowUp />
                     ) : columSort === 'asc' ? (
@@ -56,10 +56,12 @@ export function DataTableColumnHeader({ headerContext, customConfig }: DataTable
                     )}
                 </Button>
             )}
+            <div>{display}</div>
+
             {isFilterable && (
                 <Dialog>
                     <DialogTrigger asChild>
-                        <Button size="icon" variant="ghost">
+                        <Button size="icon-sm" variant="ghost">
                             <Filter className={columnFilter ? '' : 'opacity-50'} />
                         </Button>
                     </DialogTrigger>

+ 168 - 0
packages/dashboard/src/components/data-table/data-table-faceted-filter.tsx

@@ -0,0 +1,168 @@
+import * as React from 'react';
+import { Column } from '@tanstack/react-table';
+import { Check, PlusCircle } from 'lucide-react';
+
+import { cn } from '@/lib/utils.js';
+import { Badge } from '@/components/ui/badge.js';
+import { Button } from '@/components/ui/button.js';
+import {
+    Command,
+    CommandEmpty,
+    CommandGroup,
+    CommandInput,
+    CommandItem,
+    CommandList,
+    CommandSeparator,
+} from '@/components/ui/command.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { Separator } from '@/components/ui/separator.js';
+
+export interface DataTableFacetedFilterOption {
+    label: string;
+    value: string;
+    icon?: React.ComponentType<{ className?: string }>;
+}
+
+export interface DataTableFacetedFilterProps<TData, TValue> {
+    column?: Column<TData, TValue>;
+    title?: string;
+    options?: DataTableFacetedFilterOption[];
+    optionsFn?: () => Promise<DataTableFacetedFilterOption[]>;
+}
+
+export function DataTableFacetedFilter<TData, TValue>({
+    column,
+    title,
+    options,
+    optionsFn,
+}: DataTableFacetedFilterProps<TData, TValue>) {
+    const facets = column?.getFacetedUniqueValues();
+    const filterValue = column?.getFilterValue();
+
+    const selectedValues = filterValue
+        ? new Set(Object.values(filterValue as Record<string, string>))
+        : new Set();
+
+    const [resolvedOptions, setResolvedOptions] = React.useState<DataTableFacetedFilterOption[]>(
+        options || [],
+    );
+    const [isLoading, setIsLoading] = React.useState(false);
+
+    React.useEffect(() => {
+        if (optionsFn) {
+            setIsLoading(true);
+            optionsFn()
+                .then(result => {
+                    setResolvedOptions(result);
+                })
+                .catch(error => {
+                    console.error('Failed to load filter options:', error);
+                })
+                .finally(() => {
+                    setIsLoading(false);
+                });
+        } else if (options) {
+            setResolvedOptions(options);
+        }
+    }, [optionsFn]);
+
+    return (
+        <Popover>
+            <PopoverTrigger asChild>
+                <Button variant="outline" size="sm" className="h-8 border-dashed">
+                    <PlusCircle />
+                    {title}
+                    {selectedValues?.size > 0 && (
+                        <>
+                            <Separator orientation="vertical" className="mx-2 h-4" />
+                            <Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
+                                {selectedValues.size}
+                            </Badge>
+                            <div className="hidden space-x-1 lg:flex">
+                                {selectedValues.size > 2 ? (
+                                    <Badge variant="secondary" className="rounded-sm px-1 font-normal">
+                                        {selectedValues.size} selected
+                                    </Badge>
+                                ) : (
+                                    resolvedOptions
+                                        .filter(option => selectedValues.has(option.value))
+                                        .map(option => (
+                                            <Badge
+                                                variant="secondary"
+                                                key={option.value}
+                                                className="rounded-sm px-1 font-normal"
+                                            >
+                                                {option.label}
+                                            </Badge>
+                                        ))
+                                )}
+                            </div>
+                        </>
+                    )}
+                </Button>
+            </PopoverTrigger>
+            <PopoverContent className="w-[200px] p-0" align="start">
+                <Command>
+                    <CommandInput placeholder={title} />
+                    <CommandList>
+                        <CommandEmpty>No results found.</CommandEmpty>
+                        <CommandGroup>
+                            {resolvedOptions.map(option => {
+                                const isSelected = selectedValues.has(option.value);
+                                return (
+                                    <CommandItem
+                                        key={option.value}
+                                        onSelect={() => {
+                                            if (isSelected) {
+                                                selectedValues.delete(option.value);
+                                            } else {
+                                                selectedValues.add(option.value);
+                                            }
+                                            const filterValues = Array.from(selectedValues);
+                                            column?.setFilterValue(
+                                                filterValues.length ? filterValues : undefined,
+                                            );
+                                        }}
+                                    >
+                                        <div
+                                            className={cn(
+                                                'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
+                                                isSelected
+                                                    ? 'bg-primary text-primary-foreground'
+                                                    : 'opacity-50 [&_svg]:invisible',
+                                            )}
+                                        >
+                                            <Check />
+                                        </div>
+                                        {option.icon && (
+                                            <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
+                                        )}
+                                        <span>{option.label}</span>
+                                        {facets?.get(option.value) && (
+                                            <span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
+                                                {facets.get(option.value)}
+                                            </span>
+                                        )}
+                                    </CommandItem>
+                                );
+                            })}
+                        </CommandGroup>
+                        {selectedValues.size > 0 && (
+                            <>
+                                <CommandSeparator />
+                                <CommandGroup>
+                                    <CommandItem
+                                        onSelect={() => column?.setFilterValue(undefined)}
+                                        className="justify-center text-center"
+                                    >
+                                        Clear filters
+                                    </CommandItem>
+                                </CommandGroup>
+                            </>
+                        )}
+                    </CommandList>
+                </Command>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 46 - 21
packages/dashboard/src/components/data-table/data-table.tsx

@@ -18,9 +18,17 @@ import {
     useReactTable,
     ColumnFilter,
     ColumnFiltersState,
+    Column,
 } from '@tanstack/react-table';
 import { CircleX, Filter } from 'lucide-react';
-import React, { useEffect } from 'react';
+import React, { Suspense, useEffect } from 'react';
+import { DataTableFacetedFilter, DataTableFacetedFilterOption } from './data-table-faceted-filter.js';
+
+export interface FacetedFilter {
+    title: string;
+    optionsFn?: () => Promise<DataTableFacetedFilterOption[]>;
+    options?: DataTableFacetedFilterOption[];
+}
 
 interface DataTableProps<TData, TValue> {
     columns: ColumnDef<TData, TValue>[];
@@ -35,6 +43,7 @@ interface DataTableProps<TData, TValue> {
     onFilterChange?: (table: TableType<TData>, columnFilters: ColumnFilter[]) => void;
     onSearchTermChange?: (searchTerm: string) => void;
     defaultColumnVisibility?: VisibilityState;
+    facetedFilters?: Record<string, FacetedFilter>;
 }
 
 export function DataTable<TData, TValue>({
@@ -50,6 +59,7 @@ export function DataTable<TData, TValue>({
     onFilterChange,
     onSearchTermChange,
     defaultColumnVisibility,
+    facetedFilters,
 }: DataTableProps<TData, TValue>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
@@ -98,7 +108,7 @@ export function DataTable<TData, TValue>({
         <>
             <div className="flex justify-between items-start mt-2">
                 <div className="flex flex-col">
-                    <div>
+                    <div className="flex items-center justify-start gap-2">
                         {onSearchTermChange && (
                             <div className="flex items-center">
                                 <Input
@@ -108,27 +118,42 @@ export function DataTable<TData, TValue>({
                                 />
                             </div>
                         )}
+                        <Suspense>
+                            {Object.entries(facetedFilters ?? {}).map(([key, filter]) => (
+                                <DataTableFacetedFilter
+                                    key={key}
+                                    column={table.getColumn(key)}
+                                    title={filter.title}
+                                    options={filter.options}
+                                    optionsFn={filter.optionsFn}
+                                />
+                            ))}
+                        </Suspense>
                     </div>
                     <div className="flex gap-1 mt-2">
-                        {columnFilters.map(f => {
-                            const [operator, value] = Object.entries(f.value as Record<string, string>)[0];
-                            return (
-                                <Badge key={f.id} className="flex gap-1 items-center" variant="secondary">
-                                    <Filter size="12" className="opacity-50" />
-                                    <div>{f.id}</div>
-                                    <div>{operator}</div>
-                                    <div>{value}</div>
-                                    <button
-                                        className="cursor-pointer"
-                                        onClick={() =>
-                                            setColumnFilters(old => old.filter(x => x.id !== f.id))
-                                        }
-                                    >
-                                        <CircleX size="14" />
-                                    </button>
-                                </Badge>
-                            );
-                        })}
+                        {columnFilters
+                            .filter(f => !facetedFilters?.[f.id])
+                            .map(f => {
+                                const [operator, value] = Object.entries(
+                                    f.value as Record<string, string>,
+                                )[0];
+                                return (
+                                    <Badge key={f.id} className="flex gap-1 items-center" variant="secondary">
+                                        <Filter size="12" className="opacity-50" />
+                                        <div>{f.id}</div>
+                                        <div>{operator}</div>
+                                        <div>{value}</div>
+                                        <button
+                                            className="cursor-pointer"
+                                            onClick={() =>
+                                                setColumnFilters(old => old.filter(x => x.id !== f.id))
+                                            }
+                                        >
+                                            <CircleX size="14" />
+                                        </button>
+                                    </Badge>
+                                );
+                            })}
                     </div>
                 </div>
                 <DataTableViewOptions table={table} />

+ 1 - 1
packages/dashboard/src/components/layout/nav-main.tsx

@@ -47,7 +47,7 @@ export function NavMain({ items }: { items: NavMenuSection[] }) {
                 return;
             }
         }
-    }, [location.pathname, bottomSections]);
+    }, [location.pathname]);
 
     // Render a top navigation section
     const renderTopSection = (item: NavMenuSection) => (

+ 37 - 29
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -1,9 +1,9 @@
 import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header.js';
-import { DataTable } from '@/components/data-table/data-table.js';
+import { DataTable, FacetedFilter } from '@/components/data-table/data-table.js';
 import {
     FieldInfo,
     getObjectPathToPaginatedList,
-    getTypeFieldInfo
+    getTypeFieldInfo,
 } from '@/framework/document-introspection/get-document-structure.js';
 import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
 import { api } from '@/graphql/api.js';
@@ -22,7 +22,7 @@ import {
     Table,
 } from '@tanstack/react-table';
 import { AccessorKeyColumnDef, ColumnDef } from '@tanstack/table-core';
-import React, { useMemo } from 'react';
+import React, { Key, useMemo } from 'react';
 
 // Type that identifies a paginated list structure (has items array and totalItems)
 type IsPaginatedList<T> = T extends { items: any[]; totalItems: number } ? true : false;
@@ -33,14 +33,9 @@ type StringKeys<T> = T extends object ? Extract<keyof T, string> : never;
 // Helper type to handle nullability
 type NonNullable<T> = T extends null | undefined ? never : T;
 
-
 // Non-recursive approach to find paginated list paths with max 2 levels
 // Level 0: Direct top-level check
-type Level0PaginatedLists<T> = T extends object
-    ? IsPaginatedList<T> extends true
-        ? ''
-        : never
-    : never;
+type Level0PaginatedLists<T> = T extends object ? (IsPaginatedList<T> extends true ? '' : never) : never;
 
 // Level 1: One level deep
 type Level1PaginatedLists<T> = T extends object
@@ -69,10 +64,7 @@ type Level2PaginatedLists<T> = T extends object
     : never;
 
 // Combine all levels
-type FindPaginatedListPaths<T> = 
-    | Level0PaginatedLists<T>
-    | Level1PaginatedLists<T> 
-    | Level2PaginatedLists<T>;
+type FindPaginatedListPaths<T> = Level0PaginatedLists<T> | Level1PaginatedLists<T> | Level2PaginatedLists<T>;
 
 // Extract all paths from a TypedDocumentNode
 export type PaginatedListPaths<T extends TypedDocumentNode<any, any>> =
@@ -98,25 +90,30 @@ export type PaginatedListKeys<
     [K in keyof PaginatedListItemFields<T, Path>]: K;
 }[keyof PaginatedListItemFields<T, Path>];
 
-
 export type CustomizeColumnConfig<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof PaginatedListItemFields<T>]?: Partial<ColumnDef<any>>;
 };
 
-export type ListQueryShape = {
-    [key: string]: {
-        items: any[];
-        totalItems: number;
-    };
-} | {
-    [key: string]: {
-        [key: string]: {
-            items: any[];
-            totalItems: number;
-        };
-    };
+export type FacetedFilterConfig<T extends TypedDocumentNode<any, any>> = {
+    [Key in keyof PaginatedListItemFields<T>]?: FacetedFilter;
 };
 
+export type ListQueryShape =
+    | {
+          [key: string]: {
+              items: any[];
+              totalItems: number;
+          };
+      }
+    | {
+          [key: string]: {
+              [key: string]: {
+                  items: any[];
+                  totalItems: number;
+              };
+          };
+      };
+
 export type ListQueryOptionsShape = {
     options?: {
         skip?: number;
@@ -178,6 +175,7 @@ export interface PaginatedListDataTableProps<
     onPageChange: (table: Table<any>, page: number, perPage: number) => void;
     onSortChange: (table: Table<any>, sorting: SortingState) => void;
     onFilterChange: (table: Table<any>, filters: ColumnFiltersState) => void;
+    facetedFilters?: FacetedFilterConfig<T>;
 }
 
 export function PaginatedListDataTable<
@@ -199,6 +197,7 @@ export function PaginatedListDataTable<
     onPageChange,
     onSortChange,
     onFilterChange,
+    facetedFilters,
 }: PaginatedListDataTableProps<T, U, V>) {
     const [searchTerm, setSearchTerm] = React.useState<string>('');
     const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
@@ -215,7 +214,14 @@ export function PaginatedListDataTable<
     }, {});
 
     const filter = columnFilters?.length
-        ? { _and: columnFilters.map(f => ({ [f.id]: f.value })) }
+        ? {
+              _and: columnFilters.map(f => {
+                  if (Array.isArray(f.value)) {
+                      return { [f.id]: { in: f.value } };
+                  }
+                  return { [f.id]: f.value };
+              }),
+          }
         : undefined;
 
     const defaultQueryKey = ['PaginatedListDataTable', listQuery, page, itemsPerPage, sorting, filter];
@@ -274,9 +280,11 @@ export function PaginatedListDataTable<
         const queryBasedColumns = columnConfigs.map(({ fieldInfo, isCustomField }) => {
             const customConfig = customizeColumns?.[fieldInfo.name as keyof PaginatedListItemFields<T>] ?? {};
             const { header, ...customConfigRest } = customConfig;
+            const enableColumnFilter = fieldInfo.isScalar && !facetedFilters?.[fieldInfo.name];
+
             return columnHelper.accessor(fieldInfo.name, {
                 meta: { fieldInfo, isCustomField },
-                enableColumnFilter: fieldInfo.isScalar,
+                enableColumnFilter,
                 enableSorting: fieldInfo.isScalar,
                 cell: ({ cell, row }) => {
                     const value = !isCustomField
@@ -340,12 +348,12 @@ export function PaginatedListDataTable<
                 onFilterChange={onFilterChange}
                 onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
                 defaultColumnVisibility={columnVisibility}
+                facetedFilters={facetedFilters}
             />
         </PaginatedListContext.Provider>
     );
 }
 
-
 /**
  * Returns the default column visibility configuration.
  */

+ 29 - 37
packages/dashboard/src/components/ui/badge.tsx

@@ -1,46 +1,38 @@
-import * as React from "react"
-import { Slot } from "@radix-ui/react-slot"
-import { cva, type VariantProps } from "class-variance-authority"
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { cva, type VariantProps } from 'class-variance-authority';
 
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils.js';
 
 const badgeVariants = cva(
-  "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto",
-  {
-    variants: {
-      variant: {
-        default:
-          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
-        secondary:
-          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
-        destructive:
-          "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
-        outline:
-          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
-      },
+    'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto',
+    {
+        variants: {
+            variant: {
+                default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+                secondary:
+                    'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+                destructive:
+                    'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
+                outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+                success: 'border-transparent bg-success text-success-foreground [a&]:hover:bg-success/90',
+            },
+        },
+        defaultVariants: {
+            variant: 'default',
+        },
     },
-    defaultVariants: {
-      variant: "default",
-    },
-  }
-)
+);
 
 function Badge({
-  className,
-  variant,
-  asChild = false,
-  ...props
-}: React.ComponentProps<"span"> &
-  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
-  const Comp = asChild ? Slot : "span"
+    className,
+    variant,
+    asChild = false,
+    ...props
+}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+    const Comp = asChild ? Slot : 'span';
 
-  return (
-    <Comp
-      data-slot="badge"
-      className={cn(badgeVariants({ variant }), className)}
-      {...props}
-    />
-  )
+    return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
 }
 
-export { Badge, badgeVariants }
+export { Badge, badgeVariants };

+ 39 - 47
packages/dashboard/src/components/ui/button.tsx

@@ -1,57 +1,49 @@
-import * as React from "react"
-import { Slot } from "@radix-ui/react-slot"
-import { cva, type VariantProps } from "class-variance-authority"
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { cva, type VariantProps } from 'class-variance-authority';
 
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
 
 const buttonVariants = cva(
-  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
-  {
-    variants: {
-      variant: {
-        default:
-          "bg-primary text-primary-foreground shadow hover:bg-primary/90",
-        destructive:
-          "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
-        outline:
-          "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
-        secondary:
-          "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
-        ghost: "hover:bg-accent hover:text-accent-foreground",
-        link: "text-primary underline-offset-4 hover:underline",
-      },
-      size: {
-        default: "h-9 px-4 py-2",
-        sm: "h-8 rounded-md px-3 text-xs",
-        lg: "h-10 rounded-md px-8",
-        icon: "h-9 w-9",
-      },
+    'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+    {
+        variants: {
+            variant: {
+                default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
+                destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
+                outline:
+                    'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
+                secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
+                ghost: 'hover:bg-accent hover:text-accent-foreground',
+                link: 'text-primary underline-offset-4 hover:underline',
+            },
+            size: {
+                default: 'h-9 px-4 py-2',
+                sm: 'h-8 rounded-md px-3 text-xs',
+                lg: 'h-10 rounded-md px-8',
+                icon: 'h-9 w-9',
+                'icon-sm': 'h-7 w-7',
+            },
+        },
+        defaultVariants: {
+            variant: 'default',
+            size: 'default',
+        },
     },
-    defaultVariants: {
-      variant: "default",
-      size: "default",
-    },
-  }
-)
+);
 
 export interface ButtonProps
-  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
-    VariantProps<typeof buttonVariants> {
-  asChild?: boolean
+    extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+        VariantProps<typeof buttonVariants> {
+    asChild?: boolean;
 }
 
 const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
-  ({ className, variant, size, asChild = false, ...props }, ref) => {
-    const Comp = asChild ? Slot : "button"
-    return (
-      <Comp
-        className={cn(buttonVariants({ variant, size, className }))}
-        ref={ref}
-        {...props}
-      />
-    )
-  }
-)
-Button.displayName = "Button"
+    ({ className, variant, size, asChild = false, ...props }, ref) => {
+        const Comp = asChild ? Slot : 'button';
+        return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
+    },
+);
+Button.displayName = 'Button';
 
-export { Button, buttonVariants }
+export { Button, buttonVariants };

+ 3 - 13
packages/dashboard/src/framework/defaults.ts

@@ -97,19 +97,9 @@ navMenu({
                     url: '/job-queue',
                 },
                 {
-                    id: 'logs',
-                    title: 'Logs',
-                    url: '/logs',
-                },
-                {
-                    id: 'api-keys',
-                    title: 'API Keys',
-                    url: '/api-keys',
-                },
-                {
-                    id: 'webhooks',
-                    title: 'Webhooks',
-                    url: '/webhooks',
+                    id: 'healthchecks',
+                    title: 'Healthchecks',
+                    url: '/healthchecks',
                 },
             ],
         },

+ 5 - 0
packages/dashboard/src/framework/page/list-page.tsx

@@ -2,6 +2,7 @@ import { PageProps } from '@/framework/page/page-types.js';
 
 import {
     CustomizeColumnConfig,
+    FacetedFilterConfig,
     ListQueryOptionsShape,
     ListQueryShape,
     PaginatedListDataTable,
@@ -11,6 +12,7 @@ import { AnyRouter, useNavigate } from '@tanstack/react-router';
 import { ColumnDef, ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
 import { ResultOf } from 'gql.tada';
 import { Page, PageActionBar, PageTitle } from '../layout-engine/page-layout.js';
+import { FacetedFilter } from '@/components/data-table/data-table.js';
 
 type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
@@ -33,6 +35,7 @@ export interface ListPageProps<
     defaultColumnOrder?: (keyof ListQueryFields<T>)[];
     defaultVisibility?: Partial<Record<keyof ListQueryFields<T>, boolean>>;
     children?: React.ReactNode;
+    facetedFilters?: FacetedFilterConfig<T>;
 }
 
 export function ListPage<
@@ -48,6 +51,7 @@ export function ListPage<
     route: routeOrFn,
     defaultVisibility,
     onSearchTermChange,
+    facetedFilters,
     children,
 }: ListPageProps<T, U, V>) {
     const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
@@ -113,6 +117,7 @@ export function ListPage<
                 onFilterChange={(table, filters) => {
                     persistListStateToUrl(table, { filters });
                 }}
+                facetedFilters={facetedFilters}
             />
         </Page>
     );

+ 5 - 0
packages/dashboard/src/graphql/graphql.ts

@@ -4,6 +4,11 @@ import { initGraphQLTada } from 'gql.tada';
 export const graphql = initGraphQLTada<{
     disableMasking: true;
     introspection: introspection;
+    scalars: {
+        DateTime: string;
+        JSON: any;
+        Money: number;
+    };
 }>();
 
 export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';

+ 54 - 0
packages/dashboard/src/routeTree.gen.ts

@@ -16,6 +16,8 @@ import { Route as AboutImport } from './routes/about';
 import { Route as AuthenticatedImport } from './routes/_authenticated';
 import { Route as AuthenticatedIndexImport } from './routes/_authenticated/index';
 import { Route as AuthenticatedDashboardImport } from './routes/_authenticated/dashboard';
+import { Route as AuthenticatedSystemJobQueueImport } from './routes/_authenticated/_system/job-queue';
+import { Route as AuthenticatedSystemHealthchecksImport } from './routes/_authenticated/_system/healthchecks';
 import { Route as AuthenticatedProductsProductsImport } from './routes/_authenticated/_products/products';
 import { Route as AuthenticatedProductVariantsProductVariantsImport } from './routes/_authenticated/_product-variants/product-variants';
 import { Route as AuthenticatedOrdersOrdersImport } from './routes/_authenticated/_orders/orders';
@@ -58,6 +60,18 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardImport.update({
     getParentRoute: () => AuthenticatedRoute,
 } as any);
 
+const AuthenticatedSystemJobQueueRoute = AuthenticatedSystemJobQueueImport.update({
+    id: '/_system/job-queue',
+    path: '/job-queue',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
+const AuthenticatedSystemHealthchecksRoute = AuthenticatedSystemHealthchecksImport.update({
+    id: '/_system/healthchecks',
+    path: '/healthchecks',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
 const AuthenticatedProductsProductsRoute = AuthenticatedProductsProductsImport.update({
     id: '/_products/products',
     path: '/products',
@@ -201,6 +215,20 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedProductsProductsImport;
             parentRoute: typeof AuthenticatedImport;
         };
+        '/_authenticated/_system/healthchecks': {
+            id: '/_authenticated/_system/healthchecks';
+            path: '/healthchecks';
+            fullPath: '/healthchecks';
+            preLoaderRoute: typeof AuthenticatedSystemHealthchecksImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
+        '/_authenticated/_system/job-queue': {
+            id: '/_authenticated/_system/job-queue';
+            path: '/job-queue';
+            fullPath: '/job-queue';
+            preLoaderRoute: typeof AuthenticatedSystemJobQueueImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
         '/_authenticated/_collections/collections_/$id': {
             id: '/_authenticated/_collections/collections_/$id';
             path: '/collections/$id';
@@ -243,6 +271,8 @@ interface AuthenticatedRouteChildren {
     AuthenticatedOrdersOrdersRoute: typeof AuthenticatedOrdersOrdersRoute;
     AuthenticatedProductVariantsProductVariantsRoute: typeof AuthenticatedProductVariantsProductVariantsRoute;
     AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
+    AuthenticatedSystemHealthchecksRoute: typeof AuthenticatedSystemHealthchecksRoute;
+    AuthenticatedSystemJobQueueRoute: typeof AuthenticatedSystemJobQueueRoute;
     AuthenticatedCollectionsCollectionsIdRoute: typeof AuthenticatedCollectionsCollectionsIdRoute;
     AuthenticatedFacetsFacetsIdRoute: typeof AuthenticatedFacetsFacetsIdRoute;
     AuthenticatedProductVariantsProductVariantsIdRoute: typeof AuthenticatedProductVariantsProductVariantsIdRoute;
@@ -258,6 +288,8 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedOrdersOrdersRoute: AuthenticatedOrdersOrdersRoute,
     AuthenticatedProductVariantsProductVariantsRoute: AuthenticatedProductVariantsProductVariantsRoute,
     AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
+    AuthenticatedSystemHealthchecksRoute: AuthenticatedSystemHealthchecksRoute,
+    AuthenticatedSystemJobQueueRoute: AuthenticatedSystemJobQueueRoute,
     AuthenticatedCollectionsCollectionsIdRoute: AuthenticatedCollectionsCollectionsIdRoute,
     AuthenticatedFacetsFacetsIdRoute: AuthenticatedFacetsFacetsIdRoute,
     AuthenticatedProductVariantsProductVariantsIdRoute: AuthenticatedProductVariantsProductVariantsIdRoute,
@@ -278,6 +310,8 @@ export interface FileRoutesByFullPath {
     '/orders': typeof AuthenticatedOrdersOrdersRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
+    '/healthchecks': typeof AuthenticatedSystemHealthchecksRoute;
+    '/job-queue': typeof AuthenticatedSystemJobQueueRoute;
     '/collections/$id': typeof AuthenticatedCollectionsCollectionsIdRoute;
     '/facets/$id': typeof AuthenticatedFacetsFacetsIdRoute;
     '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
@@ -295,6 +329,8 @@ export interface FileRoutesByTo {
     '/orders': typeof AuthenticatedOrdersOrdersRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
+    '/healthchecks': typeof AuthenticatedSystemHealthchecksRoute;
+    '/job-queue': typeof AuthenticatedSystemJobQueueRoute;
     '/collections/$id': typeof AuthenticatedCollectionsCollectionsIdRoute;
     '/facets/$id': typeof AuthenticatedFacetsFacetsIdRoute;
     '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
@@ -314,6 +350,8 @@ export interface FileRoutesById {
     '/_authenticated/_orders/orders': typeof AuthenticatedOrdersOrdersRoute;
     '/_authenticated/_product-variants/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
+    '/_authenticated/_system/healthchecks': typeof AuthenticatedSystemHealthchecksRoute;
+    '/_authenticated/_system/job-queue': typeof AuthenticatedSystemJobQueueRoute;
     '/_authenticated/_collections/collections_/$id': typeof AuthenticatedCollectionsCollectionsIdRoute;
     '/_authenticated/_facets/facets_/$id': typeof AuthenticatedFacetsFacetsIdRoute;
     '/_authenticated/_product-variants/product-variants_/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
@@ -334,6 +372,8 @@ export interface FileRouteTypes {
         | '/orders'
         | '/product-variants'
         | '/products'
+        | '/healthchecks'
+        | '/job-queue'
         | '/collections/$id'
         | '/facets/$id'
         | '/product-variants/$id'
@@ -350,6 +390,8 @@ export interface FileRouteTypes {
         | '/orders'
         | '/product-variants'
         | '/products'
+        | '/healthchecks'
+        | '/job-queue'
         | '/collections/$id'
         | '/facets/$id'
         | '/product-variants/$id'
@@ -367,6 +409,8 @@ export interface FileRouteTypes {
         | '/_authenticated/_orders/orders'
         | '/_authenticated/_product-variants/product-variants'
         | '/_authenticated/_products/products'
+        | '/_authenticated/_system/healthchecks'
+        | '/_authenticated/_system/job-queue'
         | '/_authenticated/_collections/collections_/$id'
         | '/_authenticated/_facets/facets_/$id'
         | '/_authenticated/_product-variants/product-variants_/$id'
@@ -410,6 +454,8 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
         "/_authenticated/_orders/orders",
         "/_authenticated/_product-variants/product-variants",
         "/_authenticated/_products/products",
+        "/_authenticated/_system/healthchecks",
+        "/_authenticated/_system/job-queue",
         "/_authenticated/_collections/collections_/$id",
         "/_authenticated/_facets/facets_/$id",
         "/_authenticated/_product-variants/product-variants_/$id",
@@ -454,6 +500,14 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/_products/products.tsx",
       "parent": "/_authenticated"
     },
+    "/_authenticated/_system/healthchecks": {
+      "filePath": "_authenticated/_system/healthchecks.tsx",
+      "parent": "/_authenticated"
+    },
+    "/_authenticated/_system/job-queue": {
+      "filePath": "_authenticated/_system/job-queue.tsx",
+      "parent": "/_authenticated"
+    },
     "/_authenticated/_collections/collections_/$id": {
       "filePath": "_authenticated/_collections/collections_.$id.tsx",
       "parent": "/_authenticated"

+ 34 - 0
packages/dashboard/src/routes/_authenticated/_system/components/payload-dialog.tsx

@@ -0,0 +1,34 @@
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogHeader,
+    DialogTitle,
+} from '@/components/ui/dialog.js';
+import { DialogTrigger } from '@/components/ui/dialog.js';
+import { ScrollArea } from '@/components/ui/scroll-area.js';
+import { JsonEditor } from 'json-edit-react';
+
+type PayloadDialogProps = {
+    payload: any;
+    trigger: React.ReactNode;
+    title?: string | React.ReactNode;
+    description?: string | React.ReactNode;
+};
+
+export function PayloadDialog({ payload, trigger, title, description }: PayloadDialogProps) {
+    return (
+        <Dialog>
+            <DialogTrigger asChild>{trigger}</DialogTrigger>
+            <DialogContent>
+                <DialogHeader>
+                    <DialogTitle>{title}</DialogTitle>
+                    <DialogDescription>{description}</DialogDescription>
+                </DialogHeader>
+                <ScrollArea className="max-h-[600px]">
+                    <JsonEditor viewOnly data={payload} collapse />
+                </ScrollArea>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 90 - 0
packages/dashboard/src/routes/_authenticated/_system/healthchecks.tsx

@@ -0,0 +1,90 @@
+import { Button } from '@/components/ui/button.js';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
+import { Page, PageActionBar, PageLayout, PageTitle } from '@/framework/layout-engine/page-layout.js';
+import { Trans } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
+import { formatRelative } from 'date-fns';
+import { CheckCircle2Icon, CircleXIcon } from 'lucide-react';
+import { uiConfig } from 'virtual:vendure-ui-config';
+
+export const Route = createFileRoute('/_authenticated/_system/healthchecks')({
+    component: HealthchecksPage,
+});
+
+interface HealthcheckItem {
+    status: 'up' | 'down';
+}
+
+interface HealthcheckResponse {
+    status: 'ok' | 'error';
+    info: Record<string, HealthcheckItem>;
+    error: Record<string, HealthcheckItem>;
+    details: Record<string, HealthcheckItem>;
+}
+
+export function HealthchecksPage() {
+    const { data, refetch, dataUpdatedAt } = useQuery({
+        queryKey: ['healthchecks'],
+        queryFn: async () => {
+            const res = await fetch(`${uiConfig.apiHost}:${uiConfig.apiPort}/health`);
+            return res.json() as Promise<HealthcheckResponse>;
+        },
+        refetchInterval: 5000,
+    });
+
+    return (
+        <Page>
+            <PageTitle>Healthchecks</PageTitle>
+            <PageActionBar>
+                <Button onClick={() => refetch()}>Refresh</Button>
+            </PageActionBar>
+            <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mt-6">
+                <Card>
+                    <CardHeader>
+                        <CardTitle className="flex items-center gap-2">
+                            <span> Current status</span>
+                            <span className="text-sm font-normal text-muted-foreground">
+                                <Trans>Last updated {formatRelative(dataUpdatedAt, new Date())}</Trans>
+                            </span>
+                        </CardTitle>
+                    </CardHeader>
+                    <CardContent>
+                        {data?.status === 'ok' ? (
+                            <div className="flex items-center gap-2 ">
+                                <CheckCircle2Icon className="text-success"></CheckCircle2Icon>
+                                <span className="text-2xl">
+                                    <Trans>All resources are up and running</Trans>
+                                </span>
+                            </div>
+                        ) : (
+                            <div className="flex items-center gap-2 ">
+                                <CircleXIcon className="text-destructive"></CircleXIcon>
+                                <span className="text-2xl">
+                                    <Trans>Some resources are down</Trans>
+                                </span>
+                            </div>
+                        )}
+                    </CardContent>
+                </Card>
+                <Card>
+                    <CardHeader>
+                        <CardTitle>
+                            <Trans>Monitored Resources</Trans>
+                        </CardTitle>
+                    </CardHeader>
+                    <CardContent>
+                        <div className="flex flex-col gap-2">
+                            {Object.entries(data?.info || {}).map(([key, value]) => (
+                                <div key={key} className="flex items-center justify-start gap-6">
+                                    <span className="min-w-1/3">{key}</span>
+                                    <span>{value.status}</span>
+                                </div>
+                            ))}
+                        </div>
+                    </CardContent>
+                </Card>
+            </div>
+        </Page>
+    );
+}

+ 43 - 0
packages/dashboard/src/routes/_authenticated/_system/job-queue.graphql.ts

@@ -0,0 +1,43 @@
+import { graphql } from '@/graphql/graphql.js';
+
+export const jobInfoFragment = graphql(`
+    fragment JobInfo on Job {
+        id
+        queueName
+        createdAt
+        startedAt
+        settledAt
+        state
+        isSettled
+        progress
+        duration
+        data
+        result
+        error
+        retries
+        attempts
+    }
+`);
+
+export const jobListDocument = graphql(
+    `
+        query JobList($options: JobListOptions) {
+            jobs(options: $options) {
+                items {
+                    ...JobInfo
+                }
+                totalItems
+            }
+        }
+    `,
+    [jobInfoFragment],
+);
+
+export const jobQueueListDocument = graphql(`
+    query JobQueueList {
+        jobQueues {
+            name
+            running
+        }
+    }
+`);

+ 160 - 0
packages/dashboard/src/routes/_authenticated/_system/job-queue.tsx

@@ -0,0 +1,160 @@
+import { ListPage } from '@/framework/page/list-page.js';
+import { Trans } from '@lingui/react/macro';
+import { createFileRoute } from '@tanstack/react-router';
+import { jobListDocument, jobQueueListDocument } from './job-queue.graphql.js';
+import { Badge } from '@/components/ui/badge.js';
+import { Button } from '@/components/ui/button.js';
+import { PayloadDialog } from './components/payload-dialog.js';
+import { differenceInMilliseconds, formatDuration, formatRelative } from 'date-fns';
+import { Ban, CircleXIcon, ClockIcon, LoaderIcon, RotateCcw } from 'lucide-react';
+import { CheckCircle2Icon } from 'lucide-react';
+import { api } from '@/graphql/api.js';
+
+export const Route = createFileRoute('/_authenticated/_system/job-queue')({
+    component: JobQueuePage,
+    loader: () => ({ breadcrumb: () => <Trans>Job Queue</Trans> }),
+});
+
+const STATES = [
+    {
+        label: 'Pending',
+        value: 'PENDING',
+        icon: ClockIcon,
+    },
+    {
+        label: 'Completed',
+        value: 'COMPLETED',
+        icon: CheckCircle2Icon,
+    },
+    {
+        label: 'Running',
+        value: 'RUNNING',
+        icon: LoaderIcon,
+    },
+    {
+        label: 'Failed',
+        value: 'FAILED',
+        icon: CircleXIcon,
+    },
+    {
+        label: 'Retrying',
+        value: 'RETRYING',
+        icon: RotateCcw,
+    },
+    {
+        label: 'Cancelled',
+        value: 'CANCELLED',
+        icon: Ban,
+    },
+];
+
+export function JobQueuePage() {
+    return (
+        <ListPage
+            title="Job Queue"
+            listQuery={jobListDocument}
+            route={Route}
+            customizeColumns={{
+                createdAt: {
+                    header: 'Created At',
+                    cell: ({ row }) => formatRelative(row.original.createdAt, new Date()),
+                },
+                data: {
+                    header: 'Data',
+                    cell: ({ row }) => (
+                        <PayloadDialog
+                            payload={row.original.data}
+                            title={<Trans>View job data</Trans>}
+                            description={<Trans>The data that has been passed to the job</Trans>}
+                            trigger={
+                                <Button size="sm" variant="secondary">
+                                    View data
+                                </Button>
+                            }
+                        />
+                    ),
+                },
+                queueName: {
+                    header: 'Queue',
+                    cell: ({ row }) => <span className="font-mono">{row.original.queueName}</span>,
+                },
+                result: {
+                    header: 'Result',
+                    cell: ({ row }) => {
+                        return row.original.result ? (
+                            <PayloadDialog
+                                payload={row.original.result}
+                                title={<Trans>View job result</Trans>}
+                                description={<Trans>The result of the job</Trans>}
+                                trigger={
+                                    <Button size="sm" variant="secondary">
+                                        View result
+                                    </Button>
+                                }
+                            />
+                        ) : (
+                            <div className="text-muted-foreground">
+                                <Trans>No result yet</Trans>
+                            </div>
+                        );
+                    },
+                },
+                state: {
+                    header: 'State',
+                    cell: ({ row }) => {
+                        const state = STATES.find(s => s.value === row.original.state);
+
+                        return (
+                            <Badge
+                                variant={
+                                    row.original.state === 'PENDING'
+                                        ? 'secondary'
+                                        : row.original.state === 'COMPLETED'
+                                          ? 'success'
+                                          : row.original.state === 'FAILED'
+                                            ? 'destructive'
+                                            : 'outline'
+                                }
+                            >
+                                {state && <state.icon />}
+                                {row.original.state}
+                            </Badge>
+                        );
+                    },
+                },
+                duration: {
+                    header: 'Duration',
+                    cell: ({ row }) => {
+                        return row.original.duration ? `${row.original.duration}ms` : null;
+                    },
+                },
+            }}
+            defaultVisibility={{
+                isSettled: false,
+                settledAt: false,
+                progress: false,
+                retries: false,
+                attempts: false,
+                error: false,
+                startedAt: false,
+            }}
+            facetedFilters={{
+                queueName: {
+                    title: 'Queue',
+                    optionsFn: async () => {
+                        return api.query(jobQueueListDocument).then(r => {
+                            return r.jobQueues.map(queue => ({
+                                label: queue.name,
+                                value: queue.name,
+                            }));
+                        });
+                    },
+                },
+                state: {
+                    title: 'State',
+                    options: STATES,
+                },
+            }}
+        ></ListPage>
+    );
+}

+ 7 - 2
packages/dashboard/src/styles.css

@@ -1,5 +1,5 @@
 @import 'tailwindcss';
-@import './tailwindcss-animate.css';
+@import 'tw-animate-css';
 
 @custom-variant dark (&:is(.dark *));
 
@@ -20,6 +20,8 @@
     --accent-foreground: hsl(0 0% 9%);
     --destructive: hsl(0 84.2% 60.2%);
     --destructive-foreground: hsl(0 0% 98%);
+    --success: hsl(100, 81%, 35%);
+    --success-foreground: hsl(0 0% 98%);
     --border: hsl(0 0% 89.8%);
     --input: hsl(0 0% 89.8%);
     --ring: hsl(0 0% 3.9%);
@@ -56,6 +58,8 @@
     --accent-foreground: hsl(0 0% 98%);
     --destructive: hsl(0 62.8% 30.6%);
     --destructive-foreground: hsl(0 0% 98%);
+    --success: hsl(100, 100%, 35%);
+    --success-foreground: hsl(0 0% 98%);
     --border: hsl(0 0% 14.9%);
     --input: hsl(0 0% 14.9%);
     --ring: hsl(0 0% 83.1%);
@@ -91,6 +95,8 @@
     --color-accent-foreground: var(--accent-foreground);
     --color-destructive: var(--destructive);
     --color-destructive-foreground: var(--destructive-foreground);
+    --color-success: var(--success);
+    --color-success-foreground: var(--success-foreground);
     --color-border: var(--border);
     --color-input: var(--input);
     --color-ring: var(--ring);
@@ -129,4 +135,3 @@
 @utility col-side {
     grid-column: span 2 / span 2;
 }
-

+ 6 - 1
packages/dev-server/vite.config.mts

@@ -9,5 +9,10 @@ export default defineConfig({
         environment: 'jsdom',
         root: path.resolve(__dirname, '../dashboard'),
     },
-    plugins: [vendureDashboardPlugin({ vendureConfigPath: pathToFileURL('./dev-config.ts') }) as any],
+    plugins: [
+        vendureDashboardPlugin({
+            vendureConfigPath: pathToFileURL('./dev-config.ts'),
+            adminUiConfig: { apiHost: 'http://localhost', apiPort: 3000 },
+        }) as any,
+    ],
 });