feat: add health dashboard and local ticket archive

This commit is contained in:
rever-tecnologia 2025-12-10 14:43:13 -03:00
parent 0d78abbb6f
commit 0a6b808d99
15 changed files with 824 additions and 60 deletions

View file

@ -0,0 +1,73 @@
import { mkdir, writeFile } from "fs/promises"
import { join, dirname } from "path"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { env } from "@/lib/env"
import { createConvexClient } from "@/server/convex-client"
type ArchiveItem = {
ticket: Record<string, unknown>
comments: Array<Record<string, unknown>>
events: Array<Record<string, unknown>>
}
type ExportResponse = {
total: number
items: ArchiveItem[]
}
function assertArchiveSecret(): string {
const secret = env.INTERNAL_HEALTH_TOKEN ?? env.REPORTS_CRON_SECRET
if (!secret) {
throw new Error("Defina INTERNAL_HEALTH_TOKEN ou REPORTS_CRON_SECRET para exportar tickets")
}
return secret
}
function nowIso() {
return new Date().toISOString().replace(/[:.]/g, "-")
}
export async function exportResolvedTicketsToDisk(options?: {
days?: number
limit?: number
tenantId?: string
}) {
const days = options?.days ?? 365
const limit = options?.limit ?? 50
const tenantId = options?.tenantId ?? DEFAULT_TENANT_ID
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000
const secret = assertArchiveSecret()
const client = createConvexClient()
// @ts-expect-error - exportForArchive é adicionada manualmente sem regen do client
const res = (await client.query(api.tickets.exportForArchive, {
tenantId,
before: cutoff,
limit,
secret,
})) as ExportResponse
const archiveDir = env.ARCHIVE_DIR ?? "./archives"
const filename = `tickets-archive-${nowIso()}-resolved-${days}d.jsonl`
const fullPath = join(archiveDir, filename)
await mkdir(dirname(fullPath), { recursive: true })
const lines = res.items.map((item) =>
JSON.stringify({
ticketId: item.ticket?._id ?? null,
tenantId,
archivedAt: Date.now(),
ticket: item.ticket,
comments: item.comments,
events: item.events,
})
)
await writeFile(fullPath, lines.join("\n"), { encoding: "utf-8" })
return {
written: res.items.length,
file: fullPath,
}
}

98
src/server/health.ts Normal file
View file

@ -0,0 +1,98 @@
import { api } from "@/convex/_generated/api"
import { RETENTION_POLICY, RETENTION_STRATEGY } from "@/lib/retention"
import { prisma } from "@/lib/prisma"
import { createConvexClient } from "@/server/convex-client"
import { env } from "@/lib/env"
const OPEN_TICKET_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]
type DeviceHealth = {
machines: number
online: number
warning: number
offline: number
withoutHeartbeat: number
newestHeartbeatAgeMs: number | null
oldestHeartbeatAgeMs: number | null
thresholds: {
offlineMs: number
staleMs: number
}
truncated: boolean
}
export type HealthSnapshot = {
generatedAt: string
tickets: {
total: number
open: number
last7d: number
last24h: number
}
accounts: {
users: number
companies: number
}
devices: DeviceHealth | null
retention: typeof RETENTION_POLICY
retentionStrategy: typeof RETENTION_STRATEGY
notes?: string
}
function toIsoString(date: Date) {
return date.toISOString()
}
export async function getHealthSnapshot(): Promise<HealthSnapshot> {
const now = new Date()
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const [totalTickets, openTickets, lastWeekTickets, lastDayTickets, userCount, companyCount] = await Promise.all([
prisma.ticket.count(),
prisma.ticket.count({ where: { status: { in: OPEN_TICKET_STATUSES } } }),
prisma.ticket.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
prisma.ticket.count({ where: { createdAt: { gte: oneDayAgo } } }),
prisma.user.count(),
prisma.company.count(),
])
let devices: DeviceHealth | null = null
try {
const client = createConvexClient()
const convexHealth = await client.query(api.ops.healthSnapshot, {
token: env.INTERNAL_HEALTH_TOKEN ?? env.REPORTS_CRON_SECRET ?? undefined,
})
devices = {
machines: convexHealth.totals.machines,
online: convexHealth.connectivity.online,
warning: convexHealth.connectivity.warning,
offline: convexHealth.connectivity.offline,
withoutHeartbeat: convexHealth.totals.withoutHeartbeat,
newestHeartbeatAgeMs: convexHealth.heartbeatAgeMs.newest,
oldestHeartbeatAgeMs: convexHealth.heartbeatAgeMs.oldest,
thresholds: convexHealth.thresholds,
truncated: convexHealth.totals.truncated,
}
} catch (error) {
console.error("[health] Falha ao carregar estado das maquinas", error)
}
return {
generatedAt: toIsoString(now),
tickets: {
total: totalTickets,
open: openTickets,
last7d: lastWeekTickets,
last24h: lastDayTickets,
},
accounts: {
users: userCount,
companies: companyCount,
},
devices,
retention: RETENTION_POLICY,
retentionStrategy: RETENTION_STRATEGY,
notes: devices ? undefined : "Convex nao respondeu; verificar conectividade ou token interno.",
}
}