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:
parent
5846c299ce
commit
873305fa7f
2 changed files with 212 additions and 48 deletions
|
|
@ -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,17 +187,44 @@ 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) => ({
|
||||
// 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,
|
||||
|
|
@ -205,7 +234,10 @@ export const listUsbPolicyEvents = query({
|
|||
actorName: event.actorName,
|
||||
createdAt: event.createdAt,
|
||||
appliedAt: event.appliedAt,
|
||||
}))
|
||||
})),
|
||||
hasMore,
|
||||
nextCursor,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,14 +382,60 @@ 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 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"
|
||||
|
|
@ -342,7 +459,22 @@ export function UsbPolicyControl({
|
|||
)}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue