@flaredev/cli 0.2.0 → 0.3.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.
- package/bin/flare.js +9 -1
- package/dist/index.js +751 -280
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/page.tsx +6 -4
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -39
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +106 -25
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +6 -9
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/migrations/0000_init.sql +15 -3
- package/templates/app/migrations/meta/0000_snapshot.json +97 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Muke Johnbaptist",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"jsonc-parser": "^3.3.1",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
|
-
"@flaredev/core": "0.
|
|
44
|
+
"@flaredev/core": "0.3.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -1,63 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { ArrowRightIcon, PlusIcon } from "lucide-react";
|
|
3
|
-
import { Button } from "@/components/ui/button";
|
|
4
|
-
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
5
|
-
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
6
|
-
import { resourceIcon } from "@/components/admin/resource-icon";
|
|
7
|
-
import { adminPath, recordCount, visibleResources } from "@/lib/admin";
|
|
1
|
+
import { permanentRedirect } from "next/navigation";
|
|
8
2
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
<EmptyHeader>
|
|
18
|
-
<EmptyTitle>No resources yet</EmptyTitle>
|
|
19
|
-
<EmptyDescription>
|
|
20
|
-
Generate one with <code>flare gen resource Contact --fields "name:string, email:string"</code>.
|
|
21
|
-
</EmptyDescription>
|
|
22
|
-
</EmptyHeader>
|
|
23
|
-
</Empty>
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<div className="flex flex-col gap-6">
|
|
29
|
-
<h1 className="text-2xl font-semibold">Overview</h1>
|
|
30
|
-
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
31
|
-
{resources.map((resource, index) => {
|
|
32
|
-
const Icon = resourceIcon(resource.icon);
|
|
33
|
-
return (
|
|
34
|
-
<Card key={resource.name}>
|
|
35
|
-
<CardHeader>
|
|
36
|
-
<CardDescription className="flex items-center gap-2">
|
|
37
|
-
<Icon className="size-5" />
|
|
38
|
-
{resource.pluralLabel}
|
|
39
|
-
</CardDescription>
|
|
40
|
-
<CardTitle className="text-3xl font-bold tabular-nums">{counts[index]!.toLocaleString()}</CardTitle>
|
|
41
|
-
<CardAction>
|
|
42
|
-
<Button variant="ghost" size="icon" aria-label={`New ${resource.label.toLowerCase()}`} asChild>
|
|
43
|
-
<Link href={adminPath(resource, "new")}>
|
|
44
|
-
<PlusIcon />
|
|
45
|
-
</Link>
|
|
46
|
-
</Button>
|
|
47
|
-
</CardAction>
|
|
48
|
-
</CardHeader>
|
|
49
|
-
<CardContent>
|
|
50
|
-
<Button variant="link" className="h-auto p-0" asChild>
|
|
51
|
-
<Link href={adminPath(resource)}>
|
|
52
|
-
View {resource.pluralLabel.toLowerCase()}
|
|
53
|
-
<ArrowRightIcon data-icon="inline-end" />
|
|
54
|
-
</Link>
|
|
55
|
-
</Button>
|
|
56
|
-
</CardContent>
|
|
57
|
-
</Card>
|
|
58
|
-
);
|
|
59
|
-
})}
|
|
60
|
-
</div>
|
|
61
|
-
</div>
|
|
62
|
-
);
|
|
3
|
+
/**
|
|
4
|
+
* The admin used to live here. It's the dashboard now — one signed-in area, with the
|
|
5
|
+
* resources, the account pages, security and observability in the same shell.
|
|
6
|
+
*
|
|
7
|
+
* Kept so links and bookmarks still land somewhere.
|
|
8
|
+
*/
|
|
9
|
+
export default function AdminPage() {
|
|
10
|
+
permanentRedirect("/dashboard");
|
|
63
11
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { and, eq } from "drizzle-orm";
|
|
2
2
|
import { SecuritySettings } from "@/components/account/security-settings";
|
|
3
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
3
4
|
import { getDb } from "@/db";
|
|
4
5
|
import { account } from "@/db/auth-schema";
|
|
5
6
|
import { enabledSocialProviders } from "@/lib/auth";
|
|
@@ -18,10 +19,11 @@ export default async function AccountPage() {
|
|
|
18
19
|
|
|
19
20
|
return (
|
|
20
21
|
<>
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
<PageHeader
|
|
23
|
+
title="Account"
|
|
24
|
+
description="Your profile, how you sign in, and where you're signed in."
|
|
25
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Account" }]}
|
|
26
|
+
/>
|
|
25
27
|
<SecuritySettings
|
|
26
28
|
user={{
|
|
27
29
|
name: user.name,
|
|
@@ -3,19 +3,21 @@
|
|
|
3
3
|
import { revalidatePath } from "next/cache";
|
|
4
4
|
import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
|
|
5
5
|
import type { FieldIssue } from "@flaredev/core/server";
|
|
6
|
+
import { diffFields, recordAudit } from "@/lib/audit";
|
|
7
|
+
import { toCsv } from "@/lib/csv";
|
|
6
8
|
import { storage } from "@/lib/storage";
|
|
7
|
-
import { can, type PolicyAction } from "@flaredev/core";
|
|
8
|
-
import {
|
|
9
|
+
import { can, storedFields, type PolicyAction } from "@flaredev/core";
|
|
10
|
+
import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
|
|
9
11
|
|
|
10
12
|
export type ActionResult<T = unknown> =
|
|
11
13
|
| { ok: true; data: T }
|
|
12
14
|
| { ok: false; status: number; error: string; issues?: FieldIssue[]; field?: string };
|
|
13
15
|
|
|
14
|
-
const forbidden = (message = "You don't have access to
|
|
16
|
+
const forbidden = (message = "You don't have access to this."): ActionResult<never> => ({ ok: false, status: 403, error: message });
|
|
15
17
|
|
|
16
18
|
/** Session + policy check for one action on one resource. */
|
|
17
19
|
async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
|
|
18
|
-
const { allowed: inAdmin, role } = await
|
|
20
|
+
const { allowed: inAdmin, role } = await dashboardSession();
|
|
19
21
|
if (!inAdmin) return forbidden();
|
|
20
22
|
if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
|
|
21
23
|
}
|
|
@@ -24,19 +26,105 @@ export async function deleteRecordAction(resourceName: string, id: string): Prom
|
|
|
24
26
|
const denied = await allowed(resourceName, "delete");
|
|
25
27
|
if (denied) return denied;
|
|
26
28
|
|
|
27
|
-
const store =
|
|
29
|
+
const store = dashboardStore(resourceName);
|
|
30
|
+
const existing = await store.get(id);
|
|
28
31
|
const result = await store.delete(id);
|
|
29
|
-
if (result.ok)
|
|
32
|
+
if (result.ok) {
|
|
33
|
+
await recordAudit({
|
|
34
|
+
action: "delete",
|
|
35
|
+
resource: resourceName,
|
|
36
|
+
recordId: id,
|
|
37
|
+
recordLabel: existing.ok ? recordTitle(store.resource, existing.data) : null,
|
|
38
|
+
});
|
|
39
|
+
revalidatePath(resourcePath(store.resource));
|
|
40
|
+
}
|
|
30
41
|
return result;
|
|
31
42
|
}
|
|
32
43
|
|
|
44
|
+
/** What to call a record in the audit log: its title field, falling back to its id. */
|
|
45
|
+
function recordTitle(resource: { titleField: string }, record: Record<string, unknown>): string {
|
|
46
|
+
const value = record[resource.titleField];
|
|
47
|
+
return typeof value === "string" && value ? value : String(record.id ?? "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** How many rows one export may fetch: enough for a spreadsheet, not enough to exhaust the Worker. */
|
|
51
|
+
const EXPORT_LIMIT = 50_000;
|
|
52
|
+
const EXPORT_PAGE = 500;
|
|
53
|
+
|
|
54
|
+
export async function deleteManyAction(resourceName: string, ids: string[]): Promise<ActionResult<{ deleted: number; failed: number }>> {
|
|
55
|
+
const denied = await allowed(resourceName, "delete");
|
|
56
|
+
if (denied) return denied;
|
|
57
|
+
if (ids.length === 0) return { ok: true, data: { deleted: 0, failed: 0 } };
|
|
58
|
+
if (ids.length > 500) return { ok: false, status: 400, error: "That's more than 500 records. Delete them in smaller batches." };
|
|
59
|
+
|
|
60
|
+
const store = dashboardStore(resourceName);
|
|
61
|
+
// One at a time, through the store: deleting a record runs its policy, its hooks and
|
|
62
|
+
// its cache invalidation, and a row that refuses to go (a foreign key still pointing
|
|
63
|
+
// at it) shouldn't take the rest of the batch with it.
|
|
64
|
+
let deleted = 0;
|
|
65
|
+
let failed = 0;
|
|
66
|
+
for (const id of ids) {
|
|
67
|
+
const result = await store.delete(id);
|
|
68
|
+
if (result.ok) deleted++;
|
|
69
|
+
else failed++;
|
|
70
|
+
}
|
|
71
|
+
if (deleted > 0) {
|
|
72
|
+
await recordAudit({ action: "bulk-delete", resource: resourceName, changes: { deleted, failed } });
|
|
73
|
+
revalidatePath(resourcePath(store.resource));
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, data: { deleted, failed } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every record matching the current search and filters, as CSV.
|
|
80
|
+
*
|
|
81
|
+
* Read through the same store the table uses, so the export shows exactly what the
|
|
82
|
+
* person can see — their policy, their filters, their sort.
|
|
83
|
+
*/
|
|
84
|
+
export async function exportRecordsAction(resourceName: string, query: string): Promise<ActionResult<{ csv: string; rows: number; truncated: boolean }>> {
|
|
85
|
+
const denied = await allowed(resourceName, "read");
|
|
86
|
+
if (denied) return denied;
|
|
87
|
+
|
|
88
|
+
const store = dashboardStore(resourceName);
|
|
89
|
+
const fields = storedFields(store.resource).filter(([, def]) => def.kind !== "file");
|
|
90
|
+
const columns = [["id", "Id"] as const, ...fields.map(([key, def]) => [key, def.label] as const), ["createdAt", "Created"] as const];
|
|
91
|
+
|
|
92
|
+
const rows: Record<string, unknown>[] = [];
|
|
93
|
+
let truncated = false;
|
|
94
|
+
for (let page = 1; rows.length < EXPORT_LIMIT; page++) {
|
|
95
|
+
const params = new URLSearchParams(query);
|
|
96
|
+
params.set("page", String(page));
|
|
97
|
+
params.set("perPage", String(EXPORT_PAGE));
|
|
98
|
+
const result = await store.list(params);
|
|
99
|
+
if (!result.ok) return result;
|
|
100
|
+
rows.push(...result.data.data);
|
|
101
|
+
if (page >= result.data.meta.totalPages) break;
|
|
102
|
+
if (rows.length >= EXPORT_LIMIT) {
|
|
103
|
+
truncated = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const csv = toCsv(rows, columns.map(([key, label]) => ({ key, label })));
|
|
109
|
+
return { ok: true, data: { csv, rows: rows.length, truncated } };
|
|
110
|
+
}
|
|
111
|
+
|
|
33
112
|
export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
|
|
34
113
|
const denied = await allowed(resourceName, "create");
|
|
35
114
|
if (denied) return denied;
|
|
36
115
|
|
|
37
|
-
const store =
|
|
116
|
+
const store = dashboardStore(resourceName);
|
|
38
117
|
const result = await store.create(input);
|
|
39
|
-
if (result.ok)
|
|
118
|
+
if (result.ok) {
|
|
119
|
+
await recordAudit({
|
|
120
|
+
action: "create",
|
|
121
|
+
resource: resourceName,
|
|
122
|
+
recordId: String(result.data.id),
|
|
123
|
+
recordLabel: recordTitle(store.resource, result.data),
|
|
124
|
+
changes: diffFields(null, result.data),
|
|
125
|
+
});
|
|
126
|
+
revalidatePath(resourcePath(store.resource));
|
|
127
|
+
}
|
|
40
128
|
return result;
|
|
41
129
|
}
|
|
42
130
|
|
|
@@ -44,9 +132,19 @@ export async function updateRecordAction(resourceName: string, id: string, input
|
|
|
44
132
|
const denied = await allowed(resourceName, "update");
|
|
45
133
|
if (denied) return denied;
|
|
46
134
|
|
|
47
|
-
const store =
|
|
135
|
+
const store = dashboardStore(resourceName);
|
|
136
|
+
const before = await store.get(id);
|
|
48
137
|
const result = await store.update(id, input);
|
|
49
|
-
if (result.ok)
|
|
138
|
+
if (result.ok) {
|
|
139
|
+
await recordAudit({
|
|
140
|
+
action: "update",
|
|
141
|
+
resource: resourceName,
|
|
142
|
+
recordId: id,
|
|
143
|
+
recordLabel: recordTitle(store.resource, result.data),
|
|
144
|
+
changes: diffFields(before.ok ? before.data : null, result.data),
|
|
145
|
+
});
|
|
146
|
+
revalidatePath(resourcePath(store.resource));
|
|
147
|
+
}
|
|
50
148
|
return result;
|
|
51
149
|
}
|
|
52
150
|
|
|
@@ -55,7 +153,7 @@ function fileField(
|
|
|
55
153
|
resourceName: string,
|
|
56
154
|
fieldKey: string,
|
|
57
155
|
): { failure: ActionResult<never> } | { field: FileField & { label: string }; prefix: string } {
|
|
58
|
-
const { resource } =
|
|
156
|
+
const { resource } = dashboardStore(resourceName);
|
|
59
157
|
const field = resource.fields[fieldKey];
|
|
60
158
|
if (!field || field.kind !== "file") {
|
|
61
159
|
return { failure: { ok: false, status: 400, error: `${resourceName} has no file field "${fieldKey}".` } };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { recordAudit } from "@/lib/audit";
|
|
5
|
+
import { can, type PolicyAction } from "@flaredev/core";
|
|
6
|
+
import type { Failure } from "@flaredev/core/server";
|
|
7
|
+
import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
|
|
8
|
+
import type { ActionResult } from "./actions";
|
|
9
|
+
|
|
10
|
+
/** One row that couldn't be saved, numbered from 1 within the batch that was sent. */
|
|
11
|
+
export interface ImportFailure {
|
|
12
|
+
row: number;
|
|
13
|
+
error: string;
|
|
14
|
+
field?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ImportSummary {
|
|
18
|
+
created: number;
|
|
19
|
+
failed: ImportFailure[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Rows accepted per call. The browser splits a file into batches of this size or smaller. */
|
|
23
|
+
const MAX_ROWS = 5000;
|
|
24
|
+
|
|
25
|
+
/** How many consecutive failures at the start of a batch count as a mis-mapped file. */
|
|
26
|
+
const GIVE_UP_AFTER = 25;
|
|
27
|
+
|
|
28
|
+
const forbidden = (message = "You don't have access to the admin."): ActionResult<never> => ({ ok: false, status: 403, error: message });
|
|
29
|
+
|
|
30
|
+
/** Session + policy check for one action on one resource. */
|
|
31
|
+
async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
|
|
32
|
+
const { allowed: inAdmin, role } = await dashboardSession();
|
|
33
|
+
if (!inAdmin) return forbidden();
|
|
34
|
+
if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A store failure as one line of text, attributed to a field when the issues name one. */
|
|
38
|
+
function describe(failure: Failure): { error: string; field?: string } {
|
|
39
|
+
const issues = failure.issues ?? [];
|
|
40
|
+
if (issues.length === 0) return { error: failure.error, field: failure.field };
|
|
41
|
+
return {
|
|
42
|
+
error: issues.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message)).join("; "),
|
|
43
|
+
field: issues[0]?.path.split(".")[0] || failure.field,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates one batch of imported records and reports what happened to each row.
|
|
49
|
+
*
|
|
50
|
+
* Rows go through `store.create` one at a time rather than a bulk insert so an import
|
|
51
|
+
* gets the same validation, defaults, generated ids and change hooks as the API and the
|
|
52
|
+
* admin form; a bulk insert would let a CSV write values no other route can.
|
|
53
|
+
*/
|
|
54
|
+
export async function importRecordsAction(resourceName: string, rows: Record<string, unknown>[]): Promise<ActionResult<ImportSummary>> {
|
|
55
|
+
const denied = await allowed(resourceName, "create");
|
|
56
|
+
if (denied) return denied;
|
|
57
|
+
|
|
58
|
+
if (rows.length > MAX_ROWS) {
|
|
59
|
+
return { ok: false, status: 413, error: `This import sent ${rows.length} rows at once. Send at most ${MAX_ROWS} rows per batch.` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const store = dashboardStore(resourceName);
|
|
63
|
+
const failed: ImportFailure[] = [];
|
|
64
|
+
let created = 0;
|
|
65
|
+
|
|
66
|
+
for (const [index, row] of rows.entries()) {
|
|
67
|
+
const result = await store.create(row);
|
|
68
|
+
if (result.ok) {
|
|
69
|
+
created += 1;
|
|
70
|
+
} else {
|
|
71
|
+
failed.push({ row: index + 1, ...describe(result) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Columns mapped to the wrong fields fail every row, so a long file would spend
|
|
75
|
+
// thousands of writes proving the same point. A short batch runs to the end, where
|
|
76
|
+
// the per-row report is more use than an early exit.
|
|
77
|
+
if (created === 0 && failed.length === GIVE_UP_AFTER && rows.length > GIVE_UP_AFTER) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
status: 422,
|
|
81
|
+
error: `The first ${GIVE_UP_AFTER} rows all failed, so nothing was imported. Check the column mapping — the first row said: ${failed[0]?.error ?? "no reason given"}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (created > 0) {
|
|
87
|
+
await recordAudit({ action: "import", resource: resourceName, changes: { created, failed: failed.length } });
|
|
88
|
+
revalidatePath(resourcePath(store.resource));
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, data: { created, failed } };
|
|
91
|
+
}
|
|
@@ -1,39 +1,42 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
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 Theme } from "@/components/dashboard/dashboard-header";
|
|
5
|
+
import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
|
|
6
|
+
import { dashboardLinks } from "@/lib/dashboard-nav";
|
|
7
|
+
import { ADMIN_ROLES, visibleResources } from "@/lib/dashboard";
|
|
8
|
+
import { requireSession } from "@/lib/session";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The frame around every signed-in page: the app's resources in the sidebar, the
|
|
12
|
+
* account menu in the header.
|
|
13
|
+
*
|
|
14
|
+
* Everyone signed in gets a dashboard. What's in it depends on the policies: the
|
|
15
|
+
* sidebar lists only the resources this person may read, and the sections that manage
|
|
16
|
+
* the app itself are for ADMIN_ROLES (lib/dashboard.ts).
|
|
17
|
+
*/
|
|
18
|
+
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
19
|
+
const { user } = await requireSession("/dashboard");
|
|
20
|
+
const jar = await cookies();
|
|
21
|
+
const theme = (jar.get("flare-theme")?.value ?? "system") as Theme;
|
|
22
|
+
const collapsed = jar.get("sidebar_state")?.value === "false";
|
|
23
|
+
const role = (user as { role?: string | null }).role ?? "";
|
|
24
|
+
|
|
25
|
+
const resources = (await visibleResources()).map((resource) => ({
|
|
26
|
+
name: resource.name,
|
|
27
|
+
pluralLabel: resource.pluralLabel,
|
|
28
|
+
slug: resource.slug,
|
|
29
|
+
icon: resource.icon,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<SidebarProvider defaultOpen={!collapsed}>
|
|
34
|
+
<DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} />
|
|
35
|
+
<SidebarInset>
|
|
36
|
+
<DashboardHeader user={{ name: user.name, email: user.email }} initialTheme={theme} />
|
|
37
|
+
<main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
|
|
38
|
+
</SidebarInset>
|
|
39
|
+
<Toaster theme={theme} />
|
|
40
|
+
</SidebarProvider>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ActivityIcon, AlertTriangleIcon, DatabaseIcon, GaugeIcon, HistoryIcon } from "lucide-react";
|
|
3
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
7
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
8
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
9
|
+
import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
10
|
+
import { TrafficChart } from "@/components/dashboard/traffic-chart";
|
|
11
|
+
import { auditCount, recentAudit } from "@/lib/audit";
|
|
12
|
+
import { workerAnalytics } from "@/lib/analytics";
|
|
13
|
+
import { recordCount, requireDashboard, resourcePath, visibleResources } from "@/lib/dashboard";
|
|
14
|
+
import { site } from "@/lib/site";
|
|
15
|
+
|
|
16
|
+
export const metadata = { title: "Observability" };
|
|
17
|
+
|
|
18
|
+
const ACTION_TONE: Record<string, "secondary" | "destructive" | "outline"> = {
|
|
19
|
+
create: "secondary",
|
|
20
|
+
update: "outline",
|
|
21
|
+
delete: "destructive",
|
|
22
|
+
"bulk-delete": "destructive",
|
|
23
|
+
import: "secondary",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const when = (date: Date) => new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
|
27
|
+
|
|
28
|
+
/** How the app is behaving: traffic from Cloudflare, size from the database, and who changed what. */
|
|
29
|
+
export default async function ObservabilityPage() {
|
|
30
|
+
await requireDashboard("/dashboard/observability");
|
|
31
|
+
|
|
32
|
+
const [analytics, resources, changes, audit] = await Promise.all([
|
|
33
|
+
workerAnalytics(site.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-")),
|
|
34
|
+
visibleResources(),
|
|
35
|
+
auditCount(7),
|
|
36
|
+
recentAudit(20),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const tables = await Promise.all(resources.map(async (resource) => ({ resource, total: await recordCount(resource.name) })));
|
|
40
|
+
const records = tables.reduce((total, table) => total + table.total, 0);
|
|
41
|
+
|
|
42
|
+
const stats: Stat[] = [
|
|
43
|
+
analytics.ok
|
|
44
|
+
? { label: "Requests (7 days)", value: analytics.data.requests, icon: ActivityIcon }
|
|
45
|
+
: { label: "Requests (7 days)", value: "—", hint: "not connected", icon: ActivityIcon },
|
|
46
|
+
analytics.ok
|
|
47
|
+
? {
|
|
48
|
+
label: "Errors",
|
|
49
|
+
value: analytics.data.errors,
|
|
50
|
+
hint: `${analytics.data.errorRate.toFixed(2)}% of requests`,
|
|
51
|
+
goodDirection: "down",
|
|
52
|
+
icon: AlertTriangleIcon,
|
|
53
|
+
}
|
|
54
|
+
: { label: "Errors", value: "—", hint: "not connected", icon: AlertTriangleIcon },
|
|
55
|
+
analytics.ok
|
|
56
|
+
? { label: "Median CPU time", value: `${analytics.data.medianCpuMs} ms`, hint: "per request", icon: GaugeIcon }
|
|
57
|
+
: { label: "Median CPU time", value: "—", hint: "not connected", icon: GaugeIcon },
|
|
58
|
+
{ label: "Changes this week", value: changes, hint: "creates, edits and deletes", icon: HistoryIcon },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<PageHeader
|
|
64
|
+
title="Observability"
|
|
65
|
+
description="Traffic, database and the record of who changed what."
|
|
66
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Observability" }]}
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
<StatCards stats={stats} />
|
|
70
|
+
|
|
71
|
+
{!analytics.ok && (
|
|
72
|
+
<Alert>
|
|
73
|
+
<AlertTitle>{analytics.reason === "unconfigured" ? "Traffic isn't connected yet" : "Couldn't read traffic from Cloudflare"}</AlertTitle>
|
|
74
|
+
<AlertDescription>
|
|
75
|
+
<p>
|
|
76
|
+
{analytics.message}
|
|
77
|
+
{analytics.reason === "unconfigured" &&
|
|
78
|
+
" Create a token with Account Analytics: Read, then set it with `wrangler secret put CLOUDFLARE_API_TOKEN` and `wrangler secret put CLOUDFLARE_ACCOUNT_ID`."}
|
|
79
|
+
</p>
|
|
80
|
+
</AlertDescription>
|
|
81
|
+
</Alert>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{analytics.ok && analytics.data.points.length > 0 && (
|
|
85
|
+
<Card>
|
|
86
|
+
<CardHeader>
|
|
87
|
+
<CardTitle className="text-base">Requests a day</CardTitle>
|
|
88
|
+
<CardDescription>The last seven days, errors marked in red.</CardDescription>
|
|
89
|
+
</CardHeader>
|
|
90
|
+
<CardContent>
|
|
91
|
+
<TrafficChart points={analytics.data.points} />
|
|
92
|
+
</CardContent>
|
|
93
|
+
</Card>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
|
97
|
+
<Card className="h-fit">
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
100
|
+
<DatabaseIcon className="size-4" />
|
|
101
|
+
Database
|
|
102
|
+
</CardTitle>
|
|
103
|
+
<CardDescription>{records.toLocaleString()} records across {tables.length} {tables.length === 1 ? "table" : "tables"}.</CardDescription>
|
|
104
|
+
</CardHeader>
|
|
105
|
+
<CardContent className="flex flex-col">
|
|
106
|
+
{tables.length === 0 && <p className="text-sm text-muted-foreground">No resources yet.</p>}
|
|
107
|
+
{tables.map(({ resource, total }) => (
|
|
108
|
+
<div key={resource.name} className="flex items-center justify-between gap-3 border-b py-2.5 text-sm last:border-b-0 last:pb-0">
|
|
109
|
+
<Link href={resourcePath(resource)} className="hover:underline">
|
|
110
|
+
{resource.pluralLabel}
|
|
111
|
+
</Link>
|
|
112
|
+
<span className="tabular-nums text-muted-foreground">{total.toLocaleString()}</span>
|
|
113
|
+
</div>
|
|
114
|
+
))}
|
|
115
|
+
</CardContent>
|
|
116
|
+
</Card>
|
|
117
|
+
|
|
118
|
+
<Card>
|
|
119
|
+
<CardHeader>
|
|
120
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
121
|
+
<HistoryIcon className="size-4" />
|
|
122
|
+
Recent changes
|
|
123
|
+
</CardTitle>
|
|
124
|
+
<CardDescription>Every write through the dashboard, newest first.</CardDescription>
|
|
125
|
+
</CardHeader>
|
|
126
|
+
<CardContent>
|
|
127
|
+
{audit.length === 0 ? (
|
|
128
|
+
<Empty>
|
|
129
|
+
<EmptyHeader>
|
|
130
|
+
<EmptyTitle>Nothing yet</EmptyTitle>
|
|
131
|
+
<EmptyDescription>Creates, edits, deletes and imports show up here as they happen.</EmptyDescription>
|
|
132
|
+
</EmptyHeader>
|
|
133
|
+
</Empty>
|
|
134
|
+
) : (
|
|
135
|
+
<div className="overflow-x-auto">
|
|
136
|
+
<Table>
|
|
137
|
+
<TableHeader>
|
|
138
|
+
<TableRow>
|
|
139
|
+
<TableHead>What</TableHead>
|
|
140
|
+
<TableHead>Record</TableHead>
|
|
141
|
+
<TableHead>Who</TableHead>
|
|
142
|
+
<TableHead className="text-right">When</TableHead>
|
|
143
|
+
</TableRow>
|
|
144
|
+
</TableHeader>
|
|
145
|
+
<TableBody>
|
|
146
|
+
{audit.map((entry) => (
|
|
147
|
+
<TableRow key={entry.id}>
|
|
148
|
+
<TableCell>
|
|
149
|
+
<Badge variant={ACTION_TONE[entry.action] ?? "secondary"}>{entry.action}</Badge>
|
|
150
|
+
<span className="ml-2 text-muted-foreground">{entry.resource}</span>
|
|
151
|
+
</TableCell>
|
|
152
|
+
<TableCell className="max-w-56 truncate">{entry.recordLabel ?? describeBulk(entry.changes)}</TableCell>
|
|
153
|
+
<TableCell className="max-w-48 truncate text-muted-foreground">{entry.userEmail ?? "—"}</TableCell>
|
|
154
|
+
<TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">{when(entry.createdAt)}</TableCell>
|
|
155
|
+
</TableRow>
|
|
156
|
+
))}
|
|
157
|
+
</TableBody>
|
|
158
|
+
</Table>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</CardContent>
|
|
162
|
+
</Card>
|
|
163
|
+
</div>
|
|
164
|
+
</>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Bulk actions have no single record, so say how many they touched. */
|
|
169
|
+
function describeBulk(changes: Record<string, unknown> | null): string {
|
|
170
|
+
if (!changes) return "—";
|
|
171
|
+
if (typeof changes.deleted === "number") return `${changes.deleted} deleted`;
|
|
172
|
+
if (typeof changes.created === "number") return `${changes.created} imported`;
|
|
173
|
+
return "—";
|
|
174
|
+
}
|