feat: adiciona informacoes de reinicio e melhora SLA global
All checks were successful
All checks were successful
- Agente Rust: captura LastBootTime, uptime e contagem de boots - Backend: extrai campos do extended (bootInfo, discos, RAM, etc) antes de salvar - Frontend /devices: exibe secao de ultimo reinicio - SLA global: adiciona campos de modo, threshold de alerta e status de pausa - Corrige acento em "destinatario" -> "destinatario" em automations 🤖 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
d32b94c22d
commit
f39bd46c2b
7 changed files with 338 additions and 19 deletions
|
|
@ -329,6 +329,16 @@ type WindowsChassis = {
|
|||
SMBIOSAssetTag?: string
|
||||
}
|
||||
|
||||
type WindowsBootInfo = {
|
||||
LastBootTime?: string
|
||||
UptimeSeconds?: number
|
||||
BootCountLast30Days?: number
|
||||
RecentBoots?: Array<{
|
||||
TimeCreated?: string
|
||||
Computer?: string
|
||||
}>
|
||||
}
|
||||
|
||||
type WindowsExtended = {
|
||||
software?: DeviceSoftware[]
|
||||
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
||||
|
|
@ -355,6 +365,7 @@ type WindowsExtended = {
|
|||
networkAdapters?: WindowsNetworkAdapter[]
|
||||
monitors?: WindowsMonitor[]
|
||||
chassis?: WindowsChassis
|
||||
bootInfo?: WindowsBootInfo
|
||||
}
|
||||
|
||||
type MacExtended = {
|
||||
|
|
@ -3093,6 +3104,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
return []
|
||||
}, [windowsExt?.monitors])
|
||||
const windowsChassisInfo = windowsExt?.chassis ?? null
|
||||
const windowsBootInfo = windowsExt?.bootInfo ?? null
|
||||
const osNameDisplay = useMemo(() => {
|
||||
const base = device?.osName?.trim()
|
||||
const edition = windowsEditionLabel?.trim()
|
||||
|
|
@ -4302,11 +4314,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Softwares instalados */}
|
||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<DeviceSoftwareList machineId={device.id as Id<"machines">} />
|
||||
</div>
|
||||
|
||||
{/* Campos personalizados */}
|
||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
|
|
@ -4346,6 +4353,11 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Softwares instalados */}
|
||||
<div className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<DeviceSoftwareList machineId={device.id as Id<"machines">} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 border-t border-slate-100 pt-6">
|
||||
|
|
@ -5721,6 +5733,33 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Último reinício */}
|
||||
{windowsBootInfo ? (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">Último reinício</p>
|
||||
<div className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
||||
<DetailLine
|
||||
label="Data/Hora"
|
||||
value={windowsBootInfo.LastBootTime
|
||||
? format(new Date(windowsBootInfo.LastBootTime), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DetailLine
|
||||
label="Tempo ligado"
|
||||
value={windowsBootInfo.UptimeSeconds
|
||||
? formatDistanceToNowStrict(Date.now() - (windowsBootInfo.UptimeSeconds * 1000), { locale: ptBR, addSuffix: false })
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DetailLine
|
||||
label="Reinícios (30 dias)"
|
||||
value={windowsBootInfo.BootCountLast30Days?.toString() ?? "—"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Label } from "@/components/ui/label"
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { CategorySlaManager } from "./category-sla-manager"
|
||||
import { CompanySlaManager } from "./company-sla-manager"
|
||||
|
|
@ -26,10 +27,15 @@ type SlaPolicy = {
|
|||
name: string
|
||||
description: string
|
||||
timeToFirstResponse: number | null
|
||||
responseMode: "business" | "calendar"
|
||||
timeToResolution: number | null
|
||||
solutionMode: "business" | "calendar"
|
||||
alertThreshold: number
|
||||
pauseStatuses: string[]
|
||||
}
|
||||
|
||||
type TimeUnit = "minutes" | "hours" | "days"
|
||||
type TimeMode = "business" | "calendar"
|
||||
|
||||
const TIME_UNITS: Array<{ value: TimeUnit; label: string; factor: number }> = [
|
||||
{ value: "minutes", label: "Minutos", factor: 1 },
|
||||
|
|
@ -37,6 +43,17 @@ const TIME_UNITS: Array<{ value: TimeUnit; label: string; factor: number }> = [
|
|||
{ value: "days", label: "Dias", factor: 1440 },
|
||||
]
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: TimeMode; label: string }> = [
|
||||
{ value: "calendar", label: "Horas corridas" },
|
||||
{ value: "business", label: "Horas úteis" },
|
||||
]
|
||||
|
||||
const PAUSE_STATUS_OPTIONS = [
|
||||
{ value: "PENDING", label: "Pendente" },
|
||||
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
|
||||
{ value: "PAUSED", label: "Pausado" },
|
||||
] as const
|
||||
|
||||
function formatMinutes(value: number | null) {
|
||||
if (value === null) return "—"
|
||||
if (value < 60) return `${Math.round(value)} min`
|
||||
|
|
@ -82,8 +99,12 @@ export function SlasManager() {
|
|||
const [description, setDescription] = useState("")
|
||||
const [responseAmount, setResponseAmount] = useState("")
|
||||
const [responseUnit, setResponseUnit] = useState<TimeUnit>("hours")
|
||||
const [responseMode, setResponseMode] = useState<TimeMode>("calendar")
|
||||
const [resolutionAmount, setResolutionAmount] = useState("")
|
||||
const [resolutionUnit, setResolutionUnit] = useState<TimeUnit>("hours")
|
||||
const [solutionMode, setSolutionMode] = useState<TimeMode>("calendar")
|
||||
const [alertThreshold, setAlertThreshold] = useState(80)
|
||||
const [pauseStatuses, setPauseStatuses] = useState<string[]>(["PAUSED"])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const { bestFirstResponse, bestResolution } = useMemo(() => {
|
||||
|
|
@ -107,8 +128,12 @@ export function SlasManager() {
|
|||
setDescription("")
|
||||
setResponseAmount("")
|
||||
setResponseUnit("hours")
|
||||
setResponseMode("calendar")
|
||||
setResolutionAmount("")
|
||||
setResolutionUnit("hours")
|
||||
setSolutionMode("calendar")
|
||||
setAlertThreshold(80)
|
||||
setPauseStatuses(["PAUSED"])
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
|
|
@ -125,11 +150,30 @@ export function SlasManager() {
|
|||
setDescription(policy.description)
|
||||
setResponseAmount(response.amount)
|
||||
setResponseUnit(response.unit)
|
||||
setResponseMode(policy.responseMode ?? "calendar")
|
||||
setResolutionAmount(resolution.amount)
|
||||
setResolutionUnit(resolution.unit)
|
||||
setSolutionMode(policy.solutionMode ?? "calendar")
|
||||
setAlertThreshold(Math.round((policy.alertThreshold ?? 0.8) * 100))
|
||||
setPauseStatuses(policy.pauseStatuses ?? ["PAUSED"])
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const togglePauseStatus = (status: string) => {
|
||||
setPauseStatuses((current) => {
|
||||
const selected = new Set(current)
|
||||
if (selected.has(status)) {
|
||||
selected.delete(status)
|
||||
} else {
|
||||
selected.add(status)
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
selected.add("PAUSED")
|
||||
}
|
||||
return Array.from(selected)
|
||||
})
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingSla(null)
|
||||
|
|
@ -153,6 +197,8 @@ export function SlasManager() {
|
|||
const toastId = editingSla ? "sla-edit" : "sla-create"
|
||||
toast.loading(editingSla ? "Salvando alterações..." : "Criando política...", { id: toastId })
|
||||
|
||||
const normalizedThreshold = Math.min(Math.max(alertThreshold, 10), 95) / 100
|
||||
|
||||
try {
|
||||
if (editingSla) {
|
||||
await updateSla({
|
||||
|
|
@ -162,7 +208,11 @@ export function SlasManager() {
|
|||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
timeToFirstResponse,
|
||||
responseMode,
|
||||
timeToResolution,
|
||||
solutionMode,
|
||||
alertThreshold: normalizedThreshold,
|
||||
pauseStatuses,
|
||||
})
|
||||
toast.success("Política atualizada", { id: toastId })
|
||||
} else {
|
||||
|
|
@ -172,7 +222,11 @@ export function SlasManager() {
|
|||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
timeToFirstResponse,
|
||||
responseMode,
|
||||
timeToResolution,
|
||||
solutionMode,
|
||||
alertThreshold: normalizedThreshold,
|
||||
pauseStatuses,
|
||||
})
|
||||
toast.success("Política criada", { id: toastId })
|
||||
}
|
||||
|
|
@ -235,7 +289,7 @@ export function SlasManager() {
|
|||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
|
||||
<IconBolt className="size-4" /> Melhor resolucao
|
||||
<IconBolt className="size-4" /> Melhor resolução
|
||||
</CardTitle>
|
||||
<CardDescription>Menor meta para encerrar chamados.</CardDescription>
|
||||
</CardHeader>
|
||||
|
|
@ -298,9 +352,24 @@ export function SlasManager() {
|
|||
{policy.description && (
|
||||
<p className="text-xs text-neutral-500">{policy.description}</p>
|
||||
)}
|
||||
<div className="flex gap-4 text-xs text-neutral-600">
|
||||
<span>Resposta: {formatMinutes(policy.timeToFirstResponse)}</span>
|
||||
<span>Resolução: {formatMinutes(policy.timeToResolution)}</span>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-neutral-600">
|
||||
<span>
|
||||
Resposta: {formatMinutes(policy.timeToFirstResponse)}
|
||||
{policy.timeToFirstResponse !== null && (
|
||||
<span className="ml-1 text-neutral-400">
|
||||
({policy.responseMode === "business" ? "horas úteis" : "corrido"})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
Resolução: {formatMinutes(policy.timeToResolution)}
|
||||
{policy.timeToResolution !== null && (
|
||||
<span className="ml-1 text-neutral-400">
|
||||
({policy.solutionMode === "business" ? "horas úteis" : "corrido"})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Alerta: {Math.round(policy.alertThreshold * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -382,6 +451,18 @@ export function SlasManager() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={responseMode} onValueChange={(value) => setResponseMode(value as TimeMode)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODE_OPTIONS.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tempo para resolução</Label>
|
||||
|
|
@ -408,6 +489,61 @@ export function SlasManager() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={solutionMode} onValueChange={(value) => setSolutionMode(value as TimeMode)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODE_OPTIONS.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Alerta de proximidade</Label>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Percentual do tempo em que o sistema emitirá alerta antes do vencimento.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={95}
|
||||
value={alertThreshold}
|
||||
onChange={(event) => setAlertThreshold(Number(event.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-neutral-500">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Status que pausam o SLA</Label>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Quando o chamado estiver em um desses status, o cronômetro do SLA será pausado.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PAUSE_STATUS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => togglePauseStatus(option.value)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
||||
pauseStatuses.includes(option.value)
|
||||
? "bg-neutral-900 text-white"
|
||||
: "bg-slate-100 text-neutral-600 hover:bg-slate-200"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
|
|
|
|||
|
|
@ -598,7 +598,7 @@ export function EmailActionConfig({ action, onChange, onRemove, agents }: EmailA
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="AUTO">Auto (detecta pelo destinatario)</SelectItem>
|
||||
<SelectItem value="AUTO">Auto (detecta pelo destinatário)</SelectItem>
|
||||
<SelectItem value="PORTAL">Portal (cliente)</SelectItem>
|
||||
<SelectItem value="STAFF">Painel (agente)</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue