diff --git a/convex/metrics.ts b/convex/metrics.ts index 27f90f8..91d03a9 100644 --- a/convex/metrics.ts +++ b/convex/metrics.ts @@ -1,7 +1,8 @@ import { v } from "convex/values" -import type { Id } from "./_generated/dataModel" +import type { Doc, Id } from "./_generated/dataModel" import { query } from "./_generated/server" +import type { QueryCtx } from "./_generated/server" import { OPEN_STATUSES, ONE_DAY_MS, @@ -26,7 +27,7 @@ type MetricRunPayload = { data: unknown } -type MetricResolver = (ctx: Parameters[0], input: MetricResolverInput) => Promise +type MetricResolver = (ctx: QueryCtx, input: MetricResolverInput) => Promise function parseRange(params?: Record): number { const value = params?.range @@ -380,10 +381,12 @@ const metricResolvers: Record = { 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 cpu = clampPercent(pickMachineMetric(machine, ["cpuUsagePercent", "cpu_usage_percent"])) + const memory = clampPercent(pickMachineMetric(machine, ["memoryUsedPercent", "memory_usage_percent"])) + const disk = clampPercent(pickMachineMetric(machine, ["diskUsedPercent", "diskUsagePercent", "storageUsedPercent"])) + const alerts = readMachineAlertsCount(machine) + const fallbackHostname = readString((machine as unknown as Record)["computerName"]) + const hostname = machine.hostname ?? fallbackHostname ?? "Dispositivo sem nome" const attention = (cpu ?? 0) > 85 || (memory ?? 0) > 90 || @@ -392,7 +395,7 @@ const metricResolvers: Record = { alerts > 0 return { id: machine._id, - hostname: machine.hostname ?? machine.computerName ?? "Dispositivo sem nome", + hostname, status, cpuUsagePercent: cpu, memoryUsedPercent: memory, @@ -467,3 +470,72 @@ function clampPercent(value: unknown) { if (value > 100) return 100 return Math.round(value * 10) / 10 } + +function readNumeric(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string") { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + return null +} + +function pickMachineMetric(machine: Doc<"machines">, keys: string[]): number | null { + const record = machine as unknown as Record + for (const key of keys) { + const direct = readNumeric(record[key]) + if (direct !== null) { + return direct + } + } + const metadata = record["metadata"] + if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { + const metrics = (metadata as Record)["metrics"] + if (metrics && typeof metrics === "object" && !Array.isArray(metrics)) { + const metricsRecord = metrics as Record + for (const key of keys) { + const value = readNumeric(metricsRecord[key]) + if (value !== null) { + return value + } + } + } + } + return null +} + +function readMachineAlertsCount(machine: Doc<"machines">): number { + const record = machine as unknown as Record + const directCount = readNumeric(record["postureAlertsCount"]) + if (directCount !== null) { + return directCount + } + const directArray = record["postureAlerts"] + if (Array.isArray(directArray)) { + return directArray.length + } + const metadata = record["metadata"] + if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { + const metadataRecord = metadata as Record + const metaCount = readNumeric(metadataRecord["postureAlertsCount"]) + if (metaCount !== null) { + return metaCount + } + const metaAlerts = metadataRecord["postureAlerts"] + if (Array.isArray(metaAlerts)) { + return metaAlerts.length + } + } + return 0 +} + +function readString(value: unknown): string | null { + if (typeof value === "string" && value.trim().length > 0) { + return value + } + return null +} diff --git a/convex/tickets.ts b/convex/tickets.ts index e494c65..1f00f49 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -1898,12 +1898,15 @@ export const addComment = mutation({ try { const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) { - await ctx.scheduler.runAfter(0, api.ticketNotifications.sendPublicCommentEmail, { - to: snapshotEmail, - ticketId: String(ticketDoc._id), - reference: ticketDoc.reference ?? 0, - subject: ticketDoc.subject ?? "", - }) + const schedulerRunAfter = ctx.scheduler?.runAfter + if (typeof schedulerRunAfter === "function") { + await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, { + to: snapshotEmail, + ticketId: String(ticketDoc._id), + reference: ticketDoc.reference ?? 0, + subject: ticketDoc.subject ?? "", + }) + } } } catch (e) { console.warn("[tickets] Falha ao agendar e-mail de comentário", e) @@ -2148,12 +2151,15 @@ export async function resolveTicketHandler( const requesterDoc = await ctx.db.get(ticketDoc.requesterId) const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null if (email) { - await ctx.scheduler.runAfter(0, api.ticketNotifications.sendResolvedEmail, { - to: email, - ticketId: String(ticketId), - reference: ticketDoc.reference ?? 0, - subject: ticketDoc.subject ?? "", - }) + const schedulerRunAfter = ctx.scheduler?.runAfter + if (typeof schedulerRunAfter === "function") { + await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, { + to: email, + ticketId: String(ticketId), + reference: ticketDoc.reference ?? 0, + subject: ticketDoc.subject ?? "", + }) + } } } catch (e) { console.warn("[tickets] Falha ao agendar e-mail de encerramento", e) diff --git a/src/app/api/export/pdf/route.ts b/src/app/api/export/pdf/route.ts index e37cc8e..33e2ddc 100644 --- a/src/app/api/export/pdf/route.ts +++ b/src/app/api/export/pdf/route.ts @@ -49,7 +49,8 @@ export async function POST(request: Request) { printBackground: true, pageRanges: "1", }) - return new NextResponse(pdf, { + const pdfBytes = new Uint8Array(pdf) + return new NextResponse(pdfBytes, { status: 200, headers: { "Content-Type": "application/pdf", @@ -59,7 +60,8 @@ export async function POST(request: Request) { } const screenshot = await page.screenshot({ type: "png", fullPage: true }) - return new NextResponse(screenshot, { + const screenshotBytes = new Uint8Array(screenshot) + return new NextResponse(screenshotBytes, { status: 200, headers: { "Content-Type": "image/png", diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 9d3f27f..4412c41 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -11,8 +11,8 @@ import { TrendingUp, PanelsTopLeft, UserCog, + Building, Building2, - Skyscraper, Waypoints, Clock4, Timer, @@ -108,7 +108,7 @@ const navigation: NavigationGroup[] = [ { title: "Empresas", url: "/admin/companies", - icon: Skyscraper, + icon: Building, requiredRole: "admin", }, { title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" }, diff --git a/src/components/dashboards/dashboard-builder.tsx b/src/components/dashboards/dashboard-builder.tsx index 9bb1840..063067b 100644 --- a/src/components/dashboards/dashboard-builder.tsx +++ b/src/components/dashboards/dashboard-builder.tsx @@ -150,6 +150,14 @@ type PackedLayoutItem = LayoutStateItem & { y: number } +type CanvasRenderableItem = { + key: string + layout: PackedLayoutItem + minW?: number + minH?: number + element: React.ReactNode +} + type DashboardDetailResult = { dashboard: DashboardRecord widgets: DashboardWidgetRecord[] @@ -542,8 +550,6 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } return { key: item.i, layout: item, - minW: item.minW, - minH: item.minH, element: ( handleWidgetReady(widget.widgetKey, ready)} /> ), - } + ...(item.minW !== undefined ? { minW: item.minW } : {}), + ...(item.minH !== undefined ? { minH: item.minH } : {}), + } satisfies CanvasRenderableItem }) - .filter((item): item is { key: string; layout: PackedLayoutItem; minW?: number; minH?: number; element: React.ReactNode } => Boolean(item)) + .filter(Boolean) as CanvasRenderableItem[] const allWidgetsReady = canvasItems.length > 0 && canvasItems.every((item) => readyWidgets.has(item.key)) @@ -1177,34 +1185,37 @@ function WidgetConfigDialog({ widget: DashboardWidgetRecord | null onSubmit: (values: WidgetConfigFormValues) => Promise }) { + const normalizedConfig = getWidgetConfigForWidget(widget) const form = useForm({ resolver: zodResolver(widgetConfigSchema), defaultValues: { - title: widget?.title ?? widget?.config?.title ?? "", - type: widget?.type ?? "kpi", - metricKey: widget?.config?.dataSource?.metricKey ?? "", - stacked: Boolean((widget?.config as { encoding?: { stacked?: boolean } })?.encoding?.stacked ?? false), - legend: Boolean((widget?.config as { options?: { legend?: boolean } })?.options?.legend ?? true), - rangeOverride: typeof (widget?.config as { dataSource?: { params?: Record } })?.dataSource?.params?.range === "string" - ? ((widget?.config as { dataSource?: { params?: Record } })?.dataSource?.params?.range as string) - : "", - showTooltip: Boolean((widget?.config as { options?: { tooltip?: boolean } })?.options?.tooltip ?? true), + title: normalizedConfig?.title ?? widget?.title ?? "", + type: normalizedConfig?.type ?? widget?.type ?? "kpi", + metricKey: normalizedConfig?.dataSource?.metricKey ?? "", + stacked: Boolean(normalizedConfig?.encoding && "stacked" in normalizedConfig.encoding ? normalizedConfig.encoding.stacked : false), + legend: Boolean(normalizedConfig?.options && "legend" in normalizedConfig.options ? normalizedConfig.options.legend : true), + rangeOverride: + typeof normalizedConfig?.dataSource?.params?.range === "string" + ? (normalizedConfig.dataSource?.params?.range as string) + : "", + showTooltip: Boolean(normalizedConfig?.options && "tooltip" in normalizedConfig.options ? normalizedConfig.options.tooltip : true), }, }) useEffect(() => { if (!widget) return + const config = getWidgetConfigForWidget(widget) form.reset({ - title: widget.title ?? (widget.config as { title?: string })?.title ?? "", - type: widget.type, - metricKey: (widget.config as { dataSource?: { metricKey?: string } })?.dataSource?.metricKey ?? "", - stacked: Boolean((widget.config as { encoding?: { stacked?: boolean } })?.encoding?.stacked ?? false), - legend: Boolean((widget.config as { options?: { legend?: boolean } })?.options?.legend ?? true), + title: config?.title ?? widget.title ?? "", + type: config?.type ?? widget.type, + metricKey: config?.dataSource?.metricKey ?? "", + stacked: Boolean(config?.encoding && "stacked" in config.encoding ? config.encoding.stacked : false), + legend: Boolean(config?.options && "legend" in config.options ? config.options.legend : true), rangeOverride: - typeof (widget.config as { dataSource?: { params?: Record } })?.dataSource?.params?.range === "string" - ? ((widget.config as { dataSource?: { params?: Record } })?.dataSource?.params?.range as string) + typeof config?.dataSource?.params?.range === "string" + ? (config.dataSource?.params?.range as string) : "", - showTooltip: Boolean((widget.config as { options?: { tooltip?: boolean } })?.options?.tooltip ?? true), + showTooltip: Boolean(config?.options && "tooltip" in config.options ? config.options.tooltip : true), }) }, [widget, form]) @@ -1249,19 +1260,19 @@ function WidgetConfigDialog({ form.setValue("legend", checked)} /> form.setValue("stacked", checked)} /> form.setValue("showTooltip", checked)} /> diff --git a/src/components/dashboards/widget-renderer.tsx b/src/components/dashboards/widget-renderer.tsx index 70b831a..763f059 100644 --- a/src/components/dashboards/widget-renderer.tsx +++ b/src/components/dashboards/widget-renderer.tsx @@ -236,7 +236,7 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange } const widgetType = (config.type ?? widget.type ?? "text").toLowerCase() const title = config.title ?? widget.title ?? "Widget" const description = config.description - const mergedParams = useMemo(() => mergeFilterParams(config.dataSource?.params, filters), [config.dataSource?.params, filters]) + const mergedParams = mergeFilterParams(config.dataSource?.params, filters) const metric = useMetricData({ metricKey: config.dataSource?.metricKey, params: mergedParams, @@ -708,13 +708,7 @@ function renderGauge({ outerRadius={110} data={[{ name: "SLA", value: display }]} > - + percentFormatter.format(Number(val ?? 0))} />} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index b25a7b9..8b98439 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,4 +1,5 @@ import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" import { PrismaClient } from "@prisma/client" @@ -7,6 +8,12 @@ declare global { } // Resolve a robust DATABASE_URL for all runtimes (prod/dev) +const PROJECT_ROOT = process.cwd() +const PROJECT_ROOT_URL = (() => { + const baseHref = pathToFileURL(PROJECT_ROOT).href + return new URL(baseHref.endsWith("/") ? baseHref : `${baseHref}/`) +})() + function resolveFileUrl(url: string) { if (!url.startsWith("file:")) { return url @@ -14,13 +21,16 @@ function resolveFileUrl(url: string) { const filePath = url.slice("file:".length) if (filePath.startsWith("./") || filePath.startsWith("../")) { - const schemaDir = path.resolve(process.cwd(), "prisma") - const absolutePath = path.resolve(schemaDir, filePath) + const normalized = path.normalize(filePath) + const targetUrl = new URL(normalized, PROJECT_ROOT_URL) + const absolutePath = fileURLToPath(targetUrl) + if (!absolutePath.startsWith(PROJECT_ROOT)) { + throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`) + } return `file:${absolutePath}` } if (!filePath.startsWith("/")) { - const absolutePath = path.resolve(process.cwd(), filePath) - return `file:${absolutePath}` + return resolveFileUrl(`file:./${filePath}`) } return url }