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,
|
||||
}
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue