feat(devices): implementa tabela separada para softwares instalados
- 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>
This commit is contained in:
parent
ef2545221d
commit
23fe67e7d3
7 changed files with 741 additions and 205 deletions
276
convex/machineSoftware.ts
Normal file
276
convex/machineSoftware.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// ci: trigger convex functions deploy (no-op)
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { api } from "./_generated/api"
|
||||
import { internal, api } from "./_generated/api"
|
||||
import { paginationOptsValidator } from "convex/server"
|
||||
import { ConvexError, v, Infer } from "convex/values"
|
||||
import { sha256 } from "@noble/hashes/sha2.js"
|
||||
|
|
@ -1010,6 +1010,34 @@ export const heartbeat = mutation({
|
|||
await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now)
|
||||
}
|
||||
|
||||
// Processar softwares instalados (armazenados em tabela separada)
|
||||
// Os dados de software sao extraidos ANTES de sanitizar o inventory
|
||||
const rawInventory = args.inventory ?? args.metadata?.inventory
|
||||
if (rawInventory && typeof rawInventory === "object") {
|
||||
const softwareArray = (rawInventory as Record<string, unknown>)["software"]
|
||||
if (Array.isArray(softwareArray) && softwareArray.length > 0) {
|
||||
const validSoftware = softwareArray
|
||||
.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object")
|
||||
.map((item) => ({
|
||||
name: typeof item.name === "string" ? item.name : "",
|
||||
version: typeof item.version === "string" ? item.version : undefined,
|
||||
publisher: typeof item.publisher === "string" || typeof item.source === "string"
|
||||
? (item.publisher as string) || (item.source as string)
|
||||
: undefined,
|
||||
source: typeof item.source === "string" ? item.source : undefined,
|
||||
}))
|
||||
.filter((item) => item.name.length > 0)
|
||||
|
||||
if (validSoftware.length > 0) {
|
||||
await ctx.runMutation(internal.machineSoftware.syncFromHeartbeat, {
|
||||
tenantId: machine.tenantId,
|
||||
machineId: machine._id,
|
||||
software: validSoftware,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.patch(token._id, {
|
||||
lastUsedAt: now,
|
||||
usageCount: (token.usageCount ?? 0) + 1,
|
||||
|
|
|
|||
|
|
@ -821,6 +821,25 @@ export default defineSchema({
|
|||
})
|
||||
.index("by_machine", ["machineId"]),
|
||||
|
||||
// Tabela separada para softwares instalados - permite filtros, pesquisa e paginacao
|
||||
// Os dados sao enviados pelo agente desktop e armazenados aqui de forma normalizada
|
||||
machineSoftware: defineTable({
|
||||
tenantId: v.string(),
|
||||
machineId: v.id("machines"),
|
||||
name: v.string(),
|
||||
nameLower: v.string(), // Para busca case-insensitive
|
||||
version: v.optional(v.string()),
|
||||
publisher: v.optional(v.string()),
|
||||
source: v.optional(v.string()), // dpkg, rpm, windows, macos, etc
|
||||
installedAt: v.optional(v.number()), // Data de instalacao (se disponivel)
|
||||
detectedAt: v.number(), // Quando foi detectado pelo agente
|
||||
lastSeenAt: v.number(), // Ultima vez que foi visto no heartbeat
|
||||
})
|
||||
.index("by_machine", ["machineId"])
|
||||
.index("by_machine_name", ["machineId", "nameLower"])
|
||||
.index("by_tenant_name", ["tenantId", "nameLower"])
|
||||
.index("by_tenant_machine", ["tenantId", "machineId"]),
|
||||
|
||||
machineTokens: defineTable({
|
||||
tenantId: v.string(),
|
||||
machineId: v.id("machines"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue