Merge pull request #339 from johndoe6345789/codex/create-new-components-for-viewers-pvcc7s

Add viewer filter and detail components
This commit is contained in:
2025-12-28 04:12:40 +00:00
committed by GitHub
5 changed files with 615 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
import { useMemo } from 'react'
import { Badge, Button, Input, Label, Switch, ToggleGroup, ToggleGroupItem } from '@/components/ui'
import type { OperationType, ResourceType } from '@/lib/security/secure-db/types'
import { FunnelSimple, MagnifyingGlass, X } from '@phosphor-icons/react'
interface AuditLogFiltersProps {
searchTerm: string
onSearchChange: (value: string) => void
selectedOperations: OperationType[]
onOperationsChange: (operations: OperationType[]) => void
selectedResources: ResourceType[]
onResourcesChange: (resources: ResourceType[]) => void
showFailuresOnly: boolean
onShowFailuresChange: (value: boolean) => void
availableOperations?: OperationType[]
availableResources?: ResourceType[]
onReset?: () => void
}
const DEFAULT_OPERATIONS: OperationType[] = ['CREATE', 'READ', 'UPDATE', 'DELETE']
const DEFAULT_RESOURCES: ResourceType[] = [
'user',
'workflow',
'luaScript',
'pageConfig',
'modelSchema',
'comment',
'componentNode',
'componentConfig',
'cssCategory',
'dropdownConfig',
'tenant',
'powerTransfer',
'smtpConfig',
'credential'
]
export function AuditLogFilters({
searchTerm,
onSearchChange,
selectedOperations,
onOperationsChange,
selectedResources,
onResourcesChange,
showFailuresOnly,
onShowFailuresChange,
availableOperations,
availableResources,
onReset
}: AuditLogFiltersProps) {
const operationOptions = availableOperations || DEFAULT_OPERATIONS
const resourceOptions = useMemo(
() => availableResources || DEFAULT_RESOURCES,
[availableResources]
)
return (
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<FunnelSimple weight="bold" />
<span className="text-sm font-medium">Filters</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="audit-log-search">Search</Label>
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="audit-log-search"
placeholder="Search by user, resource, or error message"
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
className="pl-9"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2">
<div className="space-y-1">
<Label htmlFor="audit-log-failures">Failures only</Label>
<p className="text-xs text-muted-foreground">Show only unsuccessful operations</p>
</div>
<Switch
id="audit-log-failures"
checked={showFailuresOnly}
onCheckedChange={onShowFailuresChange}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Operations</Label>
<ToggleGroup
type="multiple"
value={selectedOperations}
onValueChange={(value) => onOperationsChange(value as OperationType[])}
className="flex flex-wrap gap-2"
>
{operationOptions.map((operation) => (
<ToggleGroupItem
key={operation}
value={operation}
className="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground"
>
{operation}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Resources</Label>
<span className="text-xs text-muted-foreground">Select one or more</span>
</div>
<div className="flex flex-wrap gap-2">
{resourceOptions.map((resource) => {
const isSelected = selectedResources.includes(resource)
return (
<Button
key={resource}
variant={isSelected ? 'default' : 'outline'}
size="sm"
onClick={() =>
onResourcesChange(
isSelected
? selectedResources.filter((value) => value !== resource)
: [...selectedResources, resource]
)
}
className="rounded-full"
>
<Badge
variant={isSelected ? 'default' : 'secondary'}
className="pointer-events-none bg-transparent px-0 text-xs capitalize"
>
{resource}
</Badge>
</Button>
)
})}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{selectedOperations.length > 0 && (
<Badge variant="outline" className="gap-1">
<span className="font-medium">Operations:</span>
<span>{selectedOperations.join(', ')}</span>
</Badge>
)}
{selectedResources.length > 0 && (
<Badge variant="outline" className="gap-1">
<span className="font-medium">Resources:</span>
<span>{selectedResources.join(', ')}</span>
</Badge>
)}
{showFailuresOnly && (
<Badge variant="destructive" className="gap-1">
<WarningIcon />
Failures
</Badge>
)}
{selectedOperations.length === 0 &&
selectedResources.length === 0 &&
!showFailuresOnly && (
<span>No filters applied</span>
)}
</div>
{onReset && (
<Button variant="ghost" size="sm" onClick={onReset} className="ml-auto gap-1">
<X className="h-4 w-4" />
Clear all
</Button>
)}
</div>
</div>
)
}
function WarningIcon() {
return <span className="inline-block h-3 w-3 rounded-full bg-destructive" />
}

View File

@@ -0,0 +1,124 @@
import { Badge, Card, CardContent, CardHeader, CardTitle, ScrollArea, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import type { AuditLog, OperationType, ResourceType } from '@/lib/security/secure-db/types'
import { ArrowDown, ArrowUp, ShieldCheck, User as UserIcon, WarningCircle } from '@phosphor-icons/react'
interface LogTableProps {
logs: AuditLog[]
sortField?: keyof AuditLog | null
sortDirection?: 'asc' | 'desc'
onSortChange?: (field: keyof AuditLog) => void
}
const OPERATION_COLORS: Record<OperationType, string> = {
CREATE: 'bg-green-100 text-green-800',
READ: 'bg-blue-100 text-blue-800',
UPDATE: 'bg-yellow-100 text-yellow-800',
DELETE: 'bg-red-100 text-red-800'
}
const RESOURCE_ICONS: Partial<Record<ResourceType, JSX.Element>> = {
user: <UserIcon className="h-4 w-4" weight="bold" />,
credential: <ShieldCheck className="h-4 w-4" weight="bold" />
}
export function LogTable({ logs, sortField, sortDirection = 'asc', onSortChange }: LogTableProps) {
const handleSort = (field: keyof AuditLog) => {
onSortChange?.(field)
}
return (
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b bg-muted/40 py-3">
<CardTitle className="text-base font-semibold">Audit Log</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-[480px]">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<SortableHeader
field="timestamp"
label="Timestamp"
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
/>
<TableHead>User</TableHead>
<TableHead>Operation</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Status</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
No audit events to display
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.id} className="hover:bg-muted/20">
<TableCell className="text-sm text-muted-foreground">
{new Date(log.timestamp).toLocaleString()}
</TableCell>
<TableCell className="font-medium">{log.username}</TableCell>
<TableCell>
<Badge className={OPERATION_COLORS[log.operation]}>{log.operation}</Badge>
</TableCell>
<TableCell className="flex items-center gap-2">
{RESOURCE_ICONS[log.resource] || <ShieldCheck className="h-4 w-4 text-muted-foreground" />}
<span className="capitalize">{log.resource}</span>
</TableCell>
<TableCell>
{log.success ? (
<Badge variant="outline" className="border-green-200 text-green-700">
<ShieldCheck className="mr-1 h-4 w-4" />
Success
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<WarningCircle className="h-4 w-4" />
Failed
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{log.errorMessage || '—'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
)
}
interface SortableHeaderProps {
field: keyof AuditLog
label: string
sortField?: keyof AuditLog | null
sortDirection?: 'asc' | 'desc'
onSort?: (field: keyof AuditLog) => void
}
function SortableHeader({ field, label, sortField, sortDirection = 'asc', onSort }: SortableHeaderProps) {
const isActive = sortField === field
const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown
return (
<TableHead
className="cursor-pointer select-none"
onClick={() => onSort?.(field)}
>
<div className="flex items-center gap-2">
{label}
{isActive && <Icon className="h-3.5 w-3.5" />}
</div>
</TableHead>
)
}

View File

@@ -0,0 +1,73 @@
import { Badge, Button, ScrollArea, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui'
import type { ModelSchema } from '@/lib/schema-types'
import { getFieldLabel } from '@/lib/schema-utils'
interface DetailsDrawerProps {
open: boolean
onOpenChange: (open: boolean) => void
record: any | null
model: ModelSchema
}
export function DetailsDrawer({ open, onOpenChange, record, model }: DetailsDrawerProps) {
const renderValue = (fieldName: string) => {
const field = model.fields.find((item) => item.name === fieldName)
if (!field) return null
const value = record?.[fieldName]
if (value === null || value === undefined || value === '') {
return <span className="text-muted-foreground">Not provided</span>
}
switch (field.type) {
case 'boolean':
return value ? (
<Badge variant="outline" className="bg-emerald-50 text-emerald-700">
Yes
</Badge>
) : (
<Badge variant="secondary">No</Badge>
)
case 'date':
case 'datetime':
return new Date(value).toLocaleString()
case 'json':
return <pre className="whitespace-pre-wrap rounded-md bg-muted/60 p-3 text-xs">{JSON.stringify(value, null, 2)}</pre>
default:
return <span className="font-medium text-foreground">{String(value)}</span>
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl">
<SheetHeader>
<SheetTitle>{model.label || model.name} details</SheetTitle>
<SheetDescription>Review the full record and its attributes.</SheetDescription>
</SheetHeader>
<Separator className="my-4" />
<ScrollArea className="h-[70vh] pr-4">
<div className="space-y-4">
{model.fields.map((field) => (
<div key={field.name} className="rounded-lg border bg-muted/40 p-3">
<p className="text-xs uppercase text-muted-foreground">{getFieldLabel(field)}</p>
<div className="mt-1 text-sm">{renderValue(field.name)}</div>
{field.helpText && (
<p className="mt-1 text-xs text-muted-foreground">{field.helpText}</p>
)}
</div>
))}
</div>
</ScrollArea>
<SheetFooter className="mt-6">
<SheetClose asChild>
<Button variant="outline" className="w-full sm:w-auto">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,82 @@
import { Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
import { MagnifyingGlass } from '@phosphor-icons/react'
interface ModelFiltersProps {
model: ModelSchema
filters: Record<string, any>
searchTerm: string
onSearchChange: (value: string) => void
onFilterChange: (field: string, value: any) => void
}
function getFilterableFields(model: ModelSchema): FieldSchema[] {
if (model.listFilter) {
return model.fields.filter((field) => model.listFilter?.includes(field.name))
}
return model.fields.filter((field) => field.type === 'select' || field.type === 'boolean')
}
export function ModelFilters({ model, filters, searchTerm, onSearchChange, onFilterChange }: ModelFiltersProps) {
const filterFields = getFilterableFields(model)
return (
<div className="space-y-3 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2">
<Label htmlFor="model-search">Search</Label>
<div className="relative">
<MagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="model-search"
placeholder={`Search ${model.labelPlural || model.name}`}
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
className="pl-9"
/>
</div>
</div>
{filterFields.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filterFields.map((field) => (
<div key={field.name} className="space-y-1.5">
<Label>{field.label || field.name}</Label>
{field.type === 'select' ? (
<Select
value={filters[field.name] ?? '__all__'}
onValueChange={(value) => onFilterChange(field.name, value === '__all__' ? null : value)}
>
<SelectTrigger>
<SelectValue placeholder={field.label || field.name} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All</SelectItem>
{field.choices?.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{choice.label || choice.value}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select
value={filters[field.name] === true ? 'true' : filters[field.name] === false ? 'false' : '__all__'}
onValueChange={(value) => onFilterChange(field.name, value === 'true' ? true : value === 'false' ? false : null)}
>
<SelectTrigger>
<SelectValue placeholder={field.label || field.name} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All</SelectItem>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,148 @@
import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
import { getFieldLabel } from '@/lib/schema-utils'
import { ArrowDown, ArrowUp, Pencil, Trash } from '@phosphor-icons/react'
import { ReactNode } from 'react'
interface ModelTableProps {
model: ModelSchema
records: any[]
displayFields: string[]
sortField?: string | null
sortDirection?: 'asc' | 'desc'
onSortChange?: (field: string) => void
onEdit?: (record: any) => void
onDelete?: (id: string) => void
onRowClick?: (record: any) => void
renderRelationValue?: (value: string, field: FieldSchema) => ReactNode
}
export function ModelTable({
model,
records,
displayFields,
sortField,
sortDirection = 'asc',
onSortChange,
onEdit,
onDelete,
onRowClick,
renderRelationValue
}: ModelTableProps) {
const actionColumns = onEdit || onDelete ? 1 : 0
const renderCellValue = (record: any, fieldName: string) => {
const field = model.fields.find((item) => item.name === fieldName)
if (!field) return null
const value = record[fieldName]
if (value === null || value === undefined) {
return <span className="text-muted-foreground"></span>
}
if (field.type === 'relation' && typeof value === 'string' && renderRelationValue) {
return renderRelationValue(value, field)
}
switch (field.type) {
case 'boolean':
return value ? <Badge variant="outline">Yes</Badge> : <Badge variant="secondary">No</Badge>
case 'date':
case 'datetime':
return new Date(value).toLocaleString()
case 'json':
return <code className="text-xs">{JSON.stringify(value)}</code>
default:
return typeof value === 'string' && value.length > 60 ? `${value.slice(0, 60)}` : String(value)
}
}
return (
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
{displayFields.map((fieldName) => {
const field = model.fields.find((item) => item.name === fieldName)
if (!field) return null
const isSortable = field.sortable !== false
const isActive = sortField === fieldName
const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown
return (
<TableHead
key={fieldName}
className={isSortable ? 'cursor-pointer select-none' : undefined}
onClick={() => isSortable && onSortChange?.(fieldName)}
>
<div className="flex items-center gap-2">
<span className="uppercase text-xs font-semibold tracking-wide">
{getFieldLabel(field)}
</span>
{isSortable && isActive && <Icon className="h-3.5 w-3.5" />}
</div>
</TableHead>
)
})}
{(onEdit || onDelete) && <TableHead className="w-24">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{records.length === 0 ? (
<TableRow>
<TableCell colSpan={displayFields.length + actionColumns} className="py-10 text-center text-muted-foreground">
No records to display
</TableCell>
</TableRow>
) : (
records.map((record) => (
<TableRow
key={record.id}
className="hover:bg-muted/30"
onClick={() => onRowClick?.(record)}
>
{displayFields.map((fieldName) => (
<TableCell key={fieldName} className="py-3">
{renderCellValue(record, fieldName)}
</TableCell>
))}
{(onEdit || onDelete) && (
<TableCell>
<div className="flex gap-2">
{onEdit && (
<Button
size="sm"
variant="ghost"
onClick={(event) => {
event.stopPropagation()
onEdit(record)
}}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{onDelete && (
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={(event) => {
event.stopPropagation()
onDelete(record.id)
}}
>
<Trash className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)
}