@flaredev/cli 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/index.js +685 -341
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/costs/page.tsx +93 -0
  4. package/templates/app/app/dashboard/page.tsx +4 -7
  5. package/templates/app/app/page.tsx +10 -0
  6. package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
  7. package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
  8. package/templates/app/components/dashboard/record-actions.tsx +7 -7
  9. package/templates/app/components/dashboard/record-detail.tsx +1 -1
  10. package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
  11. package/templates/app/components/dashboard/resource-form.tsx +129 -33
  12. package/templates/app/components/dashboard/resource-icon.tsx +87 -85
  13. package/templates/app/components/dashboard/resource-stats.tsx +10 -9
  14. package/templates/app/components/dashboard/resource-table.tsx +4 -4
  15. package/templates/app/components/dashboard/row-actions.tsx +3 -3
  16. package/templates/app/lib/costs.ts +138 -0
  17. package/templates/app/lib/dashboard-nav.ts +5 -1
  18. package/templates/app/lib/dashboard.ts +66 -61
  19. package/templates/app/lib/number.ts +33 -0
  20. package/templates/app/lib/site.ts +56 -56
  21. package/templates/app/lib/suggest.ts +35 -0
  22. package/templates/app/lib/usage.ts +32 -0
  23. package/templates/next/.env.example +21 -0
  24. package/templates/next/README.md +16 -0
  25. package/templates/next/app/api/health/route.ts +7 -0
  26. package/templates/next/app/dashboard/account/password/page.tsx +19 -0
  27. package/templates/next/app/dashboard/account/security/page.tsx +31 -0
  28. package/templates/next/app/dashboard/actions.ts +277 -0
  29. package/templates/next/app/dashboard/layout.tsx +64 -0
  30. package/templates/next/app/dashboard/page.tsx +127 -0
  31. package/templates/next/components/dashboard/resource-chart.tsx +86 -0
  32. package/templates/next/lib/analytics.ts +71 -0
  33. package/templates/next/lib/audit.ts +80 -0
  34. package/templates/next/lib/auth-emails.ts +92 -0
  35. package/templates/next/lib/auth.ts +156 -0
  36. package/templates/next/lib/dashboard.ts +161 -0
  37. package/templates/next/lib/db.ts +24 -0
  38. package/templates/next/lib/mail.ts +35 -0
  39. package/templates/next/lib/realtime.ts +23 -0
  40. package/templates/next/lib/redis.ts +17 -0
  41. package/templates/next/lib/storage.ts +57 -0
  42. package/templates/next/lib/theme.ts +83 -0
  43. package/templates/next/lib/views.ts +23 -0
  44. package/templates/next/next.config.ts +13 -0
  45. package/templates/next/prisma/schema/base.prisma +162 -0
  46. package/templates/next/prisma.config.ts +15 -0
  47. package/templates/next/tsconfig.json +24 -0
  48. package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
@@ -0,0 +1,277 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
5
+ import type { FieldIssue } from "@flaredev/core/server";
6
+ import { diffFields, recordAudit } from "@/lib/audit";
7
+ import { listViews, MAX_VIEWS } from "@/lib/views";
8
+ import { prisma } from "@/lib/db";
9
+ import { toCsv } from "@/lib/csv";
10
+ import { storage } from "@/lib/storage";
11
+ import { can, storedFields, type PolicyAction, type Resource } from "@flaredev/core";
12
+ import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
13
+
14
+ export type ActionResult<T = unknown> =
15
+ | { ok: true; data: T }
16
+ | { ok: false; status: number; error: string; issues?: FieldIssue[]; field?: string };
17
+
18
+ const forbidden = (message = "You don't have access to this."): ActionResult<never> => ({ ok: false, status: 403, error: message });
19
+
20
+ /** Session + policy check for one action on one resource. */
21
+ async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
22
+ const { allowed: inAdmin, role } = await dashboardSession();
23
+ if (!inAdmin) return forbidden();
24
+ if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
25
+ }
26
+
27
+ /**
28
+ * Take a deleted record's uploads out of storage.
29
+ *
30
+ * The row is gone, so nothing can point at those objects again, and an app that never
31
+ * removes them pays to keep files no one can reach. A failure here is swallowed on
32
+ * purpose: the record is already deleted, and a stray object is a smaller problem than
33
+ * an action that reports failure after having done the work.
34
+ */
35
+ async function removeUploads(resource: Resource, record: Record<string, unknown> | null | undefined): Promise<void> {
36
+ if (!record) return;
37
+ for (const [key, def] of storedFields(resource)) {
38
+ if (def.kind !== "file") continue;
39
+ const value = record[key];
40
+ if (typeof value === "string" && value) await storage.delete(value).catch(() => undefined);
41
+ }
42
+ }
43
+
44
+ export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
45
+ const denied = await allowed(resourceName, "delete");
46
+ if (denied) return denied;
47
+
48
+ const store = dashboardStore(resourceName);
49
+ const existing = await store.get(id);
50
+ const result = await store.delete(id);
51
+ if (result.ok) {
52
+ await removeUploads(store.resource, existing.ok ? existing.data : null);
53
+ await recordAudit({
54
+ action: "delete",
55
+ resource: resourceName,
56
+ recordId: id,
57
+ recordLabel: existing.ok ? recordTitle(store.resource, existing.data) : null,
58
+ });
59
+ revalidatePath(resourcePath(store.resource));
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /** What to call a record in the audit log: its title field, falling back to its id. */
65
+ function recordTitle(resource: { titleField: string }, record: Record<string, unknown>): string {
66
+ const value = record[resource.titleField];
67
+ return typeof value === "string" && value ? value : String(record.id ?? "");
68
+ }
69
+
70
+ /** How many rows one export may fetch: enough for a spreadsheet, not enough to exhaust the Worker. */
71
+ const EXPORT_LIMIT = 50_000;
72
+ const EXPORT_PAGE = 500;
73
+
74
+ export async function deleteManyAction(resourceName: string, ids: string[]): Promise<ActionResult<{ deleted: number; failed: number }>> {
75
+ const denied = await allowed(resourceName, "delete");
76
+ if (denied) return denied;
77
+ if (ids.length === 0) return { ok: true, data: { deleted: 0, failed: 0 } };
78
+ if (ids.length > 500) return { ok: false, status: 400, error: "That's more than 500 records. Delete them in smaller batches." };
79
+
80
+ const store = dashboardStore(resourceName);
81
+ // One at a time, through the store: deleting a record runs its policy, its hooks and
82
+ // its cache invalidation, and a row that refuses to go (a foreign key still pointing
83
+ // at it) shouldn't take the rest of the batch with it.
84
+ let deleted = 0;
85
+ let failed = 0;
86
+ const hasFiles = storedFields(store.resource).some(([, def]) => def.kind === "file");
87
+ for (const id of ids) {
88
+ // Only read the record first when there's an upload to clean up afterwards.
89
+ const existing = hasFiles ? await store.get(id) : null;
90
+ const result = await store.delete(id);
91
+ if (result.ok) {
92
+ deleted++;
93
+ if (existing?.ok) await removeUploads(store.resource, existing.data);
94
+ } else failed++;
95
+ }
96
+ if (deleted > 0) {
97
+ await recordAudit({ action: "bulk-delete", resource: resourceName, changes: { deleted, failed } });
98
+ revalidatePath(resourcePath(store.resource));
99
+ }
100
+ return { ok: true, data: { deleted, failed } };
101
+ }
102
+
103
+ /**
104
+ * Every record matching the current search and filters, as CSV.
105
+ *
106
+ * Read through the same store the table uses, so the export shows exactly what the
107
+ * person can see — their policy, their filters, their sort.
108
+ */
109
+ export async function exportRecordsAction(resourceName: string, query: string): Promise<ActionResult<{ csv: string; rows: number; truncated: boolean }>> {
110
+ const denied = await allowed(resourceName, "read");
111
+ if (denied) return denied;
112
+
113
+ const store = dashboardStore(resourceName);
114
+ const fields = storedFields(store.resource).filter(([, def]) => def.kind !== "file");
115
+ const columns = [["id", "Id"] as const, ...fields.map(([key, def]) => [key, def.label] as const), ["createdAt", "Created"] as const];
116
+
117
+ const rows: Record<string, unknown>[] = [];
118
+ let truncated = false;
119
+ for (let page = 1; rows.length < EXPORT_LIMIT; page++) {
120
+ const params = new URLSearchParams(query);
121
+ params.set("page", String(page));
122
+ params.set("perPage", String(EXPORT_PAGE));
123
+ const result = await store.list(params);
124
+ if (!result.ok) return result;
125
+ rows.push(...result.data.data);
126
+ if (page >= result.data.meta.totalPages) break;
127
+ if (rows.length >= EXPORT_LIMIT) {
128
+ truncated = true;
129
+ break;
130
+ }
131
+ }
132
+
133
+ const csv = toCsv(rows, columns.map(([key, label]) => ({ key, label })));
134
+ return { ok: true, data: { csv, rows: rows.length, truncated } };
135
+ }
136
+
137
+ /** Keep the table's current query as a named view, for this person. */
138
+ export async function saveViewAction(resourceName: string, name: string, query: string): Promise<ActionResult<{ id: string }>> {
139
+ const denied = await allowed(resourceName, "read");
140
+ if (denied) return denied;
141
+ const { session } = await dashboardSession();
142
+ if (!session) return { ok: false, status: 401, error: "Sign in to save a view." };
143
+
144
+ const label = name.trim().slice(0, 60);
145
+ if (!label) return { ok: false, status: 400, error: "Give the view a name." };
146
+ const existing = await listViews(resourceName);
147
+ if (existing.length >= MAX_VIEWS) {
148
+ return { ok: false, status: 400, error: `That's ${MAX_VIEWS} views for ${resourceName}. Delete one first.` };
149
+ }
150
+
151
+ const row = await prisma.savedView.create({
152
+ data: { userId: session.user.id, resource: resourceName, name: label, query: query.replace(/^\?/, "").slice(0, 2000) },
153
+ select: { id: true },
154
+ });
155
+ revalidatePath("/dashboard", "layout");
156
+ return { ok: true, data: { id: row.id } };
157
+ }
158
+
159
+ /** Views belong to the person who saved them, so only they can remove one. */
160
+ export async function deleteViewAction(id: string): Promise<ActionResult<{ id: string }>> {
161
+ const { session } = await dashboardSession();
162
+ if (!session) return { ok: false, status: 401, error: "Sign in first." };
163
+ await prisma.savedView.deleteMany({ where: { id, userId: session.user.id } });
164
+ revalidatePath("/dashboard", "layout");
165
+ return { ok: true, data: { id } };
166
+ }
167
+
168
+ export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
169
+ const denied = await allowed(resourceName, "create");
170
+ if (denied) return denied;
171
+
172
+ const store = dashboardStore(resourceName);
173
+ const result = await store.create(input);
174
+ if (result.ok) {
175
+ await recordAudit({
176
+ action: "create",
177
+ resource: resourceName,
178
+ recordId: String(result.data.id),
179
+ recordLabel: recordTitle(store.resource, result.data),
180
+ changes: diffFields(null, result.data),
181
+ });
182
+ revalidatePath(resourcePath(store.resource));
183
+ }
184
+ return result;
185
+ }
186
+
187
+ export async function updateRecordAction(resourceName: string, id: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
188
+ const denied = await allowed(resourceName, "update");
189
+ if (denied) return denied;
190
+
191
+ const store = dashboardStore(resourceName);
192
+ const before = await store.get(id);
193
+ const result = await store.update(id, input);
194
+ if (result.ok) {
195
+ await recordAudit({
196
+ action: "update",
197
+ resource: resourceName,
198
+ recordId: id,
199
+ recordLabel: recordTitle(store.resource, result.data),
200
+ changes: diffFields(before.ok ? before.data : null, result.data),
201
+ });
202
+ revalidatePath(resourcePath(store.resource));
203
+ }
204
+ return result;
205
+ }
206
+
207
+ /** The file field `fieldKey` of a resource, or a failure when it doesn't exist. */
208
+ function fileField(
209
+ resourceName: string,
210
+ fieldKey: string,
211
+ ): { failure: ActionResult<never> } | { field: FileField & { label: string }; prefix: string } {
212
+ const { resource } = dashboardStore(resourceName);
213
+ const field = resource.fields[fieldKey];
214
+ if (!field || field.kind !== "file") {
215
+ return { failure: { ok: false, status: 400, error: `${resourceName} has no file field "${fieldKey}".` } };
216
+ }
217
+ return { field, prefix: fileKeyPrefix(resource, fieldKey) };
218
+ }
219
+
220
+ /**
221
+ * A short-lived signed URL for uploading to a `file:[…]` field. The declared type and
222
+ * size are checked against the descriptor before signing, so a browser can't widen
223
+ * what the field accepts; the storage route then checks the bytes really are that type.
224
+ */
225
+ export async function createUploadUrlAction(
226
+ resourceName: string,
227
+ fieldKey: string,
228
+ file: { name: string; type: string; size: number },
229
+ ): Promise<ActionResult<{ url: string; key: string }>> {
230
+ // Uploading is part of creating or editing a record, so either permission is enough.
231
+ const deniedCreate = await allowed(resourceName, "create");
232
+ const deniedUpdate = await allowed(resourceName, "update");
233
+ if (deniedCreate && deniedUpdate) return deniedUpdate;
234
+
235
+ const target = fileField(resourceName, fieldKey);
236
+ if ("failure" in target) return target.failure;
237
+ const { field, prefix } = target;
238
+
239
+ const contentTypes = mimeTypesFor(field.accept);
240
+ if (!matchesContentType(file.type || "application/octet-stream", contentTypes)) {
241
+ return { ok: false, status: 415, error: `${field.label} accepts ${field.accept.join(", ")} files.`, field: fieldKey };
242
+ }
243
+ const maxBytes = fileMaxBytes(field);
244
+ if (file.size > maxBytes) {
245
+ return { ok: false, status: 413, error: `${field.label} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`, field: fieldKey };
246
+ }
247
+
248
+ const upload = await storage.createUploadUrl({ key: createObjectKey(file.name, prefix), contentTypes, maxBytes });
249
+ return { ok: true, data: { url: upload.url, key: upload.key } };
250
+ }
251
+
252
+ /**
253
+ * A short-lived URL for viewing a file stored in a `file:[…]` field. Only keys under that
254
+ * field's prefix are signed, and only for roles that can work with the resource, so a
255
+ * guessed or borrowed key can't be read through a resource the role can't see.
256
+ */
257
+ export async function createReadUrlAction(
258
+ resourceName: string,
259
+ fieldKey: string,
260
+ key: string,
261
+ /**
262
+ * Serve the file as a download with this name instead of showing it in the tab. Object
263
+ * keys carry an id to keep them unique, so without this a download arrives called
264
+ * `3f1c…-invoice.pdf` — pass the name the record holds.
265
+ */
266
+ downloadAs?: string,
267
+ ): Promise<ActionResult<{ url: string }>> {
268
+ const denied = await allowed(resourceName, "read");
269
+ // Someone creating or editing a record can view what they just uploaded.
270
+ if (denied && (await allowed(resourceName, "create")) && (await allowed(resourceName, "update"))) return denied;
271
+
272
+ const target = fileField(resourceName, fieldKey);
273
+ if ("failure" in target) return target.failure;
274
+ if (!key.startsWith(target.prefix)) return { ok: false, status: 403, error: "That file doesn't belong to this field.", field: fieldKey };
275
+
276
+ return { ok: true, data: { url: await storage.createReadUrl({ key, downloadAs: downloadAs || undefined }) } };
277
+ }
@@ -0,0 +1,64 @@
1
+ import { cookies } from "next/headers";
2
+ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
3
+ import { Toaster } from "@/components/ui/sonner";
4
+ import { DashboardHeader, type Notice, type Theme } from "@/components/dashboard/dashboard-header";
5
+ import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
6
+ import { prisma } from "@/lib/db";
7
+ import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
8
+ import { dashboardLinks } from "@/lib/dashboard-nav";
9
+ import { ADMIN_ROLES, visibleResources } from "@/lib/dashboard";
10
+ import { requireSession } from "@/lib/session";
11
+ import { listViews } from "@/lib/views";
12
+
13
+ /**
14
+ * The frame around every signed-in page: the app's resources in the sidebar, the
15
+ * account menu in the header and in the sidebar's footer.
16
+ *
17
+ * Everyone signed in gets a dashboard. What's in it depends on the policies: the
18
+ * sidebar lists only the resources this person may read, and the sections that manage
19
+ * the app itself are for ADMIN_ROLES (lib/dashboard.ts).
20
+ */
21
+ export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
22
+ const { user } = await requireSession("/dashboard");
23
+ const jar = await cookies();
24
+ const theme = (jar.get("flare-theme")?.value ?? "system") as Theme;
25
+ const collapsed = jar.get("sidebar_state")?.value === "false";
26
+ const role = (user as { role?: string | null }).role ?? "";
27
+
28
+ const views = await listViews();
29
+ const resources = (await visibleResources()).map((resource) => ({
30
+ name: resource.name,
31
+ pluralLabel: resource.pluralLabel,
32
+ slug: resource.slug,
33
+ icon: resource.icon,
34
+ group: resource.group,
35
+ views: views.filter((view) => view.resource === resource.name).map(({ id, name, query }) => ({ id, name, query })),
36
+ }));
37
+
38
+ // What the bell has to say: the things about this account that aren't done yet.
39
+ const passkeys = await prisma.passkey.count({ where: { userId: user.id } });
40
+ const notices: Notice[] = [
41
+ ...(user.emailVerified
42
+ ? []
43
+ : [{ id: "verify-email", title: "Verify your email address", description: "We'll send a new link or code.", href: "/verify-email", tone: "warning" as const }]),
44
+ ...(twoFactorAvailable && !(user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled
45
+ ? [{ id: "two-factor", title: "Turn on two-factor authentication", description: "A second step after your password.", href: "/dashboard/account/security" }]
46
+ : []),
47
+ ...(authConfig.passkeys && passkeys === 0
48
+ ? [{ id: "passkey", title: "Add a passkey", description: "Sign in with Face ID, Touch ID or Windows Hello.", href: "/dashboard/account/security" }]
49
+ : []),
50
+ ];
51
+
52
+ const account = { name: user.name, email: user.email, image: (user as { image?: string | null }).image };
53
+
54
+ return (
55
+ <SidebarProvider defaultOpen={!collapsed}>
56
+ <DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
57
+ <SidebarInset>
58
+ <DashboardHeader user={account} initialTheme={theme} notices={notices} />
59
+ <main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
60
+ </SidebarInset>
61
+ <Toaster theme={theme} />
62
+ </SidebarProvider>
63
+ );
64
+ }
@@ -0,0 +1,127 @@
1
+ import Link from "next/link";
2
+ import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
6
+ import { PageHeader } from "@/components/dashboard/page-header";
7
+ import { resourceIcon } from "@/components/dashboard/resource-icon";
8
+ import { StatCards, type Stat } from "@/components/dashboard/stat-card";
9
+ import { prisma } from "@/lib/db";
10
+ import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
11
+ import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
12
+ import { requireSession } from "@/lib/session";
13
+
14
+ export const metadata = { title: "Dashboard" };
15
+
16
+ /** The first thing you see when you sign in: how much data there is, and what's left to set up. */
17
+ export default async function DashboardPage() {
18
+ const { user } = await requireSession("/dashboard");
19
+ const resources = await visibleResources();
20
+
21
+ const cards = await Promise.all(
22
+ resources.map(async (resource) => {
23
+ // One query per resource for the count and the trend, not two.
24
+ const [stats, permissions] = await Promise.all([resourceStats(resource.name), adminPermissions(resource.name)]);
25
+ return { resource, total: stats.total, recent: stats, permissions };
26
+ }),
27
+ );
28
+
29
+ const stats: Stat[] = cards.slice(0, 4).map(({ resource, total, recent }) => ({
30
+ label: resource.pluralLabel,
31
+ value: total,
32
+ hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
33
+ change: trend(recent.current, recent.previous),
34
+ icon: resourceIcon(resource.icon),
35
+ href: resourcePath(resource),
36
+ }));
37
+
38
+ const passkeys = await prisma.passkey.count({ where: { userId: user.id } });
39
+ const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
40
+ const steps = [
41
+ { done: user.emailVerified, label: "Verify your email address", href: "/verify-email" },
42
+ ...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account/security" }] : []),
43
+ ...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account/security" }] : []),
44
+ ];
45
+ const remaining = steps.filter((step) => !step.done).length;
46
+
47
+ return (
48
+ <>
49
+ <PageHeader
50
+ title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
51
+ description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource to fill this page."}
52
+ />
53
+
54
+ <StatCards stats={stats} />
55
+
56
+ {resources.length === 0 && (
57
+ <Empty className="rounded-xl border border-dashed">
58
+ <EmptyHeader>
59
+ <EmptyTitle>No resources yet</EmptyTitle>
60
+ <EmptyDescription>
61
+ Generate one with <code>flare gen resource Contact --fields &quot;name:string, email:string&quot;</code>, then fill it with{" "}
62
+ <code>flare seed:resource Contact --count 1000</code>.
63
+ </EmptyDescription>
64
+ </EmptyHeader>
65
+ </Empty>
66
+ )}
67
+
68
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
69
+ {cards.length > 0 && (
70
+ <Card>
71
+ <CardHeader>
72
+ <CardTitle className="text-base">Your data</CardTitle>
73
+ <CardDescription>Every resource you can see.</CardDescription>
74
+ </CardHeader>
75
+ <CardContent className="flex flex-col">
76
+ {cards.map(({ resource, total, permissions }) => {
77
+ const Icon = resourceIcon(resource.icon);
78
+ return (
79
+ <div key={resource.name} className="flex items-center gap-3 border-b py-3 last:border-b-0 last:pb-0">
80
+ <span className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
81
+ <Icon className="size-4.5" />
82
+ </span>
83
+ <div className="flex min-w-0 flex-col">
84
+ <Link href={resourcePath(resource)} className="truncate text-sm font-medium hover:underline">
85
+ {resource.pluralLabel}
86
+ </Link>
87
+ <span className="text-xs text-muted-foreground">{total.toLocaleString()} records</span>
88
+ </div>
89
+ <div className="ml-auto flex items-center gap-1">
90
+ {permissions.create && (
91
+ <Button variant="ghost" size="icon" aria-label={`New ${resource.label.toLowerCase()}`} asChild>
92
+ <Link href={resourcePath(resource, "new")}>
93
+ <PlusIcon />
94
+ </Link>
95
+ </Button>
96
+ )}
97
+ <Button variant="ghost" size="icon" aria-label={`Open ${resource.pluralLabel.toLowerCase()}`} asChild>
98
+ <Link href={resourcePath(resource)}>
99
+ <ArrowRightIcon />
100
+ </Link>
101
+ </Button>
102
+ </div>
103
+ </div>
104
+ );
105
+ })}
106
+ </CardContent>
107
+ </Card>
108
+ )}
109
+
110
+ <Card className="h-fit">
111
+ <CardHeader>
112
+ <CardTitle className="text-base">Secure your account</CardTitle>
113
+ <CardDescription>{remaining === 0 ? "All done." : `${remaining} to go.`}</CardDescription>
114
+ </CardHeader>
115
+ <CardContent className="flex flex-col">
116
+ {steps.map((step) => (
117
+ <Link key={step.label} href={step.href} className="flex items-center gap-3 border-b py-3 text-sm last:border-b-0 last:pb-0 hover:text-primary">
118
+ {step.done ? <CheckCircle2Icon className="size-4.5 text-success" /> : <CircleIcon className="size-4.5 text-muted-foreground" />}
119
+ <span className={step.done ? "text-muted-foreground line-through" : "font-medium"}>{step.label}</span>
120
+ </Link>
121
+ ))}
122
+ </CardContent>
123
+ </Card>
124
+ </div>
125
+ </>
126
+ );
127
+ }
@@ -0,0 +1,86 @@
1
+ import type { Resource } from "@flaredev/core";
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
3
+ import { prisma } from "@/lib/db";
4
+ import { resourceTables } from "@/resources/server";
5
+ import { cached, resourceTag, TTL } from "@/lib/cache";
6
+
7
+ export interface DayCount {
8
+ /** "2026-09-23" */
9
+ date: string;
10
+ total: number;
11
+ }
12
+
13
+ /**
14
+ * How many records were created a day, grouped in SQL rather than by reading rows: one
15
+ * query over the created_at index, whatever the table's size.
16
+ */
17
+ export function createdPerDay(name: string, days = 30): Promise<DayCount[]> {
18
+ const counts = cached(
19
+ async (resourceName: string, window: number) => {
20
+ const entry = (resourceTables as unknown as Record<string, { resource: { table: string; fields: object } } | undefined>)[resourceName];
21
+ if (!entry) return [];
22
+ const since = new Date(Date.now() - window * 86_400_000).toISOString();
23
+ // Grouped in Postgres rather than by reading rows: one pass over the created_at
24
+ // index whatever the table's size. The table name comes from the descriptor.
25
+ const rows = (await prisma.$queryRawUnsafe(
26
+ `select to_char("created_at", 'YYYY-MM-DD') as date, count(*)::int as total
27
+ from "${entry.resource.table}" where "created_at" >= '${since}' group by 1 order by 1`,
28
+ )) as DayCount[];
29
+ return rows.map((row) => ({ date: String(row.date), total: Number(row.total) }));
30
+ },
31
+ ["flare", "created-per-day"],
32
+ { tags: [resourceTag(name)], revalidate: TTL.short },
33
+ );
34
+ return counts(name, days);
35
+ }
36
+
37
+ /** Fill the gaps: a day with nothing created still needs a column. */
38
+ function series(rows: DayCount[], days: number): DayCount[] {
39
+ const found = new Map(rows.map((row) => [row.date, row.total]));
40
+ return Array.from({ length: days }, (_, index) => {
41
+ const date = new Date(Date.now() - (days - 1 - index) * 86_400_000).toISOString().slice(0, 10);
42
+ return { date, total: found.get(date) ?? 0 };
43
+ });
44
+ }
45
+
46
+ /**
47
+ * New records a day for the last month, above the table.
48
+ *
49
+ * Drawn with CSS rather than a charting library: thirty numbers don't justify shipping
50
+ * one to the browser, and this renders on the server with the rest of the page.
51
+ */
52
+ export async function ResourceChart({ resource, days = 30 }: { resource: Resource; days?: number }) {
53
+ const rows = series(await createdPerDay(resource.name, days), days);
54
+ const total = rows.reduce((sum, row) => sum + row.total, 0);
55
+ if (total === 0) return null;
56
+
57
+ const peak = Math.max(1, ...rows.map((row) => row.total));
58
+ const month = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" });
59
+
60
+ return (
61
+ <Card>
62
+ <CardHeader>
63
+ <CardTitle className="text-base">New {resource.pluralLabel.toLowerCase()}</CardTitle>
64
+ <CardDescription>
65
+ {total.toLocaleString()} in the last {days} days.
66
+ </CardDescription>
67
+ </CardHeader>
68
+ <CardContent>
69
+ <div className="flex h-28 items-end gap-0.5" role="img" aria-label={`New ${resource.pluralLabel.toLowerCase()} a day for the last ${days} days`}>
70
+ {rows.map((row) => (
71
+ <div
72
+ key={row.date}
73
+ title={`${row.date}: ${row.total}`}
74
+ style={{ height: `${Math.max(2, Math.round((row.total / peak) * 100))}%` }}
75
+ className="flex-1 rounded-t-sm bg-primary/70 transition-colors hover:bg-primary"
76
+ />
77
+ ))}
78
+ </div>
79
+ <div className="mt-2 flex justify-between text-xs text-muted-foreground">
80
+ <span>{month.format(new Date(`${rows[0]!.date}T00:00:00Z`))}</span>
81
+ <span>{month.format(new Date(`${rows[rows.length - 1]!.date}T00:00:00Z`))}</span>
82
+ </div>
83
+ </CardContent>
84
+ </Card>
85
+ );
86
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Traffic, errors and latency — on Vercel, not from here.
3
+ *
4
+ * The Cloudflare stack reads these from Cloudflare's GraphQL Analytics API. Vercel's
5
+ * equivalent needs a team token and a different shape of query, and an app shouldn't
6
+ * hold a token that can read the whole account, so this reports "unconfigured" and the
7
+ * observability page says where the numbers actually are.
8
+ *
9
+ * The types are kept identical so the page doesn't need two versions.
10
+ */
11
+
12
+ export interface RequestPoint {
13
+ /** "2026-09-22" */
14
+ date: string;
15
+ requests: number;
16
+ errors: number;
17
+ /** Median CPU time in milliseconds. */
18
+ medianCpuMs: number;
19
+ }
20
+
21
+ export interface WorkerAnalytics {
22
+ points: RequestPoint[];
23
+ requests: number;
24
+ errors: number;
25
+ /** Errors as a percentage of requests. */
26
+ errorRate: number;
27
+ medianCpuMs: number;
28
+ }
29
+
30
+ /** Why there's nothing to show, when there's nothing to show. */
31
+ export type AnalyticsResult = { ok: true; data: WorkerAnalytics } | { ok: false; reason: "unconfigured" | "failed"; message: string };
32
+
33
+ interface GraphQlResponse {
34
+ data?: {
35
+ viewer?: {
36
+ accounts?: {
37
+ workersInvocationsAdaptive?: {
38
+ dimensions: { date: string };
39
+ sum: { requests: number; errors: number };
40
+ quantiles: { cpuTimeP50: number };
41
+ }[];
42
+ }[];
43
+ };
44
+ };
45
+ errors?: { message: string }[];
46
+ }
47
+
48
+ const QUERY = `query WorkerTraffic($accountTag: String!, $scriptName: String!, $since: Date!, $until: Date!) {
49
+ viewer {
50
+ accounts(filter: { accountTag: $accountTag }) {
51
+ workersInvocationsAdaptive(
52
+ limit: 100
53
+ filter: { scriptName: $scriptName, date_geq: $since, date_leq: $until }
54
+ orderBy: [date_ASC]
55
+ ) {
56
+ dimensions { date }
57
+ sum { requests errors }
58
+ quantiles { cpuTimeP50 }
59
+ }
60
+ }
61
+ }
62
+ }`;
63
+
64
+ /** Traffic for the last `days` days, a point per day. */
65
+ export async function workerAnalytics(_scriptName: string, _days = 7): Promise<AnalyticsResult> {
66
+ return {
67
+ ok: false,
68
+ reason: "unconfigured",
69
+ message: "Traffic and error rates for this app are in your Vercel dashboard, under Observability.",
70
+ };
71
+ }