fix: resolver avisos de build e tipagem

This commit is contained in:
Esdras Renan 2025-11-04 21:02:53 -03:00
parent 741f1d7f9c
commit fa9efdb5af
7 changed files with 154 additions and 59 deletions

View file

@ -1,7 +1,8 @@
import { v } from "convex/values" import { v } from "convex/values"
import type { Id } from "./_generated/dataModel" import type { Doc, Id } from "./_generated/dataModel"
import { query } from "./_generated/server" import { query } from "./_generated/server"
import type { QueryCtx } from "./_generated/server"
import { import {
OPEN_STATUSES, OPEN_STATUSES,
ONE_DAY_MS, ONE_DAY_MS,
@ -26,7 +27,7 @@ type MetricRunPayload = {
data: unknown data: unknown
} }
type MetricResolver = (ctx: Parameters<typeof query>[0], input: MetricResolverInput) => Promise<MetricRunPayload> type MetricResolver = (ctx: QueryCtx, input: MetricResolverInput) => Promise<MetricRunPayload>
function parseRange(params?: Record<string, unknown>): number { function parseRange(params?: Record<string, unknown>): number {
const value = params?.range const value = params?.range
@ -380,10 +381,12 @@ const metricResolvers: Record<string, MetricResolver> = {
const lastHeartbeatAt = machine.lastHeartbeatAt ?? null const lastHeartbeatAt = machine.lastHeartbeatAt ?? null
const minutesSinceHeartbeat = lastHeartbeatAt ? Math.round((now - lastHeartbeatAt) / 60000) : null const minutesSinceHeartbeat = lastHeartbeatAt ? Math.round((now - lastHeartbeatAt) / 60000) : null
const status = deriveMachineStatus(machine, now) const status = deriveMachineStatus(machine, now)
const cpu = clampPercent(machine.cpuUsagePercent) const cpu = clampPercent(pickMachineMetric(machine, ["cpuUsagePercent", "cpu_usage_percent"]))
const memory = clampPercent(machine.memoryUsedPercent) const memory = clampPercent(pickMachineMetric(machine, ["memoryUsedPercent", "memory_usage_percent"]))
const disk = clampPercent(machine.diskUsedPercent ?? machine.diskUsagePercent) const disk = clampPercent(pickMachineMetric(machine, ["diskUsedPercent", "diskUsagePercent", "storageUsedPercent"]))
const alerts = Array.isArray(machine.postureAlerts) ? machine.postureAlerts.length : machine.postureAlertsCount ?? 0 const alerts = readMachineAlertsCount(machine)
const fallbackHostname = readString((machine as unknown as Record<string, unknown>)["computerName"])
const hostname = machine.hostname ?? fallbackHostname ?? "Dispositivo sem nome"
const attention = const attention =
(cpu ?? 0) > 85 || (cpu ?? 0) > 85 ||
(memory ?? 0) > 90 || (memory ?? 0) > 90 ||
@ -392,7 +395,7 @@ const metricResolvers: Record<string, MetricResolver> = {
alerts > 0 alerts > 0
return { return {
id: machine._id, id: machine._id,
hostname: machine.hostname ?? machine.computerName ?? "Dispositivo sem nome", hostname,
status, status,
cpuUsagePercent: cpu, cpuUsagePercent: cpu,
memoryUsedPercent: memory, memoryUsedPercent: memory,
@ -467,3 +470,72 @@ function clampPercent(value: unknown) {
if (value > 100) return 100 if (value > 100) return 100
return Math.round(value * 10) / 10 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<string, unknown>
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<string, unknown>)["metrics"]
if (metrics && typeof metrics === "object" && !Array.isArray(metrics)) {
const metricsRecord = metrics as Record<string, unknown>
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<string, unknown>
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<string, unknown>
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
}

View file

@ -1898,13 +1898,16 @@ export const addComment = mutation({
try { try {
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) { if (requestedVisibility === "PUBLIC" && snapshotEmail && String(ticketDoc.requesterId) !== String(args.authorId)) {
await ctx.scheduler.runAfter(0, api.ticketNotifications.sendPublicCommentEmail, { const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendPublicCommentEmail, {
to: snapshotEmail, to: snapshotEmail,
ticketId: String(ticketDoc._id), ticketId: String(ticketDoc._id),
reference: ticketDoc.reference ?? 0, reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "", subject: ticketDoc.subject ?? "",
}) })
} }
}
} catch (e) { } catch (e) {
console.warn("[tickets] Falha ao agendar e-mail de comentário", e) console.warn("[tickets] Falha ao agendar e-mail de comentário", e)
} }
@ -2148,13 +2151,16 @@ export async function resolveTicketHandler(
const requesterDoc = await ctx.db.get(ticketDoc.requesterId) const requesterDoc = await ctx.db.get(ticketDoc.requesterId)
const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null const email = (requesterDoc as Doc<"users"> | null)?.email || (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email || null
if (email) { if (email) {
await ctx.scheduler.runAfter(0, api.ticketNotifications.sendResolvedEmail, { const schedulerRunAfter = ctx.scheduler?.runAfter
if (typeof schedulerRunAfter === "function") {
await schedulerRunAfter(0, api.ticketNotifications.sendResolvedEmail, {
to: email, to: email,
ticketId: String(ticketId), ticketId: String(ticketId),
reference: ticketDoc.reference ?? 0, reference: ticketDoc.reference ?? 0,
subject: ticketDoc.subject ?? "", subject: ticketDoc.subject ?? "",
}) })
} }
}
} catch (e) { } catch (e) {
console.warn("[tickets] Falha ao agendar e-mail de encerramento", e) console.warn("[tickets] Falha ao agendar e-mail de encerramento", e)
} }

View file

@ -49,7 +49,8 @@ export async function POST(request: Request) {
printBackground: true, printBackground: true,
pageRanges: "1", pageRanges: "1",
}) })
return new NextResponse(pdf, { const pdfBytes = new Uint8Array(pdf)
return new NextResponse(pdfBytes, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "application/pdf", "Content-Type": "application/pdf",
@ -59,7 +60,8 @@ export async function POST(request: Request) {
} }
const screenshot = await page.screenshot({ type: "png", fullPage: true }) const screenshot = await page.screenshot({ type: "png", fullPage: true })
return new NextResponse(screenshot, { const screenshotBytes = new Uint8Array(screenshot)
return new NextResponse(screenshotBytes, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": "image/png", "Content-Type": "image/png",

View file

@ -11,8 +11,8 @@ import {
TrendingUp, TrendingUp,
PanelsTopLeft, PanelsTopLeft,
UserCog, UserCog,
Building,
Building2, Building2,
Skyscraper,
Waypoints, Waypoints,
Clock4, Clock4,
Timer, Timer,
@ -108,7 +108,7 @@ const navigation: NavigationGroup[] = [
{ {
title: "Empresas", title: "Empresas",
url: "/admin/companies", url: "/admin/companies",
icon: Skyscraper, icon: Building,
requiredRole: "admin", requiredRole: "admin",
}, },
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" }, { title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },

View file

@ -150,6 +150,14 @@ type PackedLayoutItem = LayoutStateItem & {
y: number y: number
} }
type CanvasRenderableItem = {
key: string
layout: PackedLayoutItem
minW?: number
minH?: number
element: React.ReactNode
}
type DashboardDetailResult = { type DashboardDetailResult = {
dashboard: DashboardRecord dashboard: DashboardRecord
widgets: DashboardWidgetRecord[] widgets: DashboardWidgetRecord[]
@ -542,8 +550,6 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
return { return {
key: item.i, key: item.i,
layout: item, layout: item,
minW: item.minW,
minH: item.minH,
element: ( element: (
<BuilderWidgetCard <BuilderWidgetCard
key={widget.widgetKey} key={widget.widgetKey}
@ -561,9 +567,11 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
onReadyChange={(ready) => handleWidgetReady(widget.widgetKey, ready)} onReadyChange={(ready) => 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)) const allWidgetsReady = canvasItems.length > 0 && canvasItems.every((item) => readyWidgets.has(item.key))
@ -1177,34 +1185,37 @@ function WidgetConfigDialog({
widget: DashboardWidgetRecord | null widget: DashboardWidgetRecord | null
onSubmit: (values: WidgetConfigFormValues) => Promise<void> onSubmit: (values: WidgetConfigFormValues) => Promise<void>
}) { }) {
const normalizedConfig = getWidgetConfigForWidget(widget)
const form = useForm<WidgetConfigFormValues>({ const form = useForm<WidgetConfigFormValues>({
resolver: zodResolver(widgetConfigSchema), resolver: zodResolver(widgetConfigSchema),
defaultValues: { defaultValues: {
title: widget?.title ?? widget?.config?.title ?? "", title: normalizedConfig?.title ?? widget?.title ?? "",
type: widget?.type ?? "kpi", type: normalizedConfig?.type ?? widget?.type ?? "kpi",
metricKey: widget?.config?.dataSource?.metricKey ?? "", metricKey: normalizedConfig?.dataSource?.metricKey ?? "",
stacked: Boolean((widget?.config as { encoding?: { stacked?: boolean } })?.encoding?.stacked ?? false), stacked: Boolean(normalizedConfig?.encoding && "stacked" in normalizedConfig.encoding ? normalizedConfig.encoding.stacked : false),
legend: Boolean((widget?.config as { options?: { legend?: boolean } })?.options?.legend ?? true), legend: Boolean(normalizedConfig?.options && "legend" in normalizedConfig.options ? normalizedConfig.options.legend : true),
rangeOverride: typeof (widget?.config as { dataSource?: { params?: Record<string, unknown> } })?.dataSource?.params?.range === "string" rangeOverride:
? ((widget?.config as { dataSource?: { params?: Record<string, unknown> } })?.dataSource?.params?.range as string) typeof normalizedConfig?.dataSource?.params?.range === "string"
? (normalizedConfig.dataSource?.params?.range as string)
: "", : "",
showTooltip: Boolean((widget?.config as { options?: { tooltip?: boolean } })?.options?.tooltip ?? true), showTooltip: Boolean(normalizedConfig?.options && "tooltip" in normalizedConfig.options ? normalizedConfig.options.tooltip : true),
}, },
}) })
useEffect(() => { useEffect(() => {
if (!widget) return if (!widget) return
const config = getWidgetConfigForWidget(widget)
form.reset({ form.reset({
title: widget.title ?? (widget.config as { title?: string })?.title ?? "", title: config?.title ?? widget.title ?? "",
type: widget.type, type: config?.type ?? widget.type,
metricKey: (widget.config as { dataSource?: { metricKey?: string } })?.dataSource?.metricKey ?? "", metricKey: config?.dataSource?.metricKey ?? "",
stacked: Boolean((widget.config as { encoding?: { stacked?: boolean } })?.encoding?.stacked ?? false), stacked: Boolean(config?.encoding && "stacked" in config.encoding ? config.encoding.stacked : false),
legend: Boolean((widget.config as { options?: { legend?: boolean } })?.options?.legend ?? true), legend: Boolean(config?.options && "legend" in config.options ? config.options.legend : true),
rangeOverride: rangeOverride:
typeof (widget.config as { dataSource?: { params?: Record<string, unknown> } })?.dataSource?.params?.range === "string" typeof config?.dataSource?.params?.range === "string"
? ((widget.config as { dataSource?: { params?: Record<string, unknown> } })?.dataSource?.params?.range as 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]) }, [widget, form])
@ -1249,19 +1260,19 @@ function WidgetConfigDialog({
<SwitchField <SwitchField
label="Legend" label="Legend"
description="Mostra a legenda na visualização." description="Mostra a legenda na visualização."
checked={form.watch("legend")} checked={Boolean(form.watch("legend"))}
onCheckedChange={(checked) => form.setValue("legend", checked)} onCheckedChange={(checked) => form.setValue("legend", checked)}
/> />
<SwitchField <SwitchField
label="Stacked" label="Stacked"
description="Acumula as séries no eixo Y." description="Acumula as séries no eixo Y."
checked={form.watch("stacked")} checked={Boolean(form.watch("stacked"))}
onCheckedChange={(checked) => form.setValue("stacked", checked)} onCheckedChange={(checked) => form.setValue("stacked", checked)}
/> />
<SwitchField <SwitchField
label="Tooltip" label="Tooltip"
description="Mantém o tooltip interativo." description="Mantém o tooltip interativo."
checked={form.watch("showTooltip")} checked={Boolean(form.watch("showTooltip"))}
onCheckedChange={(checked) => form.setValue("showTooltip", checked)} onCheckedChange={(checked) => form.setValue("showTooltip", checked)}
/> />
</div> </div>

View file

@ -236,7 +236,7 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }
const widgetType = (config.type ?? widget.type ?? "text").toLowerCase() const widgetType = (config.type ?? widget.type ?? "text").toLowerCase()
const title = config.title ?? widget.title ?? "Widget" const title = config.title ?? widget.title ?? "Widget"
const description = config.description 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({ const metric = useMetricData({
metricKey: config.dataSource?.metricKey, metricKey: config.dataSource?.metricKey,
params: mergedParams, params: mergedParams,
@ -708,13 +708,7 @@ function renderGauge({
outerRadius={110} outerRadius={110}
data={[{ name: "SLA", value: display }]} data={[{ name: "SLA", value: display }]}
> >
<RadialBar <RadialBar background dataKey="value" cornerRadius={5} fill="var(--color-value)" />
minAngle={15}
background
dataKey="value"
cornerRadius={5}
fill="var(--color-value)"
/>
<RechartsTooltip <RechartsTooltip
cursor={false} cursor={false}
content={<ChartTooltipContent hideLabel valueFormatter={(val) => percentFormatter.format(Number(val ?? 0))} />} content={<ChartTooltipContent hideLabel valueFormatter={(val) => percentFormatter.format(Number(val ?? 0))} />}

View file

@ -1,4 +1,5 @@
import path from "node:path" import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { PrismaClient } from "@prisma/client" import { PrismaClient } from "@prisma/client"
@ -7,6 +8,12 @@ declare global {
} }
// Resolve a robust DATABASE_URL for all runtimes (prod/dev) // 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) { function resolveFileUrl(url: string) {
if (!url.startsWith("file:")) { if (!url.startsWith("file:")) {
return url return url
@ -14,13 +21,16 @@ function resolveFileUrl(url: string) {
const filePath = url.slice("file:".length) const filePath = url.slice("file:".length)
if (filePath.startsWith("./") || filePath.startsWith("../")) { if (filePath.startsWith("./") || filePath.startsWith("../")) {
const schemaDir = path.resolve(process.cwd(), "prisma") const normalized = path.normalize(filePath)
const absolutePath = path.resolve(schemaDir, 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}` return `file:${absolutePath}`
} }
if (!filePath.startsWith("/")) { if (!filePath.startsWith("/")) {
const absolutePath = path.resolve(process.cwd(), filePath) return resolveFileUrl(`file:./${filePath}`)
return `file:${absolutePath}`
} }
return url return url
} }