From 4c228e908a57b835c169fd237eadffbdf9167a3f Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 16 Oct 2025 22:56:57 -0300 Subject: [PATCH] feat: enhance machine insights and solidify admin workflows --- agents.md | 1 + convex/machines.ts | 40 +++ convex/schema.ts | 13 + package.json | 2 +- pnpm-lock.yaml | 17 +- .../companies/admin-companies-manager.tsx | 11 +- .../machines/admin-machines-overview.tsx | 237 ++++++++++++++++-- 7 files changed, 286 insertions(+), 35 deletions(-) diff --git a/agents.md b/agents.md index 80e15e9..59abe71 100644 --- a/agents.md +++ b/agents.md @@ -24,6 +24,7 @@ - **React / React DOM**: `18.2.0`. - **Trilha de testes**: Vitest (`pnpm test`) sem modo watch por padrão (`--run --passWithNoTests`). - **CI**: workflow `Quality Checks` (`.github/workflows/quality-checks.yml`) roda `pnpm install`, `prisma:generate`, `lint`, `test`, `build`. Variáveis críticas (`BETTER_AUTH_SECRET`, `NEXT_PUBLIC_APP_URL`, etc.) são definidas apenas no runner — não afetam a VPS. +- **Disciplina pós-mudanças**: sempre que fizer alterações locais, rode **obrigatoriamente** `pnpm lint`, `pnpm build` e `pnpm test` antes de entregar ou abrir PR. Esses comandos são mandatórios também para os agentes/automations, garantindo que o projeto continua íntegro. - **Deploy**: pipeline `ci-cd-web-desktop.yml` (runner self-hosted). Build roda com pnpm 9, Node 20. Web é publicado em `/home/renan/apps/sistema` e o Swarm aponta `sistema_web` para essa pasta. ## Setup local (atualizado) diff --git a/convex/machines.ts b/convex/machines.ts index b7b89a8..23a0cd5 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -349,6 +349,20 @@ async function evaluatePostureAndMaybeRaise( const lastAtPrev = ensureFiniteNumber(prevMeta?.["lastPostureAt"]) ?? 0 await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now }) + await Promise.all( + findings.map((finding) => + ctx.db.insert("machineAlerts", { + tenantId: machine.tenantId, + machineId: machine._id, + companyId: machine.companyId ?? undefined, + kind: finding.kind, + message: finding.message, + severity: finding.severity, + createdAt: now, + }) + ) + ) + if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return @@ -818,6 +832,32 @@ export const listByTenant = query({ }, }) +export const listAlerts = query({ + args: { + machineId: v.id("machines"), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = Math.max(1, Math.min(args.limit ?? 50, 200)) + const alerts = await ctx.db + .query("machineAlerts") + .withIndex("by_machine_created", (q) => q.eq("machineId", args.machineId)) + .order("desc") + .take(limit) + + return alerts.map((alert) => ({ + id: alert._id, + machineId: alert.machineId, + tenantId: alert.tenantId, + companyId: alert.companyId ?? null, + kind: alert.kind, + message: alert.message, + severity: alert.severity, + createdAt: alert.createdAt, + })) + }, +}) + export const updatePersona = mutation({ args: { machineId: v.id("machines"), diff --git a/convex/schema.ts b/convex/schema.ts index ac41bb6..bece478 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -274,6 +274,19 @@ export default defineSchema({ .index("by_tenant_assigned_email", ["tenantId", "assignedUserEmail"]) .index("by_auth_email", ["authEmail"]), + machineAlerts: defineTable({ + tenantId: v.string(), + machineId: v.id("machines"), + companyId: v.optional(v.id("companies")), + kind: v.string(), + message: v.string(), + severity: v.string(), + createdAt: v.number(), + }) + .index("by_machine_created", ["machineId", "createdAt"]) + .index("by_tenant_created", ["tenantId", "createdAt"]) + .index("by_tenant_machine", ["tenantId", "machineId"]), + machineTokens: defineTable({ tenantId: v.string(), machineId: v.id("machines"), diff --git a/package.json b/package.json index c62e1ec..a3051e2 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "better-auth": "^1.3.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "convex": "^1.27.3", + "convex": "^1.28.0", "date-fns": "^4.1.0", "dotenv": "^16.4.5", "lucide-react": "^0.544.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b47441..6182f8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,8 +105,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 convex: - specifier: ^1.27.3 - version: 1.27.3(react@18.2.0) + specifier: ^1.28.0 + version: 1.28.0(react@18.2.0) date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -2631,8 +2631,8 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convex@1.27.3: - resolution: {integrity: sha512-Ebr9lPgXkL7JO5IFr3bG+gYvHskyJjc96Fx0BBNkJUDXrR/bd9/uI4q8QszbglK75XfDu068vR0d/HK2T7tB9Q==} + convex@1.28.0: + resolution: {integrity: sha512-40FgeJ/LxP9TxnkDDztU/A5gcGTdq1klcTT5mM0Ak+kSlQiDktMpjNX1TfkWLxXaE3lI4qvawKH95v2RiYgFxA==} engines: {node: '>=18.0.0', npm: '>=7.0.0'} hasBin: true peerDependencies: @@ -3401,10 +3401,6 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - jwt-decode@4.0.0: - resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} - engines: {node: '>=18'} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6992,10 +6988,9 @@ snapshots: convert-source-map@2.0.0: {} - convex@1.27.3(react@18.2.0): + convex@1.28.0(react@18.2.0): dependencies: esbuild: 0.25.4 - jwt-decode: 4.0.0 prettier: 3.6.2 optionalDependencies: react: 18.2.0 @@ -7926,8 +7921,6 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - jwt-decode@4.0.0: {} - keyv@4.5.4: dependencies: json-buffer: 3.0.1 diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 87311f1..3bdacb0 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -138,7 +138,8 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: body: JSON.stringify(payload), credentials: "include", }) - if (!r.ok) throw new Error("update_failed") + const data = (await r.json().catch(() => ({}))) as { error?: string } + if (!r.ok) throw new Error(data?.error ?? "Falha ao atualizar empresa") } else { const r = await fetch(`/api/admin/companies`, { method: "POST", @@ -146,14 +147,16 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: body: JSON.stringify(payload), credentials: "include", }) - if (!r.ok) throw new Error("create_failed") + const data = (await r.json().catch(() => ({}))) as { error?: string } + if (!r.ok) throw new Error(data?.error ?? "Falha ao criar empresa") } await refresh() resetForm() setEditingId(null) toast.success(editingId ? "Empresa atualizada" : "Empresa criada", { id: "companies" }) - } catch { - toast.error("Não foi possível salvar", { id: "companies" }) + } catch (error) { + const message = error instanceof Error ? error.message : "Não foi possível salvar" + toast.error(message, { id: "companies" }) } }) } diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index 819f97e..a27a47d 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -43,6 +43,22 @@ type MachineSoftware = { source?: string } +type NormalizedSoftwareEntry = { + name: string + version?: string + publisher?: string + installDate?: Date | null + source?: string +} + +type MachineAlertEntry = { + id: string + kind: string + message: string + severity: string + createdAt: number +} + type DetailLineProps = { label: string; value?: string | number | null; classNameValue?: string } type GpuAdapter = { @@ -166,6 +182,13 @@ type MachineInventory = { collaborator?: { email?: string; name?: string; role?: string } } +function collectInitials(name: string): string { + const words = name.split(/\s+/).filter(Boolean) + if (words.length === 0) return "?" + if (words.length === 1) return words[0].slice(0, 2).toUpperCase() + return (words[0][0] + words[1][0]).toUpperCase() +} + function toRecord(value: unknown): Record | null { if (!value || typeof value !== "object") return null return value as Record @@ -278,6 +301,11 @@ type WindowsOsInfo = { currentBuildNumber?: string licenseStatus?: number isActivated?: boolean + licenseStatusText?: string + productId?: string + partialProductKey?: string + computerName?: string + registeredOwner?: string installDate?: Date | null experience?: string } @@ -309,17 +337,35 @@ function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null { undefined const ubrRaw = readFlexible("UBR") const licenseStatus = readNum("LicenseStatus", "licenseStatus") + const licenseStatusTextRaw = readFlexible( + "LicenseStatusDescription", + "licenseStatusDescription", + "StatusDescription", + "statusDescription", + "Status", + "status" + ) + const licenseStatusText = licenseStatusTextRaw ? licenseStatusTextRaw.trim() : undefined const currentBuildNumber = baseBuild && ubrRaw && /^\d+$/.test(ubrRaw) ? `${baseBuild}.${ubrRaw}` : baseBuild ?? readFlexible("BuildNumber", "buildNumber") const currentBuild = baseBuild const isActivatedRaw = value["IsActivated"] ?? value["isActivated"] + const isLicensedRaw = value["IsLicensed"] ?? value["isLicensed"] const isActivated = typeof isActivatedRaw === "boolean" ? isActivatedRaw + : typeof isActivatedRaw === "number" + ? isActivatedRaw === 1 : typeof isActivatedRaw === "string" ? isActivatedRaw.toLowerCase() === "true" - : licenseStatus === 1 + : typeof isLicensedRaw === "boolean" + ? isLicensedRaw + : typeof isLicensedRaw === "number" + ? isLicensedRaw === 1 + : typeof isLicensedRaw === "string" + ? ["1", "true", "licensed", "license", "activated"].includes(isLicensedRaw.toLowerCase()) + : licenseStatus === 1 || Boolean(licenseStatusText && /licensed|activated|licenciado/i.test(licenseStatusText)) const installDate = parseWindowsInstallDate(value["InstallDate"]) ?? @@ -331,6 +377,11 @@ function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null { readFlexible("Experience", "experience", "FeatureExperiencePack", "featureExperiencePack") ?? (currentBuildNumber ? `OS Build ${currentBuildNumber}` : undefined) + const productId = readFlexible("ProductID", "productID", "ProductId", "productId", "ProductKeyId", "productKeyId") + const partialProductKey = readFlexible("PartialProductKey", "partialProductKey") + const computerName = readFlexible("DeviceName", "deviceName", "ComputerName", "computerName", "CSName", "csName", "HostName", "hostName") + const registeredOwner = readFlexible("RegisteredOwner", "registeredOwner", "RegisteredOrganization", "registeredOrganization") + return { productName, editionId, @@ -341,6 +392,11 @@ function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null { currentBuildNumber, licenseStatus, isActivated, + licenseStatusText, + productId, + partialProductKey, + computerName, + registeredOwner, installDate, experience, } @@ -363,6 +419,39 @@ function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null { return parseRecord(record) } +function normalizeWindowsSoftwareEntry(value: unknown): NormalizedSoftwareEntry | null { + const record = toRecord(value) + if (!record) return null + + const name = + readString(record, "DisplayName", "displayName", "Name", "name") ?? + readText(record, "Title", "title") ?? + "" + if (!name) return null + + const version = readString(record, "DisplayVersion", "displayVersion", "Version", "version") ?? undefined + const publisher = readString(record, "Publisher", "publisher", "Vendor", "vendor") ?? undefined + const installDate = + parseWindowsInstallDate(record["InstallDate"]) ?? + parseWindowsInstallDate(record["InstallDateUTC"]) ?? + parseWindowsInstallDate(record["InstallDateTime"]) ?? + parseWindowsInstallDate(record["InstallDateFromRegistry"]) ?? + parseWindowsInstallDate(record["InstallDateFromRegistryUTC"]) ?? + parseWindowsInstallDate(record["InstalledDate"]) ?? + parseWindowsInstallDate(record["InstalledOn"]) ?? + null + + const source = readString(record, "ParentDisplayName", "ParentKeyName", "SystemComponent") ?? undefined + + return { + name, + version, + publisher, + installDate, + source, + } +} + function parseBytesLike(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value if (typeof value === "string") { @@ -552,6 +641,12 @@ function formatPostureAlertKind(raw?: string | null): string { .replace(/\b\w/g, (char) => char.toUpperCase()) } +function postureSeverityClass(severity?: string | null) { + return (severity ?? "warning").toLowerCase() === "critical" + ? "border-rose-500/20 bg-rose-500/10" + : "border-amber-500/20 bg-amber-500/10" +} + function formatRelativeTime(date?: Date | null) { if (!date) return "Nunca" try { @@ -561,6 +656,11 @@ function formatRelativeTime(date?: Date | null) { } } +function formatInstallDate(date?: Date | null) { + if (!date) return null + return `${formatAbsoluteDateTime(date)} (${formatRelativeTime(date)})` +} + function formatDate(date?: Date | null) { if (!date) return "—" return format(date, "dd/MM/yyyy HH:mm") @@ -825,6 +925,14 @@ export function MachineDetails({ machine }: MachineDetailsProps) { api.companies.list, companyQueryArgs ?? ("skip" as const) ) as Array<{ id: string; name: string; slug?: string }> | undefined + // Convex codegen precisa ser atualizado para tipar `machines.listAlerts`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const machinesApi: any = api + const alertsHistory = useQuery( + machine && machinesApi?.machines?.listAlerts ? machinesApi.machines.listAlerts : "skip", + machine && machinesApi?.machines?.listAlerts ? { machineId: machine.id as Id<"machines">, limit: 50 } : ("skip" as const) + ) as MachineAlertEntry[] | undefined + const machineAlertsHistory = alertsHistory ?? [] const metadata = machine?.inventory ?? null const metrics = machine?.metrics ?? null const hardware = metadata?.hardware @@ -878,11 +986,49 @@ export function MachineDetails({ machine }: MachineDetailsProps) { if (windowsSoftwareRaw && typeof windowsSoftwareRaw === "object") return [windowsSoftwareRaw] return [] }, [windowsSoftwareRaw]) + const normalizedWindowsSoftware = useMemo(() => { + return windowsSoftware + .map((item) => normalizeWindowsSoftwareEntry(item)) + .filter((entry): entry is NormalizedSoftwareEntry => Boolean(entry)) + .sort((a, b) => { + const aTime = a.installDate ? a.installDate.getTime() : 0 + const bTime = b.installDate ? b.installDate.getTime() : 0 + if (aTime !== bTime) return bTime - aTime + return a.name.localeCompare(b.name, "pt-BR") + }) + }, [windowsSoftware]) const windowsEditionLabel = windowsOsInfo?.productName ?? windowsOsInfo?.editionId ?? null const windowsVersionLabel = windowsOsInfo?.displayVersion ?? windowsOsInfo?.version ?? windowsOsInfo?.releaseId ?? null const windowsBuildLabel = windowsOsInfo?.currentBuildNumber ?? windowsOsInfo?.currentBuild ?? null const windowsInstallDateLabel = windowsOsInfo?.installDate ? formatAbsoluteDateTime(windowsOsInfo.installDate) : null const windowsExperienceLabel = windowsOsInfo?.experience ?? null + const windowsProductId = windowsOsInfo?.productId ?? null + const windowsPartialProductKey = windowsOsInfo?.partialProductKey ?? null + const windowsComputerName = windowsOsInfo?.computerName ?? hardware?.model ?? machine?.hostname ?? null + const windowsRegisteredOwner = windowsOsInfo?.registeredOwner ?? null + const windowsLicenseStatusLabel = (() => { + if (windowsOsInfo?.licenseStatusText) { + return windowsOsInfo.licenseStatusText + } + switch (windowsOsInfo?.licenseStatus) { + case 0: + return "Sem licença" + case 1: + return "Licenciado" + case 2: + return "Período inicial (OOB Grace)" + case 3: + return "Período exposto (OOT Grace)" + case 4: + return "Período não genuíno" + case 5: + return "Notificação" + case 6: + return "Período estendido" + default: + return null + } + })() const linuxLsblk = linuxExt?.lsblk ?? [] const linuxSmartEntries = linuxExt?.smart ?? [] const normalizedHardwareGpus = Array.isArray(hardware?.gpus) @@ -1019,8 +1165,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) { }, [machine?.id]) const displayedWindowsSoftware = useMemo( - () => (showAllWindowsSoftware ? windowsSoftware : windowsSoftware.slice(0, 8)), - [showAllWindowsSoftware, windowsSoftware] + () => (showAllWindowsSoftware ? normalizedWindowsSoftware : normalizedWindowsSoftware.slice(0, 12)), + [showAllWindowsSoftware, normalizedWindowsSoftware] ) const handleSaveAccess = async () => { @@ -1557,15 +1703,20 @@ export function MachineDetails({ machine }: MachineDetailsProps) {

Informações do Windows

+ + + + +
@@ -1639,11 +1790,11 @@ export function MachineDetails({ machine }: MachineDetailsProps) { ) : null} - {windowsSoftware.length > 0 ? ( + {normalizedWindowsSoftware.length > 0 ? (

Aplicativos instalados

- {windowsSoftware.length > 8 ? ( + {normalizedWindowsSoftware.length > 12 ? ( ) : null}
-
    +
      {displayedWindowsSoftware.map((softwareItem, index) => { - const record = toRecord(softwareItem) ?? {} - const name = readString(record, "DisplayName", "name") ?? "—" - const version = readString(record, "DisplayVersion", "version") - const publisher = readString(record, "Publisher") + const initials = collectInitials(softwareItem.name) + const installedAt = formatInstallDate(softwareItem.installDate) return ( -
    • - {name} - {version ? {version} : null} - {publisher ? · {publisher} : null} +
    • + + {initials} + +
      +
      + {softwareItem.name} + {softwareItem.version ? ( + + {softwareItem.version} + + ) : null} +
      +
      + {softwareItem.publisher ? {softwareItem.publisher} : null} + {installedAt ? Instalado em {installedAt} : null} +
      +
    • ) })} @@ -1848,9 +2011,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {

      Alertas de postura

      {machine?.postureAlerts?.map((a: { kind?: string; message?: string; severity?: string }, i: number) => ( -
      +
      {a?.message ?? formatPostureAlertKind(a?.kind)} {formatPostureAlertKind(a?.kind)}
      @@ -1862,6 +2029,40 @@ export function MachineDetails({ machine }: MachineDetailsProps) { ) : null} +
      +
      +

      Histórico de alertas

      + {machineAlertsHistory.length > 0 ? ( + + Últimos {machineAlertsHistory.length} {machineAlertsHistory.length === 1 ? "evento" : "eventos"} + + ) : null} +
      + {machineAlertsHistory.length > 0 ? ( +
      +
      +
        + {machineAlertsHistory.map((alert) => { + const date = new Date(alert.createdAt) + return ( +
      1. + +
        + {formatPostureAlertKind(alert.kind)} + {formatRelativeTime(date)} +
        +

        {alert.message ?? formatPostureAlertKind(alert.kind)}

        +

        {format(date, "dd/MM/yyyy HH:mm:ss")}

        +
      2. + ) + })} +
      +
      + ) : ( +

      Nenhum alerta registrado para esta máquina.

      + )} +
      +
      {Array.isArray(software) && software.length > 0 ? (