Improve USB policy history with filters and pagination

- Fix bug where APPLYING status would not transition to APPLIED
- Add status and date range filters to policy history
- Add cursor-based pagination with "Load more" button
- Use DateRangeButton component for date filtering
- Reset filters and pagination when switching filters

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-06 23:24:23 -03:00
parent 5846c299ce
commit 873305fa7f
2 changed files with 212 additions and 48 deletions

View file

@ -117,7 +117,9 @@ export const reportUsbPolicyStatus = mutation({
.order("desc")
.first()
if (latestEvent && latestEvent.status === "PENDING") {
// Atualiza o evento se ainda nao foi finalizado (PENDING ou APPLYING)
// Isso permite a transicao: PENDING -> APPLYING -> APPLIED/FAILED
if (latestEvent && (latestEvent.status === "PENDING" || latestEvent.status === "APPLYING")) {
await ctx.db.patch(latestEvent._id, {
status: args.status,
error: errorValue,
@ -185,27 +187,57 @@ export const listUsbPolicyEvents = query({
args: {
machineId: v.id("machines"),
limit: v.optional(v.number()),
cursor: v.optional(v.number()),
status: v.optional(v.string()),
dateFrom: v.optional(v.number()),
dateTo: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = args.limit ?? 50
const limit = args.limit ?? 10
const events = await ctx.db
let events = await ctx.db
.query("usbPolicyEvents")
.withIndex("by_machine_created", (q) => q.eq("machineId", args.machineId))
.order("desc")
.take(limit)
.collect()
return events.map((event) => ({
id: event._id,
oldPolicy: event.oldPolicy,
newPolicy: event.newPolicy,
status: event.status,
error: event.error,
actorEmail: event.actorEmail,
actorName: event.actorName,
createdAt: event.createdAt,
appliedAt: event.appliedAt,
}))
// Aplica filtro de cursor (paginacao)
if (args.cursor !== undefined) {
events = events.filter((e) => e.createdAt < args.cursor!)
}
// Aplica filtro de status
if (args.status) {
events = events.filter((e) => e.status === args.status)
}
// Aplica filtro de data
if (args.dateFrom !== undefined) {
events = events.filter((e) => e.createdAt >= args.dateFrom!)
}
if (args.dateTo !== undefined) {
events = events.filter((e) => e.createdAt <= args.dateTo!)
}
const hasMore = events.length > limit
const results = events.slice(0, limit)
const nextCursor = results.length > 0 ? results[results.length - 1].createdAt : undefined
return {
events: results.map((event) => ({
id: event._id,
oldPolicy: event.oldPolicy,
newPolicy: event.newPolicy,
status: event.status,
error: event.error,
actorEmail: event.actorEmail,
actorName: event.actorName,
createdAt: event.createdAt,
appliedAt: event.appliedAt,
})),
hasMore,
nextCursor,
}
},
})

View file

@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useMemo } from "react"
import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -20,10 +20,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Usb, Shield, ShieldOff, ShieldAlert, Clock, CheckCircle2, XCircle, Loader2, History } from "lucide-react"
import { Usb, Shield, ShieldOff, ShieldAlert, Clock, CheckCircle2, XCircle, Loader2, History, Filter, ChevronDown, RotateCcw } from "lucide-react"
import { toast } from "sonner"
import { formatDistanceToNow } from "date-fns"
import { formatDistanceToNow, startOfDay, endOfDay, parseISO } from "date-fns"
import { ptBR } from "date-fns/locale"
import { DateRangeButton } from "@/components/date-range-button"
type UsbPolicyValue = "ALLOW" | "BLOCK_ALL" | "READONLY"
@ -139,6 +140,14 @@ interface UsbPolicyControlProps {
variant?: "card" | "inline"
}
const STATUS_FILTER_OPTIONS = [
{ value: "all", label: "Todos os status" },
{ value: "PENDING", label: "Pendente" },
{ value: "APPLYING", label: "Aplicando" },
{ value: "APPLIED", label: "Aplicado" },
{ value: "FAILED", label: "Falhou" },
]
export function UsbPolicyControl({
machineId,
machineName,
@ -152,15 +161,77 @@ export function UsbPolicyControl({
const [isApplying, setIsApplying] = useState(false)
const [showHistory, setShowHistory] = useState(false)
// Filtros do historico
const [statusFilter, setStatusFilter] = useState<string>("all")
const [dateFrom, setDateFrom] = useState<string | null>(null)
const [dateTo, setDateTo] = useState<string | null>(null)
const [allEvents, setAllEvents] = useState<UsbPolicyEvent[]>([])
const [cursor, setCursor] = useState<number | undefined>(undefined)
const usbPolicy = useQuery(api.usbPolicy.getUsbPolicy, {
machineId: machineId as Id<"machines">,
})
const policyEvents = useQuery(
// Converte datas para timestamp
const dateFromTs = useMemo(() => {
if (!dateFrom) return undefined
return startOfDay(parseISO(dateFrom)).getTime()
}, [dateFrom])
const dateToTs = useMemo(() => {
if (!dateTo) return undefined
return endOfDay(parseISO(dateTo)).getTime()
}, [dateTo])
const policyEventsResult = useQuery(
api.usbPolicy.listUsbPolicyEvents,
showHistory ? { machineId: machineId as Id<"machines">, limit: 10 } : "skip"
showHistory
? {
machineId: machineId as Id<"machines">,
limit: 10,
cursor,
status: statusFilter !== "all" ? statusFilter : undefined,
dateFrom: dateFromTs,
dateTo: dateToTs,
}
: "skip"
)
// Acumula eventos quando carrega mais
useEffect(() => {
if (policyEventsResult?.events) {
if (cursor === undefined) {
// Reset quando filtros mudam
setAllEvents(policyEventsResult.events)
} else {
// Acumula quando carrega mais
setAllEvents((prev) => [...prev, ...policyEventsResult.events])
}
}
}, [policyEventsResult?.events, cursor])
// Reset cursor quando filtros mudam
useEffect(() => {
setCursor(undefined)
setAllEvents([])
}, [statusFilter, dateFrom, dateTo])
const handleLoadMore = () => {
if (policyEventsResult?.nextCursor) {
setCursor(policyEventsResult.nextCursor)
}
}
const handleResetFilters = () => {
setStatusFilter("all")
setDateFrom(null)
setDateTo(null)
setCursor(undefined)
setAllEvents([])
}
const hasActiveFilters = statusFilter !== "all" || dateFrom !== null || dateTo !== null
const setUsbPolicyMutation = useMutation(api.usbPolicy.setUsbPolicy)
useEffect(() => {
@ -311,38 +382,99 @@ export function UsbPolicyControl({
{showHistory ? "Ocultar histórico" : "Ver histórico de alterações"}
</Button>
{showHistory && policyEvents && (
<div className="mt-3 space-y-2">
{policyEvents.length === 0 ? (
<p className="text-center text-xs text-muted-foreground py-2">
Nenhuma alteração registrada
{showHistory && (
<div className="mt-3 space-y-3">
{/* Filtros */}
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-slate-50/80 p-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Filter className="size-3.5" />
<span>Filtros:</span>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-7 w-[130px] text-xs">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<DateRangeButton
from={dateFrom}
to={dateTo}
onChange={({ from, to }) => {
setDateFrom(from)
setDateTo(to)
}}
className="h-7 text-xs"
clearLabel="Limpar período"
/>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={handleResetFilters}
>
<RotateCcw className="size-3" />
Limpar
</Button>
)}
</div>
{/* Lista de eventos */}
{allEvents.length === 0 ? (
<p className="text-center text-xs text-muted-foreground py-4">
{hasActiveFilters
? "Nenhuma alteração encontrada com os filtros selecionados"
: "Nenhuma alteração registrada"}
</p>
) : (
policyEvents.map((event: UsbPolicyEvent) => (
<div
key={event.id}
className="flex items-start justify-between rounded-md border bg-white p-2 text-xs"
>
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<span className="font-medium">
{getPolicyConfig(event.oldPolicy).label}
</span>
<span className="text-muted-foreground">&rarr;</span>
<span className="font-medium">
{getPolicyConfig(event.newPolicy).label}
</span>
{getStatusBadge(event.status)}
<>
<div className="space-y-2">
{allEvents.map((event: UsbPolicyEvent) => (
<div
key={event.id}
className="flex items-start justify-between rounded-md border bg-white p-2 text-xs"
>
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<span className="font-medium">
{getPolicyConfig(event.oldPolicy).label}
</span>
<span className="text-muted-foreground">&rarr;</span>
<span className="font-medium">
{getPolicyConfig(event.newPolicy).label}
</span>
{getStatusBadge(event.status)}
</div>
<p className="text-muted-foreground">
{event.actorName ?? event.actorEmail ?? "Sistema"} &middot; {formatEventDate(event.createdAt)}
</p>
{event.error && (
<p className="text-red-600">{event.error}</p>
)}
</div>
</div>
<p className="text-muted-foreground">
{event.actorName ?? event.actorEmail ?? "Sistema"} &middot; {formatEventDate(event.createdAt)}
</p>
{event.error && (
<p className="text-red-600">{event.error}</p>
)}
</div>
))}
</div>
))
{/* Paginacao */}
{policyEventsResult?.hasMore && (
<Button
variant="ghost"
size="sm"
className="w-full gap-1 text-xs text-muted-foreground"
onClick={handleLoadMore}
>
<ChevronDown className="size-3.5" />
Carregar mais
</Button>
)}
</>
)}
</div>
)}