@flaredev/cli 0.4.0 → 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.
- package/dist/index.js +851 -405
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +38 -5
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- package/templates/app/app/page.tsx +10 -0
- package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
- package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
- package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +2 -2
- package/templates/app/components/dashboard/record-actions.tsx +9 -9
- package/templates/app/components/dashboard/record-detail.tsx +6 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +261 -165
- package/templates/app/components/dashboard/resource-icon.tsx +87 -85
- package/templates/app/components/dashboard/resource-stats.tsx +10 -9
- package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +12 -9
- package/templates/app/components/dashboard/row-actions.tsx +5 -5
- package/templates/app/lib/costs.ts +138 -0
- package/templates/app/lib/dashboard-nav.ts +5 -1
- package/templates/app/lib/dashboard.ts +66 -61
- package/templates/app/lib/number.ts +33 -0
- package/templates/app/lib/site.ts +56 -56
- package/templates/app/lib/suggest.ts +35 -0
- package/templates/app/lib/usage.ts +32 -0
- package/templates/locks/package-lock.json +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- package/templates/locks/yarn.lock +3 -3
- package/templates/next/.env.example +21 -0
- package/templates/next/README.md +16 -0
- package/templates/next/app/api/health/route.ts +7 -0
- package/templates/next/app/dashboard/account/password/page.tsx +19 -0
- package/templates/next/app/dashboard/account/security/page.tsx +31 -0
- package/templates/next/app/dashboard/actions.ts +277 -0
- package/templates/next/app/dashboard/layout.tsx +64 -0
- package/templates/next/app/dashboard/page.tsx +127 -0
- package/templates/next/components/dashboard/resource-chart.tsx +86 -0
- package/templates/next/lib/analytics.ts +71 -0
- package/templates/next/lib/audit.ts +80 -0
- package/templates/next/lib/auth-emails.ts +92 -0
- package/templates/next/lib/auth.ts +156 -0
- package/templates/next/lib/dashboard.ts +161 -0
- package/templates/next/lib/db.ts +24 -0
- package/templates/next/lib/mail.ts +35 -0
- package/templates/next/lib/realtime.ts +23 -0
- package/templates/next/lib/redis.ts +17 -0
- package/templates/next/lib/storage.ts +57 -0
- package/templates/next/lib/theme.ts +83 -0
- package/templates/next/lib/views.ts +23 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/prisma/schema/base.prisma +162 -0
- package/templates/next/prisma.config.ts +15 -0
- package/templates/next/tsconfig.json +24 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { storedFields, optionLabel, type Resource } from "@flaredev/core";
|
|
2
|
-
import {
|
|
2
|
+
import { resourceStats, trend } from "@/lib/dashboard";
|
|
3
3
|
import { resourceIcon } from "./resource-icon";
|
|
4
4
|
import { StatCards, type Stat } from "./stat-card";
|
|
5
5
|
|
|
@@ -11,7 +11,10 @@ import { StatCards, type Stat } from "./stat-card";
|
|
|
11
11
|
* in practice is the status of the thing — a deal's stage, an invoice's state.
|
|
12
12
|
*/
|
|
13
13
|
export async function ResourceStats({ resource }: { resource: Resource }) {
|
|
14
|
-
|
|
14
|
+
// The status split is asked for up front so the totals, the trend and the split all
|
|
15
|
+
// come from the one grouped query rather than three.
|
|
16
|
+
const statusField = storedFields(resource).find(([, def]) => def.kind === "enum" && def.filterable !== false);
|
|
17
|
+
const { total, current, previous, values } = await resourceStats(resource.name, statusField?.[0]);
|
|
15
18
|
|
|
16
19
|
const stats: Stat[] = [
|
|
17
20
|
{
|
|
@@ -21,20 +24,18 @@ export async function ResourceStats({ resource }: { resource: Resource }) {
|
|
|
21
24
|
},
|
|
22
25
|
{
|
|
23
26
|
label: "New this week",
|
|
24
|
-
value:
|
|
25
|
-
change: trend(
|
|
26
|
-
hint: `${
|
|
27
|
+
value: current,
|
|
28
|
+
change: trend(current, previous),
|
|
29
|
+
hint: `${previous.toLocaleString()} the week before`,
|
|
27
30
|
},
|
|
28
31
|
];
|
|
29
32
|
|
|
30
|
-
const statusField = storedFields(resource).find(([, def]) => def.kind === "enum" && def.filterable !== false);
|
|
31
33
|
if (statusField && total > 0) {
|
|
32
|
-
const [
|
|
34
|
+
const [, def] = statusField;
|
|
33
35
|
if (def.kind === "enum") {
|
|
34
|
-
const counts = await valueCounts(resource.name, key);
|
|
35
36
|
// Two options at most: a strip of eight cards is a wall, not a summary.
|
|
36
37
|
for (const option of def.options.slice(0, 2)) {
|
|
37
|
-
const value =
|
|
38
|
+
const value = values[option] ?? 0;
|
|
38
39
|
stats.push({
|
|
39
40
|
label: optionLabel(def, option),
|
|
40
41
|
value,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
4
4
|
import { useState, type FormEvent } from "react";
|
|
5
5
|
import { SearchIcon, XIcon } from "lucide-react";
|
|
6
|
-
import { optionLabel, storedFields, type
|
|
6
|
+
import { optionLabel, storedFields, type ClientResource } from "@flaredev/core";
|
|
7
7
|
import { Button } from "@/components/ui/button";
|
|
8
8
|
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
|
9
9
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
@@ -12,7 +12,7 @@ import { hrefWith } from "./query";
|
|
|
12
12
|
const ALL = "__all__";
|
|
13
13
|
|
|
14
14
|
/** Search box and enum/boolean filters for ResourceTable; all state goes into the URL. */
|
|
15
|
-
export function ResourceTableToolbar({ resource }: { resource:
|
|
15
|
+
export function ResourceTableToolbar({ resource }: { resource: ClientResource }) {
|
|
16
16
|
const router = useRouter();
|
|
17
17
|
const pathname = usePathname();
|
|
18
18
|
const params = new URLSearchParams(useSearchParams().toString());
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
|
|
3
|
-
import { formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
|
|
3
|
+
import { clientResource, formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
|
|
4
4
|
import { isSortable } from "@flaredev/core/server";
|
|
5
5
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
6
6
|
import { Badge } from "@/components/ui/badge";
|
|
@@ -26,7 +26,7 @@ import type { RelationMeta } from "./fields/field-widget";
|
|
|
26
26
|
import { ImportDialog } from "./import-dialog";
|
|
27
27
|
import { LocalTime } from "./local-time";
|
|
28
28
|
import { hrefWith, toSearchParams, type SearchParams } from "./query";
|
|
29
|
-
import { NewRecordButton } from "./resource-form-
|
|
29
|
+
import { NewRecordButton } from "./resource-form-sheet";
|
|
30
30
|
import { ResourceTableToolbar } from "./resource-table-toolbar";
|
|
31
31
|
import { SaveViewButton } from "./save-view-button";
|
|
32
32
|
import { RowActions } from "./row-actions";
|
|
@@ -44,6 +44,9 @@ const NUMERIC = new Set(["int", "float"]);
|
|
|
44
44
|
*/
|
|
45
45
|
export async function ResourceTable({ resource, searchParams }: { resource: Resource; searchParams: SearchParams }) {
|
|
46
46
|
await requireAccess(resource, "read");
|
|
47
|
+
// Hooks and computed values are functions, and a function can't be sent to the browser.
|
|
48
|
+
// This is the same resource without them, for the client components below.
|
|
49
|
+
const forClient = clientResource(resource);
|
|
47
50
|
const basePath = resourcePath(resource);
|
|
48
51
|
const permissions = await adminPermissions(resource.name);
|
|
49
52
|
const params = toSearchParams(searchParams);
|
|
@@ -87,7 +90,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
87
90
|
relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
const
|
|
93
|
+
const overlayForms = site.dashboard.forms === "sheet";
|
|
91
94
|
const rowIds = rows.map((row) => String(row.id));
|
|
92
95
|
const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
|
|
93
96
|
|
|
@@ -102,15 +105,15 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
102
105
|
<TableSelection ids={rowIds}>
|
|
103
106
|
<div className="flex flex-col gap-4">
|
|
104
107
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
105
|
-
<ResourceTableToolbar resource={
|
|
108
|
+
<ResourceTableToolbar resource={forClient} />
|
|
106
109
|
<div className="flex flex-wrap items-center gap-2">
|
|
107
110
|
<SaveViewButton resourceName={resource.name} label={resource.pluralLabel} />
|
|
108
111
|
<ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
|
|
109
112
|
<ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
|
|
110
|
-
{permissions.create && <ImportDialog resource={
|
|
113
|
+
{permissions.create && <ImportDialog resource={forClient} />}
|
|
111
114
|
{permissions.create &&
|
|
112
|
-
(
|
|
113
|
-
<NewRecordButton resource={
|
|
115
|
+
(overlayForms ? (
|
|
116
|
+
<NewRecordButton resource={forClient} relations={relations} listHref={basePath} />
|
|
114
117
|
) : (
|
|
115
118
|
<Button asChild>
|
|
116
119
|
<Link href={resourcePath(resource, "new")}>
|
|
@@ -281,9 +284,9 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
281
284
|
})}
|
|
282
285
|
<TableCell className="text-right">
|
|
283
286
|
<RowActions
|
|
284
|
-
resource={
|
|
287
|
+
resource={forClient}
|
|
285
288
|
id={id}
|
|
286
|
-
record={
|
|
289
|
+
record={overlayForms ? row : undefined}
|
|
287
290
|
relations={relations}
|
|
288
291
|
listHref={basePath}
|
|
289
292
|
editHref={resourcePath(resource, id, "edit")}
|
|
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|
|
5
5
|
import { useState, useTransition } from "react";
|
|
6
6
|
import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
|
|
7
7
|
import { toast } from "sonner";
|
|
8
|
-
import type {
|
|
8
|
+
import type { ClientResource } from "@flaredev/core";
|
|
9
9
|
import { deleteRecordAction } from "@/app/dashboard/actions";
|
|
10
10
|
import {
|
|
11
11
|
AlertDialog,
|
|
@@ -26,11 +26,11 @@ import {
|
|
|
26
26
|
DropdownMenuTrigger,
|
|
27
27
|
} from "@/components/ui/dropdown-menu";
|
|
28
28
|
import { Spinner } from "@/components/ui/spinner";
|
|
29
|
-
import {
|
|
29
|
+
import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Edit and delete for one row. Edit opens a dialog when the row's record came with it
|
|
33
|
-
* (site.dashboard.forms is "
|
|
33
|
+
* (site.dashboard.forms is "sheet"), and otherwise goes to the form page.
|
|
34
34
|
*/
|
|
35
35
|
export function RowActions({
|
|
36
36
|
resource,
|
|
@@ -43,7 +43,7 @@ export function RowActions({
|
|
|
43
43
|
canUpdate = true,
|
|
44
44
|
canDelete = true,
|
|
45
45
|
}: {
|
|
46
|
-
resource:
|
|
46
|
+
resource: ClientResource;
|
|
47
47
|
id: string;
|
|
48
48
|
/** The record itself, when the form opens in a dialog. */
|
|
49
49
|
record?: Record<string, unknown>;
|
|
@@ -124,7 +124,7 @@ export function RowActions({
|
|
|
124
124
|
</DropdownMenu>
|
|
125
125
|
|
|
126
126
|
{record && canUpdate && (
|
|
127
|
-
<
|
|
127
|
+
<ResourceFormSheet
|
|
128
128
|
resource={resource}
|
|
129
129
|
relations={relations}
|
|
130
130
|
listHref={listHref}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Cloudflare charges, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Checked against Cloudflare's pricing pages on 24 September 2026. They change; when
|
|
5
|
+
* they do, change them here rather than in the page that displays them. The docs cover
|
|
6
|
+
* the reasoning behind these numbers:
|
|
7
|
+
* https://flare-docs.codetotech.com/guides/costs/
|
|
8
|
+
*
|
|
9
|
+
* Nothing here is a bill. The app can see how much it stores; it cannot see how many
|
|
10
|
+
* requests it served or how many rows it read — those live in Cloudflare's analytics,
|
|
11
|
+
* behind an API token this app deliberately doesn't hold. So this is a model, and the
|
|
12
|
+
* page that shows it says so.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The Workers Paid subscription, which is the whole bill for most apps. */
|
|
16
|
+
export const SUBSCRIPTION_USD = 5;
|
|
17
|
+
|
|
18
|
+
/** Free plan allowances. Daily where Cloudflare counts them daily. */
|
|
19
|
+
export const FREE = {
|
|
20
|
+
requestsPerDay: 100_000,
|
|
21
|
+
/** CPU milliseconds per invocation — the one that bites a server-rendered app. */
|
|
22
|
+
cpuMsPerRequest: 10,
|
|
23
|
+
d1RowsReadPerDay: 5_000_000,
|
|
24
|
+
d1RowsWrittenPerDay: 100_000,
|
|
25
|
+
d1StorageGb: 5,
|
|
26
|
+
r2StorageGb: 10,
|
|
27
|
+
kvReadsPerDay: 100_000,
|
|
28
|
+
/** A thousand a day, and every cache miss is a write. */
|
|
29
|
+
kvWritesPerDay: 1_000,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** Paid plan: what's included each month, then the rate past it. */
|
|
33
|
+
export const PAID = {
|
|
34
|
+
requests: { included: 10_000_000, perMillionUsd: 0.3 },
|
|
35
|
+
cpuMs: { included: 30_000_000, perMillionUsd: 0.02 },
|
|
36
|
+
d1RowsRead: { included: 25_000_000_000, perMillionUsd: 0.001 },
|
|
37
|
+
d1RowsWritten: { included: 50_000_000, perMillionUsd: 1 },
|
|
38
|
+
d1StorageGb: { included: 5, perGbMonthUsd: 0.75 },
|
|
39
|
+
r2StorageGb: { included: 10, perGbMonthUsd: 0.015 },
|
|
40
|
+
kvReads: { included: 10_000_000, perMillionUsd: 0.5 },
|
|
41
|
+
kvWrites: { included: 1_000_000, perMillionUsd: 5 },
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What one page view costs, measured on the example shop.
|
|
46
|
+
*
|
|
47
|
+
* A dashboard page runs the stats query, the chart, the table's page and its capped
|
|
48
|
+
* count, plus one lookup per relation shown — four to six queries, reading around a
|
|
49
|
+
* hundred rows between them once the indexes are doing their job. A page on the public
|
|
50
|
+
* side is lighter. These are the assumptions the estimate below rests on, and they are
|
|
51
|
+
* written down so you can argue with them.
|
|
52
|
+
*/
|
|
53
|
+
export const PER_VIEW = {
|
|
54
|
+
d1RowsRead: 100,
|
|
55
|
+
d1RowsWritten: 0.2,
|
|
56
|
+
kvReads: 4,
|
|
57
|
+
/** Cache misses are rarer than hits; most views read a warm entry. */
|
|
58
|
+
kvWrites: 0.3,
|
|
59
|
+
cpuMs: 25,
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
export interface Estimate {
|
|
63
|
+
monthlyViews: number;
|
|
64
|
+
requests: number;
|
|
65
|
+
d1RowsRead: number;
|
|
66
|
+
d1RowsWritten: number;
|
|
67
|
+
kvReads: number;
|
|
68
|
+
kvWrites: number;
|
|
69
|
+
cpuMs: number;
|
|
70
|
+
/** Dollars on top of the subscription. */
|
|
71
|
+
overageUsd: number;
|
|
72
|
+
totalUsd: number;
|
|
73
|
+
/** Which free-plan allowance this traffic would break first, if any. */
|
|
74
|
+
freePlanVerdict: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const over = (used: number, included: number, perMillionUsd: number) =>
|
|
78
|
+
used <= included ? 0 : ((used - included) / 1_000_000) * perMillionUsd;
|
|
79
|
+
|
|
80
|
+
/** What a month at this traffic would cost, and whether the free plan could carry it. */
|
|
81
|
+
export function estimate(monthlyViews: number, storedGb: { d1: number; r2: number }): Estimate {
|
|
82
|
+
const requests = monthlyViews;
|
|
83
|
+
const d1RowsRead = monthlyViews * PER_VIEW.d1RowsRead;
|
|
84
|
+
const d1RowsWritten = monthlyViews * PER_VIEW.d1RowsWritten;
|
|
85
|
+
const kvReads = monthlyViews * PER_VIEW.kvReads;
|
|
86
|
+
const kvWrites = monthlyViews * PER_VIEW.kvWrites;
|
|
87
|
+
const cpuMs = monthlyViews * PER_VIEW.cpuMs;
|
|
88
|
+
|
|
89
|
+
const overageUsd =
|
|
90
|
+
over(requests, PAID.requests.included, PAID.requests.perMillionUsd) +
|
|
91
|
+
over(cpuMs, PAID.cpuMs.included, PAID.cpuMs.perMillionUsd) +
|
|
92
|
+
over(d1RowsRead, PAID.d1RowsRead.included, PAID.d1RowsRead.perMillionUsd) +
|
|
93
|
+
over(d1RowsWritten, PAID.d1RowsWritten.included, PAID.d1RowsWritten.perMillionUsd) +
|
|
94
|
+
over(kvReads, PAID.kvReads.included, PAID.kvReads.perMillionUsd) +
|
|
95
|
+
over(kvWrites, PAID.kvWrites.included, PAID.kvWrites.perMillionUsd) +
|
|
96
|
+
Math.max(0, storedGb.d1 - PAID.d1StorageGb.included) * PAID.d1StorageGb.perGbMonthUsd +
|
|
97
|
+
Math.max(0, storedGb.r2 - PAID.r2StorageGb.included) * PAID.r2StorageGb.perGbMonthUsd;
|
|
98
|
+
|
|
99
|
+
const daily = monthlyViews / 30;
|
|
100
|
+
const broken: string[] = [];
|
|
101
|
+
if (daily > FREE.requestsPerDay) broken.push("requests");
|
|
102
|
+
if (daily * PER_VIEW.d1RowsRead > FREE.d1RowsReadPerDay) broken.push("D1 rows read");
|
|
103
|
+
if (daily * PER_VIEW.kvWrites > FREE.kvWritesPerDay) broken.push("KV writes");
|
|
104
|
+
if (storedGb.r2 > FREE.r2StorageGb) broken.push("R2 storage");
|
|
105
|
+
if (storedGb.d1 > FREE.d1StorageGb) broken.push("D1 storage");
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
monthlyViews,
|
|
109
|
+
requests,
|
|
110
|
+
d1RowsRead,
|
|
111
|
+
d1RowsWritten,
|
|
112
|
+
kvReads,
|
|
113
|
+
kvWrites,
|
|
114
|
+
cpuMs,
|
|
115
|
+
overageUsd,
|
|
116
|
+
totalUsd: SUBSCRIPTION_USD + overageUsd,
|
|
117
|
+
freePlanVerdict: broken.length === 0
|
|
118
|
+
? "Within the free allowances — but see the CPU note below."
|
|
119
|
+
: `Past the free plan on ${broken.join(", ")}.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** "1.2M", "34k", "912" — counts that have to fit on a card. */
|
|
124
|
+
export function compact(value: number): string {
|
|
125
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
|
|
126
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
|
127
|
+
return Math.round(value).toLocaleString();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** "$5.00", "$12.34". */
|
|
131
|
+
export const usd = (value: number) => `$${value.toFixed(2)}`;
|
|
132
|
+
|
|
133
|
+
/** "2.4 GB", "812 MB". */
|
|
134
|
+
export function gb(value: number): string {
|
|
135
|
+
if (value >= 1) return `${value.toFixed(value >= 10 ? 0 : 2)} GB`;
|
|
136
|
+
const mb = value * 1024;
|
|
137
|
+
return mb >= 1 ? `${mb.toFixed(mb >= 10 ? 0 : 1)} MB` : `${Math.round(mb * 1024)} KB`;
|
|
138
|
+
}
|
|
@@ -12,4 +12,8 @@ export const generatedDashboardLinks: DashboardLink[] = [];
|
|
|
12
12
|
// generated:end
|
|
13
13
|
|
|
14
14
|
/** Links shown under "Platform" in the dashboard sidebar. */
|
|
15
|
-
export const dashboardLinks: DashboardLink[] = [
|
|
15
|
+
export const dashboardLinks: DashboardLink[] = [
|
|
16
|
+
{ label: "Costs", href: "/dashboard/costs", icon: "wallet" },
|
|
17
|
+
...generatedDashboardLinks,
|
|
18
|
+
{ label: "API reference", href: "/api/reference", icon: "book" },
|
|
19
|
+
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { headers } from "next/headers";
|
|
2
2
|
import { notFound, redirect } from "next/navigation";
|
|
3
3
|
import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
|
|
4
|
-
import {
|
|
4
|
+
import { count, sql } from "drizzle-orm";
|
|
5
5
|
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
6
6
|
import { createResourceStore, type ResourceStore } from "@flaredev/core/server";
|
|
7
7
|
import { getDb } from "@/db";
|
|
@@ -73,74 +73,79 @@ export function dashboardStore(name: string): ResourceStore {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* Every number the stats above a resource's table need, in one query.
|
|
77
|
+
*
|
|
78
|
+
* The total, how many arrived in the last week and the week before, and how many hold
|
|
79
|
+
* each value of a status field — all of it from a single grouped scan, summed up here.
|
|
80
|
+
* It was four queries and three cache entries; on the free plan the cache entries were
|
|
81
|
+
* the expensive half, because Workers KV allows a thousand writes a day and every write
|
|
82
|
+
* to a record drops the entries tagged for it.
|
|
83
|
+
*
|
|
84
|
+
* Cached under the resource's tag, so a write through the admin or the API drops it on
|
|
85
|
+
* the way out and the next view recomputes it.
|
|
79
86
|
*/
|
|
80
|
-
export
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const [row] = await getDb().select({ total: count() }).from(entry.table);
|
|
88
|
-
return row?.total ?? 0;
|
|
89
|
-
},
|
|
90
|
-
["flare", "record-count"],
|
|
91
|
-
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
92
|
-
);
|
|
93
|
-
return counted(name);
|
|
87
|
+
export interface ResourceStats {
|
|
88
|
+
total: number;
|
|
89
|
+
/** Created in the last `days` days, and in the `days` before that. */
|
|
90
|
+
current: number;
|
|
91
|
+
previous: number;
|
|
92
|
+
/** Rows per value of the grouped field, when one was asked for. */
|
|
93
|
+
values: Record<string, number>;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*/
|
|
100
|
-
export function valueCounts(name: string, field: string): Promise<Record<string, number>> {
|
|
101
|
-
const counted = cached(
|
|
102
|
-
async (resourceName: string, key: string) => {
|
|
96
|
+
export function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
|
|
97
|
+
const read = cached(
|
|
98
|
+
async (resourceName: string, key: string | undefined, windowDays: number): Promise<ResourceStats> => {
|
|
103
99
|
const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
|
|
104
|
-
|
|
105
|
-
if (!entry || !column) return {};
|
|
106
|
-
const rows = (await getDb()
|
|
107
|
-
.select({ value: column as never, total: count() })
|
|
108
|
-
.from(entry.table)
|
|
109
|
-
.groupBy(column as never)) as { value: unknown; total: number }[];
|
|
110
|
-
return Object.fromEntries(rows.map((row) => [String(row.value ?? ""), row.total]));
|
|
111
|
-
},
|
|
112
|
-
["flare", "value-counts"],
|
|
113
|
-
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
114
|
-
);
|
|
115
|
-
return counted(name, field);
|
|
116
|
-
}
|
|
100
|
+
if (!entry) return { total: 0, current: 0, previous: 0, values: {} };
|
|
117
101
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const counts = cached(
|
|
124
|
-
async (resourceName: string, windowDays: number) => {
|
|
125
|
-
const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
|
|
126
|
-
const createdAt = entry ? (entry.table as unknown as Record<string, unknown>).createdAt : undefined;
|
|
127
|
-
// A resource whose table has no createdAt (a custom one) simply has no trend.
|
|
128
|
-
if (!entry || !createdAt) return { current: 0, previous: 0 };
|
|
102
|
+
const columns = entry.table as unknown as Record<string, unknown>;
|
|
103
|
+
const createdAt = columns.createdAt;
|
|
104
|
+
const grouped = key ? columns[key] : undefined;
|
|
105
|
+
// Milliseconds, not Dates: these go into a raw `sql` fragment, which doesn't pass
|
|
106
|
+
// through the column's mapper, and D1 refuses to bind an object.
|
|
129
107
|
const window = windowDays * 86_400_000;
|
|
130
|
-
const since =
|
|
131
|
-
const before =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
108
|
+
const since = Date.now() - window;
|
|
109
|
+
const before = Date.now() - window * 2;
|
|
110
|
+
|
|
111
|
+
// A resource whose table has no createdAt (a custom one) simply has no trend.
|
|
112
|
+
const windows = createdAt
|
|
113
|
+
? {
|
|
114
|
+
current: sql<number>`sum(case when ${createdAt as never} >= ${since} then 1 else 0 end)`,
|
|
115
|
+
previous: sql<number>`sum(case when ${createdAt as never} >= ${before} and ${createdAt as never} < ${since} then 1 else 0 end)`,
|
|
116
|
+
}
|
|
117
|
+
: { current: sql<number>`0`, previous: sql<number>`0` };
|
|
118
|
+
|
|
119
|
+
const select: Record<string, unknown> = { total: count(), ...windows };
|
|
120
|
+
if (grouped) select.value = grouped;
|
|
121
|
+
const query = getDb()
|
|
122
|
+
.select(select as never)
|
|
123
|
+
.from(entry.table);
|
|
124
|
+
const rows = (await (grouped ? query.groupBy(grouped as never) : query)) as {
|
|
125
|
+
total: number;
|
|
126
|
+
current: number;
|
|
127
|
+
previous: number;
|
|
128
|
+
value?: unknown;
|
|
129
|
+
}[];
|
|
130
|
+
|
|
131
|
+
const stats: ResourceStats = { total: 0, current: 0, previous: 0, values: {} };
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
stats.total += Number(row.total ?? 0);
|
|
134
|
+
stats.current += Number(row.current ?? 0);
|
|
135
|
+
stats.previous += Number(row.previous ?? 0);
|
|
136
|
+
if (grouped) stats.values[String(row.value ?? "")] = Number(row.total ?? 0);
|
|
137
|
+
}
|
|
138
|
+
return stats;
|
|
139
139
|
},
|
|
140
|
-
["flare", "
|
|
141
|
-
{ tags: [resourceTag(name)], revalidate: TTL.
|
|
140
|
+
["flare", "resource-stats"],
|
|
141
|
+
{ tags: [resourceTag(name)], revalidate: TTL.medium },
|
|
142
142
|
);
|
|
143
|
-
return
|
|
143
|
+
return read(name, field, days);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How many records a resource has. One field of {@link resourceStats}. */
|
|
147
|
+
export async function recordCount(name: string): Promise<number> {
|
|
148
|
+
return (await resourceStats(name)).total;
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
/** Percentage change between two periods, or undefined when there's nothing to compare. */
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numbers as people read and type them.
|
|
3
|
+
*
|
|
4
|
+
* A price typed as 2000 is easier to check at a glance as 2,000, but the form and the
|
|
5
|
+
* API only ever see the plain number — the grouping is put on for display and taken off
|
|
6
|
+
* again on every keystroke, so nothing downstream has to unpick a comma.
|
|
7
|
+
*
|
|
8
|
+
* Kept free of server imports: the field widgets that use it run in the browser.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** "2000.5" → "2,000.5". A half-typed "1," or "12." is left alone so typing isn't fought. */
|
|
12
|
+
export function groupDigits(value: string): string {
|
|
13
|
+
if (!value) return "";
|
|
14
|
+
const negative = value.startsWith("-");
|
|
15
|
+
const body = negative ? value.slice(1) : value;
|
|
16
|
+
const [whole = "", ...rest] = body.split(".");
|
|
17
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
18
|
+
// Keep a trailing "." while someone is still typing the decimals.
|
|
19
|
+
const decimals = rest.length > 0 ? `.${rest.join("")}` : "";
|
|
20
|
+
return `${negative ? "-" : ""}${grouped}${decimals}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* "2,000.50" → "2000.50". Everything that isn't a digit goes, along with any minus sign
|
|
25
|
+
* that isn't leading and any second decimal point.
|
|
26
|
+
*/
|
|
27
|
+
export function ungroup(value: string, allowDecimal: boolean): string {
|
|
28
|
+
const negative = value.trimStart().startsWith("-");
|
|
29
|
+
const digits = value.replace(/[^\d.]/g, "");
|
|
30
|
+
const [whole = "", ...rest] = digits.split(".");
|
|
31
|
+
const body = allowDecimal && rest.length > 0 ? `${whole}.${rest.join("")}` : whole;
|
|
32
|
+
return `${negative && body ? "-" : ""}${body}`;
|
|
33
|
+
}
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
import type { ThemeName } from "./theme";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* What the site says about itself: the home page, the sign-in screens' side panels and
|
|
5
|
-
* the page titles read from here. Edit freely.
|
|
6
|
-
*/
|
|
7
|
-
export const site = {
|
|
8
|
-
name: "__APP_NAME__",
|
|
9
|
-
tagline: "The fastest way to run your business",
|
|
10
|
-
description: "__APP_NAME__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
|
|
11
|
-
/**
|
|
12
|
-
* The look of the whole app: sign-in screens, dashboard, admin and home page.
|
|
13
|
-
* One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
|
|
14
|
-
* overrides it when the app is built or run with `flare dev`.
|
|
15
|
-
*/
|
|
16
|
-
theme: "__THEME__" as ThemeName,
|
|
17
|
-
/** How the dashboard behaves. */
|
|
18
|
-
dashboard: {
|
|
19
|
-
/**
|
|
20
|
-
* Where a record is created and edited: in a dialog over the list ("modal", the
|
|
21
|
-
* default, which keeps the list, its filters and your place in it), or on its own
|
|
22
|
-
* page ("page", better for long forms).
|
|
23
|
-
*/
|
|
24
|
-
forms: "
|
|
25
|
-
},
|
|
26
|
-
/** The small pill above the home page headline; set to null to hide it. */
|
|
27
|
-
announcement: { label: "New", text: "Passkeys and two-factor sign-in are here", href: "/sign-up" } as { label: string; text: string; href: string } | null,
|
|
28
|
-
/** The home page's feature grid (icon names from lucide.dev). */
|
|
29
|
-
features: [
|
|
30
|
-
{ icon: "zap", title: "Fast everywhere", description: "Served from the edge in every region, so pages load in milliseconds wherever your customers are." },
|
|
31
|
-
{ icon: "shield-check", title: "Secure by default", description: "Passkeys, two-factor sign-in, roles and rate limits, switched on from day one." },
|
|
32
|
-
{ icon: "layout-dashboard", title: "One place for your data", description: "Every record your team keeps, with search, filters and an admin built in." },
|
|
33
|
-
{ icon: "users", title: "Built for teams", description: "Invite your team, give each person the right access, and see who changed what." },
|
|
34
|
-
{ icon: "plug", title: "Connects to your tools", description: "A documented API for every record, ready for your scripts and integrations." },
|
|
35
|
-
{ icon: "credit-card", title: "Billing when you need it", description: "Plans, checkout and invoices through Stripe, without building a billing system." },
|
|
36
|
-
],
|
|
37
|
-
/** Placeholder names for the logo strip: replace them with your customers (or remove the strip). */
|
|
38
|
-
customers: ["Acme", "Globex", "Initech", "Vandelay", "Northwind", "Brightline"],
|
|
39
|
-
/** Shown beside the sign-up form. */
|
|
40
|
-
highlights: [
|
|
41
|
-
"Set up in minutes, with nothing to install",
|
|
42
|
-
"Secure sign-in with passkeys and two-factor",
|
|
43
|
-
"Your data stays yours: export it any time",
|
|
44
|
-
],
|
|
45
|
-
/** Shown on the sign-in screen of themes with a quote panel. */
|
|
46
|
-
testimonial: {
|
|
47
|
-
quote: "We moved our whole workflow over in an afternoon. The team hasn't looked back.",
|
|
48
|
-
author: "Alex Rivera",
|
|
49
|
-
role: "Head of Operations",
|
|
50
|
-
},
|
|
51
|
-
links: {
|
|
52
|
-
terms: "/terms",
|
|
53
|
-
privacy: "/privacy",
|
|
54
|
-
support: "mailto:support@example.com",
|
|
55
|
-
},
|
|
56
|
-
};
|
|
1
|
+
import type { ThemeName } from "./theme";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the site says about itself: the home page, the sign-in screens' side panels and
|
|
5
|
+
* the page titles read from here. Edit freely.
|
|
6
|
+
*/
|
|
7
|
+
export const site = {
|
|
8
|
+
name: "__APP_NAME__",
|
|
9
|
+
tagline: "The fastest way to run your business",
|
|
10
|
+
description: "__APP_NAME__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
|
|
11
|
+
/**
|
|
12
|
+
* The look of the whole app: sign-in screens, dashboard, admin and home page.
|
|
13
|
+
* One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
|
|
14
|
+
* overrides it when the app is built or run with `flare dev`.
|
|
15
|
+
*/
|
|
16
|
+
theme: "__THEME__" as ThemeName,
|
|
17
|
+
/** How the dashboard behaves. */
|
|
18
|
+
dashboard: {
|
|
19
|
+
/**
|
|
20
|
+
* Where a record is created and edited: in a dialog over the list ("modal", the
|
|
21
|
+
* default, which keeps the list, its filters and your place in it), or on its own
|
|
22
|
+
* page ("page", better for long forms).
|
|
23
|
+
*/
|
|
24
|
+
forms: "sheet" as "sheet" | "page",
|
|
25
|
+
},
|
|
26
|
+
/** The small pill above the home page headline; set to null to hide it. */
|
|
27
|
+
announcement: { label: "New", text: "Passkeys and two-factor sign-in are here", href: "/sign-up" } as { label: string; text: string; href: string } | null,
|
|
28
|
+
/** The home page's feature grid (icon names from lucide.dev). */
|
|
29
|
+
features: [
|
|
30
|
+
{ icon: "zap", title: "Fast everywhere", description: "Served from the edge in every region, so pages load in milliseconds wherever your customers are." },
|
|
31
|
+
{ icon: "shield-check", title: "Secure by default", description: "Passkeys, two-factor sign-in, roles and rate limits, switched on from day one." },
|
|
32
|
+
{ icon: "layout-dashboard", title: "One place for your data", description: "Every record your team keeps, with search, filters and an admin built in." },
|
|
33
|
+
{ icon: "users", title: "Built for teams", description: "Invite your team, give each person the right access, and see who changed what." },
|
|
34
|
+
{ icon: "plug", title: "Connects to your tools", description: "A documented API for every record, ready for your scripts and integrations." },
|
|
35
|
+
{ icon: "credit-card", title: "Billing when you need it", description: "Plans, checkout and invoices through Stripe, without building a billing system." },
|
|
36
|
+
],
|
|
37
|
+
/** Placeholder names for the logo strip: replace them with your customers (or remove the strip). */
|
|
38
|
+
customers: ["Acme", "Globex", "Initech", "Vandelay", "Northwind", "Brightline"],
|
|
39
|
+
/** Shown beside the sign-up form. */
|
|
40
|
+
highlights: [
|
|
41
|
+
"Set up in minutes, with nothing to install",
|
|
42
|
+
"Secure sign-in with passkeys and two-factor",
|
|
43
|
+
"Your data stays yours: export it any time",
|
|
44
|
+
],
|
|
45
|
+
/** Shown on the sign-in screen of themes with a quote panel. */
|
|
46
|
+
testimonial: {
|
|
47
|
+
quote: "We moved our whole workflow over in an afternoon. The team hasn't looked back.",
|
|
48
|
+
author: "Alex Rivera",
|
|
49
|
+
role: "Head of Operations",
|
|
50
|
+
},
|
|
51
|
+
links: {
|
|
52
|
+
terms: "/terms",
|
|
53
|
+
privacy: "/privacy",
|
|
54
|
+
support: "mailto:support@example.com",
|
|
55
|
+
},
|
|
56
|
+
};
|