489 lines
14 KiB
TypeScript
489 lines
14 KiB
TypeScript
import { type ActionFunctionArgs } from "@remix-run/node";
|
|
import { nanoid } from "nanoid";
|
|
import { z } from "zod";
|
|
import { typedjson } from "remix-typedjson";
|
|
import { prisma } from "~/db.server";
|
|
import { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
|
|
import { findProjectBySlug } from "~/models/project.server";
|
|
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
|
import { DashboardLayout, LayoutItem } from "~/presenters/v3/MetricDashboardPresenter.server";
|
|
import { getCurrentPlan } from "~/services/platform.v3.server";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
|
|
|
// Schemas for each action type
|
|
const AddWidgetSchema = z.object({
|
|
widgetId: z.string().min(1, "Widget ID is required").nullish(),
|
|
title: z.string().min(1, "Title is required"),
|
|
query: z.string().default(""),
|
|
config: z.string().transform((str, ctx) => {
|
|
try {
|
|
const parsed = JSON.parse(str);
|
|
const result = QueryWidgetConfig.safeParse(parsed);
|
|
if (!result.success) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Invalid widget config",
|
|
});
|
|
return z.NEVER;
|
|
}
|
|
return result.data;
|
|
} catch {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Invalid JSON",
|
|
});
|
|
return z.NEVER;
|
|
}
|
|
}),
|
|
});
|
|
|
|
const UpdateWidgetSchema = z.object({
|
|
widgetId: z.string().min(1, "Widget ID is required"),
|
|
title: z.string().min(1, "Title is required"),
|
|
query: z.string().min(1, "Query is required"),
|
|
config: z.string().transform((str, ctx) => {
|
|
try {
|
|
const parsed = JSON.parse(str);
|
|
const result = QueryWidgetConfig.safeParse(parsed);
|
|
if (!result.success) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Invalid widget config",
|
|
});
|
|
return z.NEVER;
|
|
}
|
|
return result.data;
|
|
} catch {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Invalid JSON",
|
|
});
|
|
return z.NEVER;
|
|
}
|
|
}),
|
|
});
|
|
|
|
const RenameWidgetSchema = z.object({
|
|
widgetId: z.string().min(1, "Widget ID is required"),
|
|
title: z.string().min(1, "Title is required"),
|
|
});
|
|
|
|
const DeleteWidgetSchema = z.object({
|
|
widgetId: z.string().min(1, "Widget ID is required"),
|
|
});
|
|
|
|
const DuplicateWidgetSchema = z.object({
|
|
widgetId: z.string().min(1, "Widget ID is required"),
|
|
newId: z.string().min(1, "New widget ID is required").nullish(),
|
|
});
|
|
|
|
const SaveLayoutSchema = z.object({
|
|
layout: z.string().transform((str, ctx) => {
|
|
try {
|
|
const parsed = JSON.parse(str);
|
|
const result = z.array(LayoutItem).safeParse(parsed);
|
|
if (!result.success) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Invalid layout format",
|
|
});
|
|
return z.NEVER;
|
|
}
|
|
return result.data;
|
|
} catch {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Invalid JSON",
|
|
});
|
|
return z.NEVER;
|
|
}
|
|
}),
|
|
});
|
|
|
|
const ParamsSchema = EnvironmentParamSchema.extend({
|
|
dashboardId: z.string(),
|
|
});
|
|
|
|
// Check widget limit for add/duplicate actions (title widgets don't count)
|
|
async function checkWidgetLimit(
|
|
existingLayout: z.infer<typeof DashboardLayout>,
|
|
organizationId: string
|
|
) {
|
|
const currentWidgetCount = Object.values(existingLayout.widgets).filter(
|
|
(w) => w.display.type !== "title"
|
|
).length;
|
|
const plan = await getCurrentPlan(organizationId);
|
|
const metricWidgetsLimitValue = (plan?.v3Subscription?.plan?.limits as any)
|
|
?.metricWidgetsPerDashboard;
|
|
const widgetLimit =
|
|
typeof metricWidgetsLimitValue === "number"
|
|
? metricWidgetsLimitValue
|
|
: (metricWidgetsLimitValue?.number ?? 16);
|
|
|
|
if (currentWidgetCount >= widgetLimit) {
|
|
throw new Response("Widget limit reached", { status: 403 });
|
|
}
|
|
}
|
|
|
|
// Optimistic concurrency save: uses updateMany so we can include updatedAt
|
|
// in the where clause. If another request modified the dashboard between our
|
|
// read and this write, updatedAt won't match and count will be 0.
|
|
async function saveDashboardLayout(
|
|
dashboardId: string,
|
|
expectedUpdatedAt: Date,
|
|
updatedLayout: z.infer<typeof DashboardLayout>
|
|
) {
|
|
const result = await prisma.metricsDashboard.updateMany({
|
|
where: { id: dashboardId, updatedAt: expectedUpdatedAt },
|
|
data: {
|
|
layout: JSON.stringify(updatedLayout),
|
|
},
|
|
});
|
|
|
|
if (result.count === 0) {
|
|
throw new Response("Dashboard was modified by another request. Please refresh and try again.", {
|
|
status: 409,
|
|
});
|
|
}
|
|
}
|
|
|
|
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|
const userId = await requireUserId(request);
|
|
const { organizationSlug, projectParam, envParam, dashboardId } = ParamsSchema.parse(params);
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
|
if (!project) {
|
|
throw new Response("Project not found", { status: 404 });
|
|
}
|
|
|
|
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
|
if (!environment) {
|
|
throw new Response("Environment not found", { status: 404 });
|
|
}
|
|
|
|
// Load the dashboard
|
|
const dashboard = await prisma.metricsDashboard.findFirst({
|
|
where: {
|
|
friendlyId: dashboardId,
|
|
organizationId: project.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!dashboard) {
|
|
throw new Response("Dashboard not found", { status: 404 });
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const action = formData.get("action");
|
|
|
|
// Parse existing layout (shared across all actions)
|
|
let existingLayout: z.infer<typeof DashboardLayout>;
|
|
try {
|
|
const parsed = JSON.parse(dashboard.layout);
|
|
const layoutResult = DashboardLayout.safeParse(parsed);
|
|
if (!layoutResult.success) {
|
|
// For add action, we can start with empty layout
|
|
if (action === "add") {
|
|
existingLayout = {
|
|
version: "1",
|
|
layout: [],
|
|
widgets: {},
|
|
};
|
|
} else {
|
|
throw new Response("Invalid dashboard layout", { status: 500 });
|
|
}
|
|
} else {
|
|
existingLayout = layoutResult.data;
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof Response) throw e;
|
|
if (action === "add") {
|
|
existingLayout = {
|
|
version: "1",
|
|
layout: [],
|
|
widgets: {},
|
|
};
|
|
} else {
|
|
throw new Response("Failed to parse dashboard layout", { status: 500 });
|
|
}
|
|
}
|
|
|
|
switch (action) {
|
|
case "add": {
|
|
const rawData = {
|
|
widgetId: formData.get("widgetId"),
|
|
title: formData.get("title"),
|
|
query: formData.get("query"),
|
|
config: formData.get("config"),
|
|
};
|
|
|
|
const result = AddWidgetSchema.safeParse(rawData);
|
|
if (!result.success) {
|
|
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
|
}
|
|
|
|
const { title, query, config } = result.data;
|
|
|
|
// Validate that non-title widgets have a query
|
|
if (config.type !== "title" && !query) {
|
|
throw new Response("Query is required for chart widgets", { status: 400 });
|
|
}
|
|
|
|
// Title widgets don't count against the limit
|
|
if (config.type !== "title") {
|
|
await checkWidgetLimit(existingLayout, project.organizationId);
|
|
}
|
|
|
|
// Use client-provided widget ID if available, otherwise generate one
|
|
// Using the client's ID ensures optimistic UI state stays in sync with the server
|
|
const widgetId = result.data.widgetId || nanoid(8);
|
|
|
|
// Calculate position at the bottom
|
|
let maxBottom = 0;
|
|
for (const item of existingLayout.layout) {
|
|
const itemBottom = item.y + item.h;
|
|
if (itemBottom > maxBottom) {
|
|
maxBottom = itemBottom;
|
|
}
|
|
}
|
|
|
|
// Add new layout item (full width, height depends on widget type)
|
|
const newLayoutItem = {
|
|
i: widgetId,
|
|
x: 0,
|
|
y: maxBottom,
|
|
w: 12,
|
|
h: config.type === "title" ? 2 : 15,
|
|
};
|
|
|
|
// Add new widget
|
|
const newWidget = {
|
|
title,
|
|
query,
|
|
display: config,
|
|
};
|
|
|
|
// Update the layout
|
|
const updatedLayout = {
|
|
...existingLayout,
|
|
layout: [...existingLayout.layout, newLayoutItem],
|
|
widgets: {
|
|
...existingLayout.widgets,
|
|
[widgetId]: newWidget,
|
|
},
|
|
};
|
|
|
|
// Save to database (with optimistic concurrency check)
|
|
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
|
|
|
return typedjson({ success: true, widgetId });
|
|
}
|
|
|
|
case "update": {
|
|
const rawData = {
|
|
widgetId: formData.get("widgetId"),
|
|
title: formData.get("title"),
|
|
query: formData.get("query"),
|
|
config: formData.get("config"),
|
|
};
|
|
|
|
const result = UpdateWidgetSchema.safeParse(rawData);
|
|
if (!result.success) {
|
|
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
|
}
|
|
|
|
const { widgetId, title, query, config } = result.data;
|
|
|
|
// Check if widget exists
|
|
if (!existingLayout.widgets[widgetId]) {
|
|
throw new Response("Widget not found", { status: 404 });
|
|
}
|
|
|
|
// Update the widget
|
|
const updatedWidget = {
|
|
title,
|
|
query,
|
|
display: config,
|
|
};
|
|
|
|
// Update the layout
|
|
const updatedLayout = {
|
|
...existingLayout,
|
|
widgets: {
|
|
...existingLayout.widgets,
|
|
[widgetId]: updatedWidget,
|
|
},
|
|
};
|
|
|
|
// Save to database (with optimistic concurrency check)
|
|
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
|
|
|
return typedjson({ success: true, updatedTitle: title });
|
|
}
|
|
|
|
case "rename": {
|
|
const rawData = {
|
|
widgetId: formData.get("widgetId"),
|
|
title: formData.get("title"),
|
|
};
|
|
|
|
const result = RenameWidgetSchema.safeParse(rawData);
|
|
if (!result.success) {
|
|
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
|
}
|
|
|
|
const { widgetId, title } = result.data;
|
|
|
|
// Check if widget exists
|
|
if (!existingLayout.widgets[widgetId]) {
|
|
throw new Response("Widget not found", { status: 404 });
|
|
}
|
|
|
|
// Update just the title
|
|
const updatedLayout = {
|
|
...existingLayout,
|
|
widgets: {
|
|
...existingLayout.widgets,
|
|
[widgetId]: {
|
|
...existingLayout.widgets[widgetId],
|
|
title,
|
|
},
|
|
},
|
|
};
|
|
|
|
// Save to database (with optimistic concurrency check)
|
|
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
|
|
|
return typedjson({ success: true, renamedTitle: title });
|
|
}
|
|
|
|
case "delete": {
|
|
const rawData = {
|
|
widgetId: formData.get("widgetId"),
|
|
};
|
|
|
|
const result = DeleteWidgetSchema.safeParse(rawData);
|
|
if (!result.success) {
|
|
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
|
}
|
|
|
|
const { widgetId } = result.data;
|
|
|
|
// Get widget title before deleting (for the success message)
|
|
const widget = existingLayout.widgets[widgetId];
|
|
const widgetTitle = widget?.title ?? "Widget";
|
|
|
|
// Remove widget from layout and widgets
|
|
const updatedLayout = {
|
|
...existingLayout,
|
|
layout: existingLayout.layout.filter((item) => item.i !== widgetId),
|
|
widgets: Object.fromEntries(
|
|
Object.entries(existingLayout.widgets).filter(([key]) => key !== widgetId)
|
|
),
|
|
};
|
|
|
|
// Save to database (with optimistic concurrency check)
|
|
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
|
|
|
return typedjson({ success: true, deletedTitle: widgetTitle });
|
|
}
|
|
|
|
case "duplicate": {
|
|
await checkWidgetLimit(existingLayout, project.organizationId);
|
|
|
|
const rawData = {
|
|
widgetId: formData.get("widgetId"),
|
|
newId: formData.get("newId"),
|
|
};
|
|
|
|
const result = DuplicateWidgetSchema.safeParse(rawData);
|
|
if (!result.success) {
|
|
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
|
}
|
|
|
|
const { widgetId } = result.data;
|
|
|
|
// Find the original widget
|
|
const originalWidget = existingLayout.widgets[widgetId];
|
|
if (!originalWidget) {
|
|
throw new Response("Widget not found", { status: 404 });
|
|
}
|
|
|
|
// Find the original layout item
|
|
const originalLayoutItem = existingLayout.layout.find((item) => item.i === widgetId);
|
|
if (!originalLayoutItem) {
|
|
throw new Response("Widget layout not found", { status: 404 });
|
|
}
|
|
|
|
// Use client-provided ID if available, otherwise generate one
|
|
// Using the client's ID ensures optimistic UI state stays in sync with the server
|
|
const newWidgetId = result.data.newId || nanoid(8);
|
|
|
|
// Calculate position at the bottom
|
|
let maxBottom = 0;
|
|
for (const item of existingLayout.layout) {
|
|
const itemBottom = item.y + item.h;
|
|
if (itemBottom > maxBottom) {
|
|
maxBottom = itemBottom;
|
|
}
|
|
}
|
|
|
|
// Create new layout item with same dimensions but at the bottom
|
|
const newLayoutItem = {
|
|
i: newWidgetId,
|
|
x: 0,
|
|
y: maxBottom,
|
|
w: originalLayoutItem.w,
|
|
h: originalLayoutItem.h,
|
|
};
|
|
|
|
// Create new widget with "(Copy)" suffix
|
|
const newWidget = {
|
|
...originalWidget,
|
|
title: `${originalWidget.title} (Copy)`,
|
|
};
|
|
|
|
// Update the layout
|
|
const updatedLayout = {
|
|
...existingLayout,
|
|
layout: [...existingLayout.layout, newLayoutItem],
|
|
widgets: {
|
|
...existingLayout.widgets,
|
|
[newWidgetId]: newWidget,
|
|
},
|
|
};
|
|
|
|
// Save to database (with optimistic concurrency check)
|
|
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
|
|
|
return typedjson({ success: true, duplicatedTitle: originalWidget.title });
|
|
}
|
|
|
|
case "layout": {
|
|
const result = SaveLayoutSchema.safeParse({
|
|
layout: formData.get("layout"),
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
|
}
|
|
|
|
// Update layout positions while preserving widgets
|
|
const updatedLayout = {
|
|
...existingLayout,
|
|
layout: result.data.layout,
|
|
};
|
|
|
|
// Save to database (with optimistic concurrency check)
|
|
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
|
|
|
return typedjson({ success: true });
|
|
}
|
|
|
|
default: {
|
|
throw new Response("Invalid action", { status: 400 });
|
|
}
|
|
}
|
|
};
|