feat: adicionar construtor de dashboards e api de métricas
This commit is contained in:
parent
c2acd65764
commit
741f1d7f9c
14 changed files with 4356 additions and 9 deletions
469
convex/metrics.ts
Normal file
469
convex/metrics.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
import { v } from "convex/values"
|
||||
|
||||
import type { Id } from "./_generated/dataModel"
|
||||
import { query } from "./_generated/server"
|
||||
import {
|
||||
OPEN_STATUSES,
|
||||
ONE_DAY_MS,
|
||||
fetchScopedTickets,
|
||||
fetchScopedTicketsByCreatedRange,
|
||||
fetchScopedTicketsByResolvedRange,
|
||||
normalizeStatus,
|
||||
} from "./reports"
|
||||
import { requireStaff } from "./rbac"
|
||||
|
||||
type Viewer = Awaited<ReturnType<typeof requireStaff>>
|
||||
|
||||
type MetricResolverInput = {
|
||||
tenantId: string
|
||||
viewer: Viewer
|
||||
viewerId: Id<"users">
|
||||
params?: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
type MetricRunPayload = {
|
||||
meta: { kind: string; key: string } & Record<string, unknown>
|
||||
data: unknown
|
||||
}
|
||||
|
||||
type MetricResolver = (ctx: Parameters<typeof query>[0], input: MetricResolverInput) => Promise<MetricRunPayload>
|
||||
|
||||
function parseRange(params?: Record<string, unknown>): number {
|
||||
const value = params?.range
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.toLowerCase()
|
||||
if (normalized === "7d") return 7
|
||||
if (normalized === "90d") return 90
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.min(365, Math.max(1, Math.round(value)))
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
function parseLimit(params?: Record<string, unknown>, fallback = 20) {
|
||||
const value = params?.limit
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.min(200, Math.round(value))
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function parseCompanyId(params?: Record<string, unknown>): Id<"companies"> | undefined {
|
||||
const value = params?.companyId
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value as Id<"companies">
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseQueueIds(params?: Record<string, unknown>): string[] | undefined {
|
||||
const value = params?.queueIds ?? params?.queueId
|
||||
if (Array.isArray(value)) {
|
||||
const clean = value
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : null))
|
||||
.filter((entry): entry is string => Boolean(entry && entry.length > 0))
|
||||
return clean.length > 0 ? clean : undefined
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? [trimmed] : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function filterTicketsByQueue<T extends { queueId?: Id<"queues"> | null }>(
|
||||
tickets: T[],
|
||||
queueIds?: string[],
|
||||
): T[] {
|
||||
if (!queueIds || queueIds.length === 0) {
|
||||
return tickets
|
||||
}
|
||||
const normalized = new Set(queueIds.map((id) => id.trim()))
|
||||
const includesNull = normalized.has("sem-fila") || normalized.has("null")
|
||||
return tickets.filter((ticket) => {
|
||||
if (!ticket.queueId) {
|
||||
return includesNull
|
||||
}
|
||||
return normalized.has(String(ticket.queueId))
|
||||
})
|
||||
}
|
||||
|
||||
const metricResolvers: Record<string, MetricResolver> = {
|
||||
"tickets.opened_resolved_by_day": async (ctx, { tenantId, viewer, params }) => {
|
||||
const rangeDays = parseRange(params)
|
||||
const companyId = parseCompanyId(params)
|
||||
const queueIds = parseQueueIds(params)
|
||||
const end = new Date()
|
||||
end.setUTCHours(0, 0, 0, 0)
|
||||
const endMs = end.getTime() + ONE_DAY_MS
|
||||
const startMs = endMs - rangeDays * ONE_DAY_MS
|
||||
|
||||
const openedTickets = filterTicketsByQueue(
|
||||
await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId),
|
||||
queueIds,
|
||||
)
|
||||
const resolvedTickets = filterTicketsByQueue(
|
||||
await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId),
|
||||
queueIds,
|
||||
)
|
||||
|
||||
const opened: Record<string, number> = {}
|
||||
const resolved: Record<string, number> = {}
|
||||
|
||||
for (let offset = rangeDays - 1; offset >= 0; offset -= 1) {
|
||||
const d = new Date(endMs - (offset + 1) * ONE_DAY_MS)
|
||||
const key = formatDateKey(d.getTime())
|
||||
opened[key] = 0
|
||||
resolved[key] = 0
|
||||
}
|
||||
|
||||
for (const ticket of openedTickets) {
|
||||
if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
|
||||
const key = formatDateKey(ticket.createdAt)
|
||||
opened[key] = (opened[key] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const ticket of resolvedTickets) {
|
||||
if (typeof ticket.resolvedAt !== "number") continue
|
||||
if (ticket.resolvedAt < startMs || ticket.resolvedAt >= endMs) continue
|
||||
const key = formatDateKey(ticket.resolvedAt)
|
||||
resolved[key] = (resolved[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
const series = []
|
||||
for (let offset = rangeDays - 1; offset >= 0; offset -= 1) {
|
||||
const d = new Date(endMs - (offset + 1) * ONE_DAY_MS)
|
||||
const key = formatDateKey(d.getTime())
|
||||
series.push({ date: key, opened: opened[key] ?? 0, resolved: resolved[key] ?? 0 })
|
||||
}
|
||||
|
||||
return {
|
||||
meta: { kind: "series", key: "tickets.opened_resolved_by_day", rangeDays },
|
||||
data: series,
|
||||
}
|
||||
},
|
||||
"tickets.waiting_action_now": async (ctx, { tenantId, viewer, params }) => {
|
||||
const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params))
|
||||
const now = Date.now()
|
||||
let total = 0
|
||||
let atRisk = 0
|
||||
|
||||
for (const ticket of tickets) {
|
||||
const status = normalizeStatus(ticket.status)
|
||||
if (!OPEN_STATUSES.has(status)) continue
|
||||
total += 1
|
||||
if (ticket.dueAt && ticket.dueAt < now) {
|
||||
atRisk += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
meta: { kind: "single", key: "tickets.waiting_action_now", unit: "tickets" },
|
||||
data: { value: total, atRisk },
|
||||
}
|
||||
},
|
||||
"tickets.waiting_action_last_7d": async (ctx, { tenantId, viewer, params }) => {
|
||||
const rangeDays = 7
|
||||
const end = new Date()
|
||||
end.setUTCHours(0, 0, 0, 0)
|
||||
const endMs = end.getTime() + ONE_DAY_MS
|
||||
const startMs = endMs - rangeDays * ONE_DAY_MS
|
||||
const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params))
|
||||
|
||||
const daily: Record<string, { total: number; atRisk: number }> = {}
|
||||
for (let offset = rangeDays - 1; offset >= 0; offset -= 1) {
|
||||
const d = new Date(endMs - (offset + 1) * ONE_DAY_MS)
|
||||
const key = formatDateKey(d.getTime())
|
||||
daily[key] = { total: 0, atRisk: 0 }
|
||||
}
|
||||
|
||||
for (const ticket of tickets) {
|
||||
if (ticket.createdAt < startMs) continue
|
||||
const key = formatDateKey(ticket.createdAt)
|
||||
const bucket = daily[key]
|
||||
if (!bucket) continue
|
||||
if (OPEN_STATUSES.has(normalizeStatus(ticket.status))) {
|
||||
bucket.total += 1
|
||||
if (ticket.dueAt && ticket.dueAt < Date.now()) {
|
||||
bucket.atRisk += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const values = Object.values(daily)
|
||||
const total = values.reduce((sum, item) => sum + item.total, 0)
|
||||
const atRisk = values.reduce((sum, item) => sum + item.atRisk, 0)
|
||||
|
||||
return {
|
||||
meta: { kind: "single", key: "tickets.waiting_action_last_7d", aggregation: "sum", rangeDays },
|
||||
data: { value: total, atRisk },
|
||||
}
|
||||
},
|
||||
"tickets.open_by_priority": async (ctx, { tenantId, viewer, params }) => {
|
||||
const rangeDays = parseRange(params)
|
||||
const companyId = parseCompanyId(params)
|
||||
const end = new Date()
|
||||
end.setUTCHours(0, 0, 0, 0)
|
||||
const endMs = end.getTime() + ONE_DAY_MS
|
||||
const startMs = endMs - rangeDays * ONE_DAY_MS
|
||||
const tickets = filterTicketsByQueue(
|
||||
await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId),
|
||||
parseQueueIds(params),
|
||||
)
|
||||
|
||||
const counts: Record<string, number> = {}
|
||||
for (const ticket of tickets) {
|
||||
if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue
|
||||
const key = (ticket.priority ?? "MEDIUM").toUpperCase()
|
||||
counts[key] = (counts[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
const data = Object.entries(counts).map(([priority, total]) => ({ priority, total }))
|
||||
data.sort((a, b) => b.total - a.total)
|
||||
|
||||
return {
|
||||
meta: { kind: "collection", key: "tickets.open_by_priority", rangeDays },
|
||||
data,
|
||||
}
|
||||
},
|
||||
"tickets.open_by_queue": async (ctx, { tenantId, viewer, params }) => {
|
||||
const rangeDays = parseRange(params)
|
||||
const companyId = parseCompanyId(params)
|
||||
const end = new Date()
|
||||
end.setUTCHours(0, 0, 0, 0)
|
||||
const endMs = end.getTime() + ONE_DAY_MS
|
||||
const startMs = endMs - rangeDays * ONE_DAY_MS
|
||||
const queueFilter = parseQueueIds(params)
|
||||
const tickets = filterTicketsByQueue(
|
||||
await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId),
|
||||
parseQueueIds(params),
|
||||
)
|
||||
|
||||
const queueCounts = new Map<string, number>()
|
||||
for (const ticket of tickets) {
|
||||
if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue
|
||||
const queueKey = ticket.queueId ? String(ticket.queueId) : "sem-fila"
|
||||
if (queueFilter && queueFilter.length > 0 && !queueFilter.includes(queueKey)) {
|
||||
continue
|
||||
}
|
||||
queueCounts.set(queueKey, (queueCounts.get(queueKey) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect()
|
||||
const data = Array.from(queueCounts.entries()).map(([queueId, total]) => {
|
||||
const queue = queues.find((q) => String(q._id) === queueId)
|
||||
return {
|
||||
queueId,
|
||||
name: queue ? queue.name : queueId === "sem-fila" ? "Sem fila" : "Fila desconhecida",
|
||||
total,
|
||||
}
|
||||
})
|
||||
|
||||
data.sort((a, b) => b.total - a.total)
|
||||
|
||||
return {
|
||||
meta: { kind: "collection", key: "tickets.open_by_queue", rangeDays },
|
||||
data,
|
||||
}
|
||||
},
|
||||
"tickets.sla_compliance_by_queue": async (ctx, { tenantId, viewer, params }) => {
|
||||
const rangeDays = parseRange(params)
|
||||
const companyId = parseCompanyId(params)
|
||||
const end = new Date()
|
||||
end.setUTCHours(0, 0, 0, 0)
|
||||
const endMs = end.getTime() + ONE_DAY_MS
|
||||
const startMs = endMs - rangeDays * ONE_DAY_MS
|
||||
const queueFilter = parseQueueIds(params)
|
||||
const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
|
||||
const now = Date.now()
|
||||
const stats = new Map<string, { total: number; compliant: number }>()
|
||||
|
||||
for (const ticket of tickets) {
|
||||
const queueKey = ticket.queueId ? String(ticket.queueId) : "sem-fila"
|
||||
if (queueFilter && queueFilter.length > 0 && !queueFilter.includes(queueKey)) {
|
||||
continue
|
||||
}
|
||||
const current = stats.get(queueKey) ?? { total: 0, compliant: 0 }
|
||||
current.total += 1
|
||||
const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null
|
||||
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null
|
||||
let compliant = false
|
||||
if (dueAt) {
|
||||
if (resolvedAt) {
|
||||
compliant = resolvedAt <= dueAt
|
||||
} else {
|
||||
compliant = dueAt >= now
|
||||
}
|
||||
} else {
|
||||
compliant = resolvedAt !== null
|
||||
}
|
||||
if (compliant) {
|
||||
current.compliant += 1
|
||||
}
|
||||
stats.set(queueKey, current)
|
||||
}
|
||||
|
||||
const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect()
|
||||
const data = Array.from(stats.entries()).map(([queueId, value]) => {
|
||||
const queue = queues.find((q) => String(q._id) === queueId)
|
||||
const compliance = value.total > 0 ? value.compliant / value.total : 0
|
||||
return {
|
||||
queueId,
|
||||
name: queue ? queue.name : queueId === "sem-fila" ? "Sem fila" : "Fila desconhecida",
|
||||
total: value.total,
|
||||
compliance,
|
||||
}
|
||||
})
|
||||
|
||||
data.sort((a, b) => (b.compliance ?? 0) - (a.compliance ?? 0))
|
||||
|
||||
return {
|
||||
meta: { kind: "collection", key: "tickets.sla_compliance_by_queue", rangeDays },
|
||||
data,
|
||||
}
|
||||
},
|
||||
"tickets.sla_rate": async (ctx, { tenantId, viewer, params }) => {
|
||||
const rangeDays = parseRange(params)
|
||||
const companyId = parseCompanyId(params)
|
||||
const end = new Date()
|
||||
end.setUTCHours(0, 0, 0, 0)
|
||||
const endMs = end.getTime() + ONE_DAY_MS
|
||||
const startMs = endMs - rangeDays * ONE_DAY_MS
|
||||
const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
|
||||
|
||||
const total = tickets.length
|
||||
const resolved = tickets.filter((t) => normalizeStatus(t.status) === "RESOLVED").length
|
||||
const rate = total > 0 ? resolved / total : 0
|
||||
|
||||
return {
|
||||
meta: { kind: "single", key: "tickets.sla_rate", rangeDays, unit: "ratio" },
|
||||
data: { value: rate, total, resolved },
|
||||
}
|
||||
},
|
||||
"tickets.awaiting_table": async (ctx, { tenantId, viewer, params }) => {
|
||||
const limit = parseLimit(params, 20)
|
||||
const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params))
|
||||
const awaiting = tickets
|
||||
.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)))
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
||||
.slice(0, limit)
|
||||
.map((ticket) => ({
|
||||
id: ticket._id,
|
||||
reference: ticket.reference ?? null,
|
||||
subject: ticket.subject,
|
||||
status: normalizeStatus(ticket.status),
|
||||
priority: ticket.priority,
|
||||
updatedAt: ticket.updatedAt,
|
||||
createdAt: ticket.createdAt,
|
||||
assignee: ticket.assigneeSnapshot
|
||||
? {
|
||||
name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null,
|
||||
email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null,
|
||||
}
|
||||
: null,
|
||||
queueId: ticket.queueId ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
meta: { kind: "table", key: "tickets.awaiting_table", limit },
|
||||
data: awaiting,
|
||||
}
|
||||
},
|
||||
"devices.health_summary": async (ctx, { tenantId, params }) => {
|
||||
const limit = parseLimit(params, 10)
|
||||
const machines = await ctx.db.query("machines").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect()
|
||||
const now = Date.now()
|
||||
const summary = machines
|
||||
.map((machine) => {
|
||||
const lastHeartbeatAt = machine.lastHeartbeatAt ?? null
|
||||
const minutesSinceHeartbeat = lastHeartbeatAt ? Math.round((now - lastHeartbeatAt) / 60000) : null
|
||||
const status = deriveMachineStatus(machine, now)
|
||||
const cpu = clampPercent(machine.cpuUsagePercent)
|
||||
const memory = clampPercent(machine.memoryUsedPercent)
|
||||
const disk = clampPercent(machine.diskUsedPercent ?? machine.diskUsagePercent)
|
||||
const alerts = Array.isArray(machine.postureAlerts) ? machine.postureAlerts.length : machine.postureAlertsCount ?? 0
|
||||
const attention =
|
||||
(cpu ?? 0) > 85 ||
|
||||
(memory ?? 0) > 90 ||
|
||||
(disk ?? 0) > 90 ||
|
||||
(minutesSinceHeartbeat ?? Infinity) > 120 ||
|
||||
alerts > 0
|
||||
return {
|
||||
id: machine._id,
|
||||
hostname: machine.hostname ?? machine.computerName ?? "Dispositivo sem nome",
|
||||
status,
|
||||
cpuUsagePercent: cpu,
|
||||
memoryUsedPercent: memory,
|
||||
diskUsedPercent: disk,
|
||||
lastHeartbeatAt,
|
||||
minutesSinceHeartbeat,
|
||||
alerts,
|
||||
attention,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.attention === b.attention) {
|
||||
return (b.cpuUsagePercent ?? 0) - (a.cpuUsagePercent ?? 0)
|
||||
}
|
||||
return a.attention ? -1 : 1
|
||||
})
|
||||
.slice(0, limit)
|
||||
|
||||
return {
|
||||
meta: { kind: "collection", key: "devices.health_summary", limit },
|
||||
data: summary,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const run = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
metricKey: v.string(),
|
||||
params: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, metricKey, params }) => {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||
const resolver = metricResolvers[metricKey]
|
||||
if (!resolver) {
|
||||
return {
|
||||
meta: { kind: "error", key: metricKey, message: "Métrica não suportada" },
|
||||
data: null,
|
||||
}
|
||||
}
|
||||
const payload = await resolver(ctx, {
|
||||
tenantId,
|
||||
viewer,
|
||||
viewerId,
|
||||
params: params && typeof params === "object" ? (params as Record<string, unknown>) : undefined,
|
||||
})
|
||||
return payload
|
||||
},
|
||||
})
|
||||
|
||||
function formatDateKey(timestamp: number) {
|
||||
const d = new Date(timestamp)
|
||||
const year = d.getUTCFullYear()
|
||||
const month = `${d.getUTCMonth() + 1}`.padStart(2, "0")
|
||||
const day = `${d.getUTCDate()}`.padStart(2, "0")
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function deriveMachineStatus(machine: Record<string, unknown>, now: number) {
|
||||
const lastHeartbeatAt = typeof machine.lastHeartbeatAt === "number" ? machine.lastHeartbeatAt : null
|
||||
if (!lastHeartbeatAt) return "unknown"
|
||||
const diffMinutes = (now - lastHeartbeatAt) / 60000
|
||||
if (diffMinutes <= 10) return "online"
|
||||
if (diffMinutes <= 120) return "stale"
|
||||
return "offline"
|
||||
}
|
||||
|
||||
function clampPercent(value: unknown) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null
|
||||
if (value < 0) return 0
|
||||
if (value > 100) return 100
|
||||
return Math.round(value * 10) / 10
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue