feat: adiciona informacoes de reinicio e melhora SLA global
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 4s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m0s
Quality Checks / Lint, Test and Build (push) Successful in 3m29s
CI/CD Web + Desktop / Deploy Convex functions (push) Successful in 1m24s

- 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:
rever-tecnologia 2025-12-18 09:38:58 -03:00
parent d32b94c22d
commit f39bd46c2b
7 changed files with 338 additions and 19 deletions

View file

@ -1099,6 +1099,41 @@ fn collect_windows_extended() -> serde_json::Value {
}
"#).unwrap_or_else(|| json!({}));
// Último reinício e contagem de boots
let boot_info = ps(r#"
$os = Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime
$lastBoot = $os.LastBootUpTime
# Calcula uptime
$uptime = if ($lastBoot) { (New-TimeSpan -Start $lastBoot -End (Get-Date)).TotalSeconds } else { 0 }
# Conta eventos de boot (ID 6005) - últimos 30 dias para performance
$startDate = (Get-Date).AddDays(-30)
$bootEvents = @()
$bootCount = 0
try {
$events = Get-WinEvent -FilterHashtable @{
LogName = 'System'
ID = 6005
StartTime = $startDate
} -MaxEvents 50 -ErrorAction SilentlyContinue
$bootCount = @($events).Count
$bootEvents = @($events | Select-Object -First 10 | ForEach-Object {
@{
TimeCreated = $_.TimeCreated.ToString('o')
Computer = $_.MachineName
}
})
} catch {}
[PSCustomObject]@{
LastBootTime = if ($lastBoot) { $lastBoot.ToString('o') } else { $null }
UptimeSeconds = [math]::Round($uptime)
BootCountLast30Days = $bootCount
RecentBoots = $bootEvents
}
"#).unwrap_or_else(|| json!({ "LastBootTime": null, "UptimeSeconds": 0, "BootCountLast30Days": 0, "RecentBoots": [] }));
json!({
"windows": {
"software": software,
@ -1125,6 +1160,7 @@ fn collect_windows_extended() -> serde_json::Value {
"networkAdapters": network_adapters,
"monitors": monitors,
"chassis": power_supply,
"bootInfo": boot_info,
}
})
}

View file

@ -331,9 +331,59 @@ async function getMachineLastHeartbeat(
return hb?.lastHeartbeatAt ?? fallback ?? null
}
// Campos do inventory que sao muito grandes e nao devem ser persistidos
// para evitar OOM no Convex (documentos de ~100KB cada)
const INVENTORY_BLOCKLIST = new Set(["software", "extended"])
// Campo software é muito grande e é tratado separadamente via machineSoftware
// Extrai campos importantes do extended antes de bloqueá-lo
function extractFromExtended(extended: unknown): JsonRecord {
const result: JsonRecord = {}
const sanitizedExtended = sanitizeRecord(extended)
if (!sanitizedExtended) return result
// Extrair dados do Windows
const windows = sanitizeRecord(sanitizedExtended["windows"])
if (windows) {
const windowsFields: JsonRecord = {}
// bootInfo - informacoes de reinicio
if (windows["bootInfo"]) {
windowsFields["bootInfo"] = windows["bootInfo"] as JsonValue
}
// osInfo - informacoes do sistema operacional
if (windows["osInfo"]) {
windowsFields["osInfo"] = windows["osInfo"] as JsonValue
}
// cpu, baseboard, bios, memoryModules, videoControllers, disks
for (const key of ["cpu", "baseboard", "bios", "memoryModules", "videoControllers", "disks", "bitLocker", "tpm", "secureBoot", "deviceGuard", "firewallProfiles", "windowsUpdate", "computerSystem", "azureAdStatus", "battery", "thermal", "networkAdapters", "monitors", "chassis", "defender", "hotfix"]) {
if (windows[key]) {
windowsFields[key] = windows[key] as JsonValue
}
}
if (Object.keys(windowsFields).length > 0) {
result["windows"] = windowsFields
}
}
// Extrair dados do Linux
const linux = sanitizeRecord(sanitizedExtended["linux"])
if (linux) {
const linuxFields: JsonRecord = {}
for (const key of ["lsblk", "smart", "lspci", "lsusb", "dmidecode"]) {
if (linux[key]) {
linuxFields[key] = linux[key] as JsonValue
}
}
if (Object.keys(linuxFields).length > 0) {
result["linux"] = linuxFields
}
}
// Extrair dados do macOS
const macos = sanitizeRecord(sanitizedExtended["macos"])
if (macos) {
result["macos"] = macos as JsonValue
}
return result
}
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
const sanitizedPatch = sanitizeRecord(patch)
@ -341,9 +391,10 @@ function mergeInventory(current: JsonRecord | null | undefined, patch: Record<st
return current ? { ...current } : {}
}
const base: JsonRecord = current ? { ...current } : {}
for (const [key, value] of Object.entries(sanitizedPatch)) {
// Filtrar campos volumosos que causam OOM
if (INVENTORY_BLOCKLIST.has(key)) continue
// Filtrar software (extended já foi processado em sanitizeInventoryPayload)
if (key === "software") continue
if (value === undefined) continue
if (isObject(value) && isObject(base[key])) {
base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>)
@ -393,9 +444,20 @@ function ensureString(value: unknown): string | null {
function sanitizeInventoryPayload(value: unknown): JsonRecord | null {
const record = sanitizeRecord(value)
if (!record) return null
for (const blocked of INVENTORY_BLOCKLIST) {
delete record[blocked]
// Extrair campos importantes do extended antes de deletá-lo
if (record["extended"]) {
const extractedExtended = extractFromExtended(record["extended"])
if (Object.keys(extractedExtended).length > 0) {
record["extended"] = extractedExtended
} else {
delete record["extended"]
}
}
// Deletar apenas software (extended já foi processado acima)
delete record["software"]
return record
}

View file

@ -200,7 +200,11 @@ export default defineSchema({
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()), // minutes
responseMode: v.optional(v.string()), // "business" | "calendar"
timeToResolution: v.optional(v.number()), // minutes
solutionMode: v.optional(v.string()), // "business" | "calendar"
alertThreshold: v.optional(v.number()), // 0.1 a 0.95
pauseStatuses: v.optional(v.array(v.string())), // Status que pausam SLA
}).index("by_tenant_name", ["tenantId", "name"]),
tickets: defineTable({

View file

@ -9,6 +9,26 @@ function normalizeName(value: string) {
return value.trim();
}
function normalizeMode(value?: string): "business" | "calendar" {
if (value === "business") return "business";
return "calendar";
}
function normalizeThreshold(value?: number): number {
if (value === undefined || value === null) return 0.8;
if (value < 0.1) return 0.1;
if (value > 0.95) return 0.95;
return value;
}
const VALID_PAUSE_STATUSES = ["PAUSED", "PENDING", "AWAITING_ATTENDANCE"] as const;
function normalizePauseStatuses(statuses?: string[]): string[] {
if (!statuses || statuses.length === 0) return ["PAUSED"];
const filtered = statuses.filter((s) => VALID_PAUSE_STATUSES.includes(s as typeof VALID_PAUSE_STATUSES[number]));
return filtered.length > 0 ? filtered : ["PAUSED"];
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
@ -35,7 +55,11 @@ export const list = query({
name: policy.name,
description: policy.description ?? "",
timeToFirstResponse: policy.timeToFirstResponse ?? null,
responseMode: policy.responseMode ?? "calendar",
timeToResolution: policy.timeToResolution ?? null,
solutionMode: policy.solutionMode ?? "calendar",
alertThreshold: policy.alertThreshold ?? 0.8,
pauseStatuses: policy.pauseStatuses ?? ["PAUSED"],
}));
},
});
@ -47,9 +71,14 @@ export const create = mutation({
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()),
responseMode: v.optional(v.string()),
timeToResolution: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
},
handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
handler: async (ctx, args) => {
const { tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
await requireAdmin(ctx, actorId, tenantId);
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
@ -68,7 +97,11 @@ export const create = mutation({
name: trimmed,
description,
timeToFirstResponse,
responseMode: normalizeMode(responseMode),
timeToResolution,
solutionMode: normalizeMode(solutionMode),
alertThreshold: normalizeThreshold(alertThreshold),
pauseStatuses: normalizePauseStatuses(pauseStatuses),
});
return id;
},
@ -82,9 +115,14 @@ export const update = mutation({
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()),
responseMode: v.optional(v.string()),
timeToResolution: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
},
handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
handler: async (ctx, args) => {
const { policyId, tenantId, actorId, name, description, timeToFirstResponse, responseMode, timeToResolution, solutionMode, alertThreshold, pauseStatuses } = args;
await requireAdmin(ctx, actorId, tenantId);
const policy = await ctx.db.get(policyId);
if (!policy || policy.tenantId !== tenantId) {
@ -106,7 +144,11 @@ export const update = mutation({
name: trimmed,
description,
timeToFirstResponse,
responseMode: normalizeMode(responseMode),
timeToResolution,
solutionMode: normalizeMode(solutionMode),
alertThreshold: normalizeThreshold(alertThreshold),
pauseStatuses: normalizePauseStatuses(pauseStatuses),
});
},
});

View file

@ -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}

View file

@ -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">

View file

@ -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>