feat: enhance machine insights and solidify admin workflows

This commit is contained in:
Esdras Renan 2025-10-16 22:56:57 -03:00
parent ac986410a3
commit 4c228e908a
7 changed files with 286 additions and 35 deletions

View file

@ -24,6 +24,7 @@
- **React / React DOM**: `18.2.0`. - **React / React DOM**: `18.2.0`.
- **Trilha de testes**: Vitest (`pnpm test`) sem modo watch por padrão (`--run --passWithNoTests`). - **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. - **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. - **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) ## Setup local (atualizado)

View file

@ -349,6 +349,20 @@ async function evaluatePostureAndMaybeRaise(
const lastAtPrev = ensureFiniteNumber(prevMeta?.["lastPostureAt"]) ?? 0 const lastAtPrev = ensureFiniteNumber(prevMeta?.["lastPostureAt"]) ?? 0
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now }) 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 ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) 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({ export const updatePersona = mutation({
args: { args: {
machineId: v.id("machines"), machineId: v.id("machines"),

View file

@ -274,6 +274,19 @@ export default defineSchema({
.index("by_tenant_assigned_email", ["tenantId", "assignedUserEmail"]) .index("by_tenant_assigned_email", ["tenantId", "assignedUserEmail"])
.index("by_auth_email", ["authEmail"]), .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({ machineTokens: defineTable({
tenantId: v.string(), tenantId: v.string(),
machineId: v.id("machines"), machineId: v.id("machines"),

View file

@ -48,7 +48,7 @@
"better-auth": "^1.3.26", "better-auth": "^1.3.26",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"convex": "^1.27.3", "convex": "^1.28.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",

17
pnpm-lock.yaml generated
View file

@ -105,8 +105,8 @@ importers:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
convex: convex:
specifier: ^1.27.3 specifier: ^1.28.0
version: 1.27.3(react@18.2.0) version: 1.28.0(react@18.2.0)
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@ -2631,8 +2631,8 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
convex@1.27.3: convex@1.28.0:
resolution: {integrity: sha512-Ebr9lPgXkL7JO5IFr3bG+gYvHskyJjc96Fx0BBNkJUDXrR/bd9/uI4q8QszbglK75XfDu068vR0d/HK2T7tB9Q==} resolution: {integrity: sha512-40FgeJ/LxP9TxnkDDztU/A5gcGTdq1klcTT5mM0Ak+kSlQiDktMpjNX1TfkWLxXaE3lI4qvawKH95v2RiYgFxA==}
engines: {node: '>=18.0.0', npm: '>=7.0.0'} engines: {node: '>=18.0.0', npm: '>=7.0.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@ -3401,10 +3401,6 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -6992,10 +6988,9 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
convex@1.27.3(react@18.2.0): convex@1.28.0(react@18.2.0):
dependencies: dependencies:
esbuild: 0.25.4 esbuild: 0.25.4
jwt-decode: 4.0.0
prettier: 3.6.2 prettier: 3.6.2
optionalDependencies: optionalDependencies:
react: 18.2.0 react: 18.2.0
@ -7926,8 +7921,6 @@ snapshots:
object.assign: 4.1.7 object.assign: 4.1.7
object.values: 1.2.1 object.values: 1.2.1
jwt-decode@4.0.0: {}
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1

View file

@ -138,7 +138,8 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
body: JSON.stringify(payload), body: JSON.stringify(payload),
credentials: "include", 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 { } else {
const r = await fetch(`/api/admin/companies`, { const r = await fetch(`/api/admin/companies`, {
method: "POST", method: "POST",
@ -146,14 +147,16 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
body: JSON.stringify(payload), body: JSON.stringify(payload),
credentials: "include", 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() await refresh()
resetForm() resetForm()
setEditingId(null) setEditingId(null)
toast.success(editingId ? "Empresa atualizada" : "Empresa criada", { id: "companies" }) toast.success(editingId ? "Empresa atualizada" : "Empresa criada", { id: "companies" })
} catch { } catch (error) {
toast.error("Não foi possível salvar", { id: "companies" }) const message = error instanceof Error ? error.message : "Não foi possível salvar"
toast.error(message, { id: "companies" })
} }
}) })
} }

View file

@ -43,6 +43,22 @@ type MachineSoftware = {
source?: string 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 DetailLineProps = { label: string; value?: string | number | null; classNameValue?: string }
type GpuAdapter = { type GpuAdapter = {
@ -166,6 +182,13 @@ type MachineInventory = {
collaborator?: { email?: string; name?: string; role?: string } 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<string, unknown> | null { function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object") return null if (!value || typeof value !== "object") return null
return value as Record<string, unknown> return value as Record<string, unknown>
@ -278,6 +301,11 @@ type WindowsOsInfo = {
currentBuildNumber?: string currentBuildNumber?: string
licenseStatus?: number licenseStatus?: number
isActivated?: boolean isActivated?: boolean
licenseStatusText?: string
productId?: string
partialProductKey?: string
computerName?: string
registeredOwner?: string
installDate?: Date | null installDate?: Date | null
experience?: string experience?: string
} }
@ -309,17 +337,35 @@ function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null {
undefined undefined
const ubrRaw = readFlexible("UBR") const ubrRaw = readFlexible("UBR")
const licenseStatus = readNum("LicenseStatus", "licenseStatus") const licenseStatus = readNum("LicenseStatus", "licenseStatus")
const licenseStatusTextRaw = readFlexible(
"LicenseStatusDescription",
"licenseStatusDescription",
"StatusDescription",
"statusDescription",
"Status",
"status"
)
const licenseStatusText = licenseStatusTextRaw ? licenseStatusTextRaw.trim() : undefined
const currentBuildNumber = const currentBuildNumber =
baseBuild && ubrRaw && /^\d+$/.test(ubrRaw) ? `${baseBuild}.${ubrRaw}` : baseBuild ?? readFlexible("BuildNumber", "buildNumber") baseBuild && ubrRaw && /^\d+$/.test(ubrRaw) ? `${baseBuild}.${ubrRaw}` : baseBuild ?? readFlexible("BuildNumber", "buildNumber")
const currentBuild = baseBuild const currentBuild = baseBuild
const isActivatedRaw = value["IsActivated"] ?? value["isActivated"] const isActivatedRaw = value["IsActivated"] ?? value["isActivated"]
const isLicensedRaw = value["IsLicensed"] ?? value["isLicensed"]
const isActivated = const isActivated =
typeof isActivatedRaw === "boolean" typeof isActivatedRaw === "boolean"
? isActivatedRaw ? isActivatedRaw
: typeof isActivatedRaw === "number"
? isActivatedRaw === 1
: typeof isActivatedRaw === "string" : typeof isActivatedRaw === "string"
? isActivatedRaw.toLowerCase() === "true" ? 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 = const installDate =
parseWindowsInstallDate(value["InstallDate"]) ?? parseWindowsInstallDate(value["InstallDate"]) ??
@ -331,6 +377,11 @@ function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null {
readFlexible("Experience", "experience", "FeatureExperiencePack", "featureExperiencePack") ?? readFlexible("Experience", "experience", "FeatureExperiencePack", "featureExperiencePack") ??
(currentBuildNumber ? `OS Build ${currentBuildNumber}` : undefined) (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 { return {
productName, productName,
editionId, editionId,
@ -341,6 +392,11 @@ function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null {
currentBuildNumber, currentBuildNumber,
licenseStatus, licenseStatus,
isActivated, isActivated,
licenseStatusText,
productId,
partialProductKey,
computerName,
registeredOwner,
installDate, installDate,
experience, experience,
} }
@ -363,6 +419,39 @@ function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null {
return parseRecord(record) 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 { function parseBytesLike(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string") { if (typeof value === "string") {
@ -552,6 +641,12 @@ function formatPostureAlertKind(raw?: string | null): string {
.replace(/\b\w/g, (char) => char.toUpperCase()) .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) { function formatRelativeTime(date?: Date | null) {
if (!date) return "Nunca" if (!date) return "Nunca"
try { 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) { function formatDate(date?: Date | null) {
if (!date) return "—" if (!date) return "—"
return format(date, "dd/MM/yyyy HH:mm") return format(date, "dd/MM/yyyy HH:mm")
@ -825,6 +925,14 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
api.companies.list, api.companies.list,
companyQueryArgs ?? ("skip" as const) companyQueryArgs ?? ("skip" as const)
) as Array<{ id: string; name: string; slug?: string }> | undefined ) 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 metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null const metrics = machine?.metrics ?? null
const hardware = metadata?.hardware const hardware = metadata?.hardware
@ -878,11 +986,49 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
if (windowsSoftwareRaw && typeof windowsSoftwareRaw === "object") return [windowsSoftwareRaw] if (windowsSoftwareRaw && typeof windowsSoftwareRaw === "object") return [windowsSoftwareRaw]
return [] return []
}, [windowsSoftwareRaw]) }, [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 windowsEditionLabel = windowsOsInfo?.productName ?? windowsOsInfo?.editionId ?? null
const windowsVersionLabel = windowsOsInfo?.displayVersion ?? windowsOsInfo?.version ?? windowsOsInfo?.releaseId ?? null const windowsVersionLabel = windowsOsInfo?.displayVersion ?? windowsOsInfo?.version ?? windowsOsInfo?.releaseId ?? null
const windowsBuildLabel = windowsOsInfo?.currentBuildNumber ?? windowsOsInfo?.currentBuild ?? null const windowsBuildLabel = windowsOsInfo?.currentBuildNumber ?? windowsOsInfo?.currentBuild ?? null
const windowsInstallDateLabel = windowsOsInfo?.installDate ? formatAbsoluteDateTime(windowsOsInfo.installDate) : null const windowsInstallDateLabel = windowsOsInfo?.installDate ? formatAbsoluteDateTime(windowsOsInfo.installDate) : null
const windowsExperienceLabel = windowsOsInfo?.experience ?? 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 linuxLsblk = linuxExt?.lsblk ?? []
const linuxSmartEntries = linuxExt?.smart ?? [] const linuxSmartEntries = linuxExt?.smart ?? []
const normalizedHardwareGpus = Array.isArray(hardware?.gpus) const normalizedHardwareGpus = Array.isArray(hardware?.gpus)
@ -1019,8 +1165,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}, [machine?.id]) }, [machine?.id])
const displayedWindowsSoftware = useMemo( const displayedWindowsSoftware = useMemo(
() => (showAllWindowsSoftware ? windowsSoftware : windowsSoftware.slice(0, 8)), () => (showAllWindowsSoftware ? normalizedWindowsSoftware : normalizedWindowsSoftware.slice(0, 12)),
[showAllWindowsSoftware, windowsSoftware] [showAllWindowsSoftware, normalizedWindowsSoftware]
) )
const handleSaveAccess = async () => { const handleSaveAccess = async () => {
@ -1557,15 +1703,20 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3"> <div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Informações do Windows</p> <p className="text-xs font-semibold uppercase text-slate-500">Informações do Windows</p>
<div className="mt-2 grid gap-1 text-sm text-muted-foreground"> <div className="mt-2 grid gap-1 text-sm text-muted-foreground">
<DetailLine label="Nome do dispositivo" value={windowsComputerName ?? "—"} classNameValue="break-words" />
<DetailLine label="Edição" value={windowsEditionLabel ?? "—"} classNameValue="break-words" /> <DetailLine label="Edição" value={windowsEditionLabel ?? "—"} classNameValue="break-words" />
<DetailLine label="Versão" value={windowsVersionLabel ?? "—"} /> <DetailLine label="Versão" value={windowsVersionLabel ?? "—"} />
<DetailLine label="Compilação do SO" value={windowsBuildLabel ?? "—"} /> <DetailLine label="Compilação do SO" value={windowsBuildLabel ?? "—"} />
<DetailLine label="Instalado em" value={windowsInstallDateLabel ?? "—"} /> <DetailLine label="Instalado em" value={windowsInstallDateLabel ?? "—"} />
<DetailLine label="Experiência" value={windowsExperienceLabel ?? "—"} /> <DetailLine label="Experiência" value={windowsExperienceLabel ?? "—"} />
<DetailLine label="Status da licença" value={windowsLicenseStatusLabel ?? "—"} classNameValue="break-words" />
<DetailLine <DetailLine
label="Ativação" label="Ativação"
value={windowsActivationStatus == null ? "—" : windowsActivationStatus ? "Ativado" : "Não ativado"} value={windowsActivationStatus == null ? "—" : windowsActivationStatus ? "Ativado" : "Não ativado"}
/> />
<DetailLine label="ID do produto" value={windowsProductId ?? "—"} classNameValue="break-words" />
<DetailLine label="Chave parcial" value={windowsPartialProductKey ?? "—"} />
<DetailLine label="Proprietário registrado" value={windowsRegisteredOwner ?? "—"} classNameValue="break-words" />
<DetailLine label="Número de série" value={windowsSerialNumber ?? "—"} /> <DetailLine label="Número de série" value={windowsSerialNumber ?? "—"} />
</div> </div>
</div> </div>
@ -1639,11 +1790,11 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</div> </div>
) : null} ) : null}
{windowsSoftware.length > 0 ? ( {normalizedWindowsSoftware.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3"> <div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold uppercase text-slate-500">Aplicativos instalados</p> <p className="text-xs font-semibold uppercase text-slate-500">Aplicativos instalados</p>
{windowsSoftware.length > 8 ? ( {normalizedWindowsSoftware.length > 12 ? (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -1651,21 +1802,33 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
className="h-7 px-2 text-xs text-slate-600 hover:bg-slate-200/60" className="h-7 px-2 text-xs text-slate-600 hover:bg-slate-200/60"
onClick={() => setShowAllWindowsSoftware((prev) => !prev)} onClick={() => setShowAllWindowsSoftware((prev) => !prev)}
> >
{showAllWindowsSoftware ? "Mostrar menos" : `Ver todos (${windowsSoftware.length})`} {showAllWindowsSoftware ? "Mostrar menos" : `Ver todos (${normalizedWindowsSoftware.length})`}
</Button> </Button>
) : null} ) : null}
</div> </div>
<ul className="mt-2 grid gap-1 text-xs text-muted-foreground"> <ul className="mt-2 space-y-2">
{displayedWindowsSoftware.map((softwareItem, index) => { {displayedWindowsSoftware.map((softwareItem, index) => {
const record = toRecord(softwareItem) ?? {} const initials = collectInitials(softwareItem.name)
const name = readString(record, "DisplayName", "name") ?? "—" const installedAt = formatInstallDate(softwareItem.installDate)
const version = readString(record, "DisplayVersion", "version")
const publisher = readString(record, "Publisher")
return ( return (
<li key={`sw-${index}`}> <li key={`sw-${index}`} className="flex items-start gap-3 rounded-md border border-slate-200 bg-white px-3 py-2">
<span className="font-medium text-foreground">{name}</span> <span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold uppercase text-slate-700">
{version ? <span className="ml-1">{version}</span> : null} {initials}
{publisher ? <span className="ml-1">· {publisher}</span> : null} </span>
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{softwareItem.name}</span>
{softwareItem.version ? (
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-[11px] text-slate-700">
{softwareItem.version}
</Badge>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
{softwareItem.publisher ? <span>{softwareItem.publisher}</span> : null}
{installedAt ? <span>Instalado em {installedAt}</span> : null}
</div>
</div>
</li> </li>
) )
})} })}
@ -1848,9 +2011,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<h4 className="text-sm font-semibold">Alertas de postura</h4> <h4 className="text-sm font-semibold">Alertas de postura</h4>
<div className="space-y-2"> <div className="space-y-2">
{machine?.postureAlerts?.map((a: { kind?: string; message?: string; severity?: string }, i: number) => ( {machine?.postureAlerts?.map((a: { kind?: string; message?: string; severity?: string }, i: number) => (
<div key={`alert-${i}`} className={cn("flex items-center justify-between rounded-md border px-3 py-2 text-sm", <div
(a?.severity ?? "warning").toLowerCase() === "critical" ? "border-rose-500/20 bg-rose-500/10" : "border-amber-500/20 bg-amber-500/10") key={`alert-${i}`}
}> className={cn(
"flex items-center justify-between rounded-md border px-3 py-2 text-sm",
postureSeverityClass(a?.severity)
)}
>
<span className="font-medium text-foreground">{a?.message ?? formatPostureAlertKind(a?.kind)}</span> <span className="font-medium text-foreground">{a?.message ?? formatPostureAlertKind(a?.kind)}</span>
<Badge variant="outline">{formatPostureAlertKind(a?.kind)}</Badge> <Badge variant="outline">{formatPostureAlertKind(a?.kind)}</Badge>
</div> </div>
@ -1862,6 +2029,40 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</section> </section>
) : null} ) : null}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">Histórico de alertas</h4>
{machineAlertsHistory.length > 0 ? (
<span className="text-xs text-muted-foreground">
Últimos {machineAlertsHistory.length} {machineAlertsHistory.length === 1 ? "evento" : "eventos"}
</span>
) : null}
</div>
{machineAlertsHistory.length > 0 ? (
<div className="relative max-h-64 overflow-y-auto pr-2">
<div className="absolute left-3 top-3 bottom-3 w-px bg-slate-200" />
<ol className="space-y-3 pl-6">
{machineAlertsHistory.map((alert) => {
const date = new Date(alert.createdAt)
return (
<li key={alert.id} className="relative rounded-md border border-slate-200/80 bg-white px-3 py-2 text-xs shadow-sm">
<span className="absolute -left-5 top-3 inline-flex size-3 items-center justify-center rounded-full border border-white bg-slate-200 ring-2 ring-white" />
<div className={cn("flex items-center justify-between", postureSeverityClass(alert.severity))}>
<span className="text-xs font-medium uppercase tracking-wide text-slate-600">{formatPostureAlertKind(alert.kind)}</span>
<span className="text-xs text-slate-500">{formatRelativeTime(date)}</span>
</div>
<p className="mt-1 text-sm text-foreground">{alert.message ?? formatPostureAlertKind(alert.kind)}</p>
<p className="mt-1 text-[11px] text-muted-foreground">{format(date, "dd/MM/yyyy HH:mm:ss")}</p>
</li>
)
})}
</ol>
</div>
) : (
<p className="text-xs text-muted-foreground">Nenhum alerta registrado para esta máquina.</p>
)}
</section>
<div className="flex flex-wrap gap-2 pt-2"> <div className="flex flex-wrap gap-2 pt-2">
{Array.isArray(software) && software.length > 0 ? ( {Array.isArray(software) && software.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv(software, "softwares.csv")}>Softwares CSV</Button> <Button size="sm" variant="outline" onClick={() => exportCsv(software, "softwares.csv")}>Softwares CSV</Button>