- Cria tabela machineSoftware no schema com indices otimizados - Adiciona mutations para sincronizar softwares do heartbeat - Atualiza heartbeat para processar e salvar softwares - Cria componente DeviceSoftwareList com pesquisa e paginacao - Integra lista de softwares no drawer de detalhes do dispositivo feat(sla): transforma formulario em modal completo - Substitui formulario inline por modal guiado - Adiciona badge "Global" para indicar escopo da politica - Adiciona seletor de unidade de tempo (minutos, horas, dias) - Melhora textos e adiciona dica sobre hierarquia de SLAs fix(reports): ajusta altura do SearchableCombobox 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
276 lines
8 KiB
TypeScript
276 lines
8 KiB
TypeScript
import { mutation, query, internalMutation } from "./_generated/server"
|
|
import { v } from "convex/values"
|
|
import type { Id } from "./_generated/dataModel"
|
|
|
|
// Tipo para software recebido do agente
|
|
type SoftwareInput = {
|
|
name: string
|
|
version?: string
|
|
publisher?: string
|
|
source?: string
|
|
}
|
|
|
|
// Upsert de softwares de uma maquina (chamado pelo heartbeat)
|
|
export const syncFromHeartbeat = internalMutation({
|
|
args: {
|
|
tenantId: v.string(),
|
|
machineId: v.id("machines"),
|
|
software: v.array(
|
|
v.object({
|
|
name: v.string(),
|
|
version: v.optional(v.string()),
|
|
publisher: v.optional(v.string()),
|
|
source: v.optional(v.string()),
|
|
})
|
|
),
|
|
},
|
|
handler: async (ctx, { tenantId, machineId, software }) => {
|
|
const now = Date.now()
|
|
|
|
// Busca softwares existentes da maquina
|
|
const existing = await ctx.db
|
|
.query("machineSoftware")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
|
.collect()
|
|
|
|
const existingMap = new Map(existing.map((s) => [`${s.nameLower}|${s.version ?? ""}`, s]))
|
|
|
|
// Processa cada software recebido
|
|
const seenKeys = new Set<string>()
|
|
for (const item of software) {
|
|
if (!item.name || item.name.trim().length === 0) continue
|
|
|
|
const nameLower = item.name.toLowerCase().trim()
|
|
const key = `${nameLower}|${item.version ?? ""}`
|
|
seenKeys.add(key)
|
|
|
|
const existingDoc = existingMap.get(key)
|
|
if (existingDoc) {
|
|
// Atualiza lastSeenAt se ja existe
|
|
await ctx.db.patch(existingDoc._id, {
|
|
lastSeenAt: now,
|
|
publisher: item.publisher || existingDoc.publisher,
|
|
source: item.source || existingDoc.source,
|
|
})
|
|
} else {
|
|
// Cria novo registro
|
|
await ctx.db.insert("machineSoftware", {
|
|
tenantId,
|
|
machineId,
|
|
name: item.name.trim(),
|
|
nameLower,
|
|
version: item.version?.trim() || undefined,
|
|
publisher: item.publisher?.trim() || undefined,
|
|
source: item.source?.trim() || undefined,
|
|
detectedAt: now,
|
|
lastSeenAt: now,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Remove softwares que nao foram vistos (desinstalados)
|
|
// So remove se o software nao foi visto nas ultimas 24 horas
|
|
const staleThreshold = now - 24 * 60 * 60 * 1000
|
|
for (const doc of existing) {
|
|
const key = `${doc.nameLower}|${doc.version ?? ""}`
|
|
if (!seenKeys.has(key) && doc.lastSeenAt < staleThreshold) {
|
|
await ctx.db.delete(doc._id)
|
|
}
|
|
}
|
|
|
|
return { processed: software.length }
|
|
},
|
|
})
|
|
|
|
// Lista softwares de uma maquina com paginacao e filtros
|
|
export const listByMachine = query({
|
|
args: {
|
|
tenantId: v.string(),
|
|
viewerId: v.id("users"),
|
|
machineId: v.id("machines"),
|
|
search: v.optional(v.string()),
|
|
limit: v.optional(v.number()),
|
|
cursor: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, { machineId, search, limit = 50, cursor }) => {
|
|
const pageLimit = Math.min(limit, 100)
|
|
|
|
let query = ctx.db
|
|
.query("machineSoftware")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
|
|
|
// Coleta todos e filtra em memoria (Convex nao suporta LIKE)
|
|
const all = await query.collect()
|
|
|
|
// Filtra por search se fornecido
|
|
let filtered = all
|
|
if (search && search.trim().length > 0) {
|
|
const searchLower = search.toLowerCase().trim()
|
|
filtered = all.filter(
|
|
(s) =>
|
|
s.nameLower.includes(searchLower) ||
|
|
(s.publisher && s.publisher.toLowerCase().includes(searchLower)) ||
|
|
(s.version && s.version.toLowerCase().includes(searchLower))
|
|
)
|
|
}
|
|
|
|
// Ordena por nome
|
|
filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower))
|
|
|
|
// Paginacao manual
|
|
let startIndex = 0
|
|
if (cursor) {
|
|
const cursorIndex = filtered.findIndex((s) => s._id === cursor)
|
|
if (cursorIndex >= 0) {
|
|
startIndex = cursorIndex + 1
|
|
}
|
|
}
|
|
|
|
const page = filtered.slice(startIndex, startIndex + pageLimit)
|
|
const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null
|
|
|
|
return {
|
|
items: page.map((s) => ({
|
|
id: s._id,
|
|
name: s.name,
|
|
version: s.version ?? null,
|
|
publisher: s.publisher ?? null,
|
|
source: s.source ?? null,
|
|
detectedAt: s.detectedAt,
|
|
lastSeenAt: s.lastSeenAt,
|
|
})),
|
|
total: filtered.length,
|
|
nextCursor,
|
|
}
|
|
},
|
|
})
|
|
|
|
// Lista softwares de todas as maquinas de um tenant (para admin)
|
|
export const listByTenant = query({
|
|
args: {
|
|
tenantId: v.string(),
|
|
viewerId: v.id("users"),
|
|
search: v.optional(v.string()),
|
|
machineId: v.optional(v.id("machines")),
|
|
limit: v.optional(v.number()),
|
|
cursor: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, { tenantId, search, machineId, limit = 50, cursor }) => {
|
|
const pageLimit = Math.min(limit, 100)
|
|
|
|
// Busca por tenant ou por maquina especifica
|
|
let all: Array<{
|
|
_id: Id<"machineSoftware">
|
|
tenantId: string
|
|
machineId: Id<"machines">
|
|
name: string
|
|
nameLower: string
|
|
version?: string
|
|
publisher?: string
|
|
source?: string
|
|
detectedAt: number
|
|
lastSeenAt: number
|
|
}>
|
|
|
|
if (machineId) {
|
|
all = await ctx.db
|
|
.query("machineSoftware")
|
|
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", tenantId).eq("machineId", machineId))
|
|
.collect()
|
|
} else {
|
|
// Busca por tenant - pode ser grande, limita
|
|
all = await ctx.db
|
|
.query("machineSoftware")
|
|
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
|
|
.take(5000)
|
|
}
|
|
|
|
// Filtra por search
|
|
let filtered = all
|
|
if (search && search.trim().length > 0) {
|
|
const searchLower = search.toLowerCase().trim()
|
|
filtered = all.filter(
|
|
(s) =>
|
|
s.nameLower.includes(searchLower) ||
|
|
(s.publisher && s.publisher.toLowerCase().includes(searchLower)) ||
|
|
(s.version && s.version.toLowerCase().includes(searchLower))
|
|
)
|
|
}
|
|
|
|
// Ordena por nome
|
|
filtered.sort((a, b) => a.nameLower.localeCompare(b.nameLower))
|
|
|
|
// Paginacao
|
|
let startIndex = 0
|
|
if (cursor) {
|
|
const cursorIndex = filtered.findIndex((s) => s._id === cursor)
|
|
if (cursorIndex >= 0) {
|
|
startIndex = cursorIndex + 1
|
|
}
|
|
}
|
|
|
|
const page = filtered.slice(startIndex, startIndex + pageLimit)
|
|
const nextCursor = page.length === pageLimit ? page[page.length - 1]._id : null
|
|
|
|
// Busca nomes das maquinas
|
|
const machineIds = [...new Set(page.map((s) => s.machineId))]
|
|
const machines = await Promise.all(machineIds.map((id) => ctx.db.get(id)))
|
|
const machineNames = new Map(
|
|
machines.filter(Boolean).map((m) => [m!._id, m!.displayName || m!.hostname])
|
|
)
|
|
|
|
return {
|
|
items: page.map((s) => ({
|
|
id: s._id,
|
|
machineId: s.machineId,
|
|
machineName: machineNames.get(s.machineId) ?? "Desconhecido",
|
|
name: s.name,
|
|
version: s.version ?? null,
|
|
publisher: s.publisher ?? null,
|
|
source: s.source ?? null,
|
|
detectedAt: s.detectedAt,
|
|
lastSeenAt: s.lastSeenAt,
|
|
})),
|
|
total: filtered.length,
|
|
nextCursor,
|
|
}
|
|
},
|
|
})
|
|
|
|
// Conta softwares de uma maquina
|
|
export const countByMachine = query({
|
|
args: {
|
|
machineId: v.id("machines"),
|
|
},
|
|
handler: async (ctx, { machineId }) => {
|
|
const software = await ctx.db
|
|
.query("machineSoftware")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
|
.collect()
|
|
|
|
return { count: software.length }
|
|
},
|
|
})
|
|
|
|
// Conta softwares unicos por tenant (para relatorios)
|
|
export const stats = query({
|
|
args: {
|
|
tenantId: v.string(),
|
|
viewerId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { tenantId }) => {
|
|
const software = await ctx.db
|
|
.query("machineSoftware")
|
|
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
|
|
.take(10000)
|
|
|
|
const uniqueNames = new Set(software.map((s) => s.nameLower))
|
|
const machineIds = new Set(software.map((s) => s.machineId))
|
|
|
|
return {
|
|
totalInstances: software.length,
|
|
uniqueSoftware: uniqueNames.size,
|
|
machinesWithSoftware: machineIds.size,
|
|
}
|
|
},
|
|
})
|