@everystack/admin 0.1.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/README.md +282 -0
- package/package.json +53 -0
- package/src/AdminRoot.tsx +34 -0
- package/src/adapters/EverystackAdapter.ts +218 -0
- package/src/adapters/types.ts +78 -0
- package/src/components/AvatarFieldInput.tsx +100 -0
- package/src/components/ImageDisplay.tsx +57 -0
- package/src/components/ImageFieldInput.tsx +140 -0
- package/src/components/dashboard/Brush.tsx +139 -0
- package/src/components/dashboard/DimensionBars.tsx +67 -0
- package/src/components/dashboard/DimensionDonut.tsx +98 -0
- package/src/components/dashboard/EventTable.tsx +248 -0
- package/src/components/dashboard/StatDials.tsx +67 -0
- package/src/components/dashboard/TimeHistogram.tsx +158 -0
- package/src/context.ts +75 -0
- package/src/hooks/useAdminConfig.ts +22 -0
- package/src/hooks/useAdminData.ts +132 -0
- package/src/hooks/useAggregates.ts +290 -0
- package/src/hooks/useRpc.ts +124 -0
- package/src/hooks/useStorageConfig.ts +16 -0
- package/src/index.ts +138 -0
- package/src/presets/twitter.ts +267 -0
- package/src/providers/AdminConfigProvider.tsx +18 -0
- package/src/providers/DataAdapterProvider.tsx +18 -0
- package/src/rpc/resource-dashboard-rpc.ts +416 -0
- package/src/screens/AdminDashboard.tsx +217 -0
- package/src/screens/AdminLayout.tsx +97 -0
- package/src/screens/ResourceCreate.tsx +243 -0
- package/src/screens/ResourceDashboard.tsx +278 -0
- package/src/screens/ResourceEdit.tsx +283 -0
- package/src/screens/ResourceList.tsx +153 -0
- package/src/screens/ResourceShow.tsx +171 -0
- package/src/screens/index.ts +14 -0
- package/src/types.ts +358 -0
- package/src/utils/naming.ts +105 -0
- package/src/utils/pick-image-web.ts +81 -0
- package/src/utils/resolve-image-url.ts +43 -0
- package/src/utils/upload-file.ts +93 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Resource Dashboard RPC Factory
|
|
3
|
+
*
|
|
4
|
+
* Builds server-side aggregation queries from ResourceDashboardConfig.
|
|
5
|
+
* Creates two RPC handlers per resource:
|
|
6
|
+
* - dashboardData: Single CTE query returning time histogram, dimension counts, and stats
|
|
7
|
+
* - eventList: Paginated, searchable, sortable event listing
|
|
8
|
+
*
|
|
9
|
+
* All filters use whitelist validation from DimensionConfig.allowedValues.
|
|
10
|
+
* Table/column names come from server-side config (not user input).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const rpcs = createResourceDashboardRpcs('log_events', config, db);
|
|
14
|
+
* // Wire into handler: rpc: { logEventsDashboardData: rpcs.dashboardData, ... }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { sql, type SQL } from 'drizzle-orm';
|
|
18
|
+
import type { ResourceDashboardConfig, DimensionConfig, StatDialConfig } from '../types';
|
|
19
|
+
|
|
20
|
+
interface DbExecutor {
|
|
21
|
+
execute(query: unknown): Promise<{ rows?: unknown[] } | unknown[]>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractRows(result: { rows?: unknown[] } | unknown[]): unknown[] {
|
|
25
|
+
if (Array.isArray(result)) return result;
|
|
26
|
+
return (result as { rows?: unknown[] }).rows ?? [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ────────────────────────────────────────────────────────────
|
|
30
|
+
// Dashboard Filters (generic — keyed by dimension column name)
|
|
31
|
+
// ────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export interface DashboardFilters {
|
|
34
|
+
time?: [string, string];
|
|
35
|
+
[dimension: string]: string[] | [string, string] | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ────────────────────────────────────────────────────────────
|
|
39
|
+
// Dashboard Aggregates (generic — keyed by config IDs)
|
|
40
|
+
// ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface DashboardAggregates {
|
|
43
|
+
timeHistogram: { bucket: string; count: number }[];
|
|
44
|
+
dimensions: Record<string, { value: string; count: number }[]>;
|
|
45
|
+
stats: Record<string, number>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ────────────────────────────────────────────────────────────
|
|
49
|
+
// Event List types
|
|
50
|
+
// ────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export interface EventListResult {
|
|
53
|
+
rows: Record<string, unknown>[];
|
|
54
|
+
total: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ────────────────────────────────────────────────────────────
|
|
58
|
+
// WHERE clause builder
|
|
59
|
+
// ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function buildWhereClause(
|
|
62
|
+
config: ResourceDashboardConfig,
|
|
63
|
+
filters: DashboardFilters,
|
|
64
|
+
hours: number,
|
|
65
|
+
): SQL {
|
|
66
|
+
const conditions: SQL[] = [];
|
|
67
|
+
const tsCol = config.timestampColumn;
|
|
68
|
+
|
|
69
|
+
// Time window
|
|
70
|
+
if (filters.time && filters.time[0] && filters.time[1]) {
|
|
71
|
+
conditions.push(sql`${sql.raw(tsCol)} >= ${filters.time[0]}::timestamptz`);
|
|
72
|
+
conditions.push(sql`${sql.raw(tsCol)} <= ${filters.time[1]}::timestamptz`);
|
|
73
|
+
} else {
|
|
74
|
+
conditions.push(sql`${sql.raw(tsCol)} >= NOW() - INTERVAL '1 hour' * ${hours}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Dimension filters
|
|
78
|
+
for (const dim of config.dimensions) {
|
|
79
|
+
const filterValue = filters[dim.column];
|
|
80
|
+
if (!filterValue || !Array.isArray(filterValue) || filterValue.length === 0) continue;
|
|
81
|
+
// Skip time tuples (already handled)
|
|
82
|
+
if (filterValue.length === 2 && typeof filterValue[0] === 'string' && filterValue[0].includes('T')) continue;
|
|
83
|
+
|
|
84
|
+
const allowed = new Set(dim.allowedValues ?? []);
|
|
85
|
+
const safeValues = (filterValue as string[]).filter(v => allowed.size === 0 || allowed.has(v));
|
|
86
|
+
if (safeValues.length === 0) continue;
|
|
87
|
+
|
|
88
|
+
const params = safeValues.map(v => sql`${v}`);
|
|
89
|
+
let inList = params[0];
|
|
90
|
+
for (let i = 1; i < params.length; i++) {
|
|
91
|
+
inList = sql`${inList}, ${params[i]}`;
|
|
92
|
+
}
|
|
93
|
+
conditions.push(sql`${sql.raw(dim.column)} IN (${inList})`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (conditions.length === 0) return sql`1=1`;
|
|
97
|
+
if (conditions.length === 1) return conditions[0];
|
|
98
|
+
|
|
99
|
+
let result = conditions[0];
|
|
100
|
+
for (let i = 1; i < conditions.length; i++) {
|
|
101
|
+
result = sql`${result} AND ${conditions[i]}`;
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ────────────────────────────────────────────────────────────
|
|
107
|
+
// Stats CTE builder
|
|
108
|
+
// ────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function buildStatSelects(stats: StatDialConfig[]): SQL {
|
|
111
|
+
const parts: SQL[] = [];
|
|
112
|
+
|
|
113
|
+
for (const stat of stats) {
|
|
114
|
+
switch (stat.value.type) {
|
|
115
|
+
case 'count':
|
|
116
|
+
parts.push(sql`COUNT(*)::int AS ${sql.raw(stat.id)}`);
|
|
117
|
+
break;
|
|
118
|
+
case 'count_where':
|
|
119
|
+
parts.push(
|
|
120
|
+
sql`COUNT(*) FILTER (WHERE ${sql.raw(stat.value.column)} = ${stat.value.equals})::int AS ${sql.raw(stat.id)}`
|
|
121
|
+
);
|
|
122
|
+
break;
|
|
123
|
+
case 'distinct':
|
|
124
|
+
parts.push(
|
|
125
|
+
sql`COUNT(DISTINCT ${sql.raw(stat.value.column)})::int AS ${sql.raw(stat.id)}`
|
|
126
|
+
);
|
|
127
|
+
break;
|
|
128
|
+
case 'table_count':
|
|
129
|
+
// Handled separately as a subquery
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (parts.length === 0) {
|
|
135
|
+
return sql`COUNT(*)::int AS total`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let result = parts[0];
|
|
139
|
+
for (let i = 1; i < parts.length; i++) {
|
|
140
|
+
result = sql`${result}, ${parts[i]}`;
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ────────────────────────────────────────────────────────────
|
|
146
|
+
// Dashboard Data RPC
|
|
147
|
+
// ────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function createDashboardDataHandler(
|
|
150
|
+
tableName: string,
|
|
151
|
+
config: ResourceDashboardConfig,
|
|
152
|
+
db: DbExecutor,
|
|
153
|
+
): (body: unknown) => Promise<unknown> {
|
|
154
|
+
return async (body: unknown) => {
|
|
155
|
+
const params = (body as Record<string, unknown>) ?? {};
|
|
156
|
+
const hours = Number(params.hours ?? config.defaultHours ?? 168);
|
|
157
|
+
const filters = (params.filters ?? {}) as DashboardFilters;
|
|
158
|
+
const where = buildWhereClause(config, filters, hours);
|
|
159
|
+
const bucketSize = config.bucketSize ?? 'hour';
|
|
160
|
+
|
|
161
|
+
// Build dimension CTEs
|
|
162
|
+
const dimensionCtes: SQL[] = [];
|
|
163
|
+
const dimensionSelects: SQL[] = [];
|
|
164
|
+
|
|
165
|
+
for (const dim of config.dimensions) {
|
|
166
|
+
const cteId = `dim_${dim.column}`;
|
|
167
|
+
dimensionCtes.push(sql`${sql.raw(cteId)} AS (
|
|
168
|
+
SELECT ${sql.raw(dim.column)} AS value, COUNT(*)::int AS count
|
|
169
|
+
FROM filtered
|
|
170
|
+
GROUP BY ${sql.raw(dim.column)}
|
|
171
|
+
ORDER BY count DESC
|
|
172
|
+
)`);
|
|
173
|
+
dimensionSelects.push(
|
|
174
|
+
sql`(SELECT COALESCE(json_agg(row_to_json(d)), '[]'::json) FROM ${sql.raw(cteId)} d) AS ${sql.raw(`"dim_${dim.column}"`)}`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build table_count stats (secondary tables)
|
|
179
|
+
const tableCountStats = config.stats.filter(s => s.value.type === 'table_count');
|
|
180
|
+
const tableCountCtes: SQL[] = [];
|
|
181
|
+
const tableCountSelects: SQL[] = [];
|
|
182
|
+
|
|
183
|
+
for (const stat of tableCountStats) {
|
|
184
|
+
if (stat.value.type !== 'table_count') continue;
|
|
185
|
+
const secTable = stat.value.table;
|
|
186
|
+
const secTsCol = stat.value.timestampColumn ?? config.timestampColumn;
|
|
187
|
+
// Build a simple time-window WHERE for the secondary table
|
|
188
|
+
let secWhere: SQL;
|
|
189
|
+
if (filters.time && filters.time[0] && filters.time[1]) {
|
|
190
|
+
secWhere = sql`${sql.raw(secTsCol)} >= ${filters.time[0]}::timestamptz AND ${sql.raw(secTsCol)} <= ${filters.time[1]}::timestamptz`;
|
|
191
|
+
} else {
|
|
192
|
+
secWhere = sql`${sql.raw(secTsCol)} >= NOW() - INTERVAL '1 hour' * ${hours}`;
|
|
193
|
+
}
|
|
194
|
+
tableCountCtes.push(sql`${sql.raw(`sec_${stat.id}`)} AS (
|
|
195
|
+
SELECT COUNT(*)::int AS count FROM ${sql.raw(secTable)} WHERE ${secWhere}
|
|
196
|
+
)`);
|
|
197
|
+
tableCountSelects.push(
|
|
198
|
+
sql`(SELECT count FROM ${sql.raw(`sec_${stat.id}`)}) AS ${sql.raw(`"sec_${stat.id}"`)}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Assemble query
|
|
203
|
+
const statSelects = buildStatSelects(config.stats);
|
|
204
|
+
|
|
205
|
+
let allCtes = sql`filtered AS (
|
|
206
|
+
SELECT *
|
|
207
|
+
FROM ${sql.raw(tableName)}
|
|
208
|
+
WHERE ${where}
|
|
209
|
+
),
|
|
210
|
+
time_agg AS (
|
|
211
|
+
SELECT date_trunc('${sql.raw(bucketSize)}', ${sql.raw(config.timestampColumn)})::text AS bucket,
|
|
212
|
+
COUNT(*)::int AS count
|
|
213
|
+
FROM filtered
|
|
214
|
+
GROUP BY 1
|
|
215
|
+
ORDER BY 1 ASC
|
|
216
|
+
),
|
|
217
|
+
stats_agg AS (
|
|
218
|
+
SELECT ${statSelects}
|
|
219
|
+
FROM filtered
|
|
220
|
+
)`;
|
|
221
|
+
|
|
222
|
+
for (const cte of dimensionCtes) {
|
|
223
|
+
allCtes = sql`${allCtes}, ${cte}`;
|
|
224
|
+
}
|
|
225
|
+
for (const cte of tableCountCtes) {
|
|
226
|
+
allCtes = sql`${allCtes}, ${cte}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let selectClauses = sql`(SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM time_agg t) AS "timeHistogram",
|
|
230
|
+
(SELECT row_to_json(s) FROM stats_agg s) AS stats`;
|
|
231
|
+
|
|
232
|
+
for (const sel of dimensionSelects) {
|
|
233
|
+
selectClauses = sql`${selectClauses}, ${sel}`;
|
|
234
|
+
}
|
|
235
|
+
for (const sel of tableCountSelects) {
|
|
236
|
+
selectClauses = sql`${selectClauses}, ${sel}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = await db.execute(sql`
|
|
240
|
+
WITH ${allCtes}
|
|
241
|
+
SELECT ${selectClauses}
|
|
242
|
+
`);
|
|
243
|
+
|
|
244
|
+
const rows = extractRows(result);
|
|
245
|
+
const row = rows[0] as Record<string, unknown> | undefined;
|
|
246
|
+
|
|
247
|
+
if (!row) {
|
|
248
|
+
const emptyStats: Record<string, number> = {};
|
|
249
|
+
for (const s of config.stats) emptyStats[s.id] = 0;
|
|
250
|
+
return {
|
|
251
|
+
timeHistogram: [],
|
|
252
|
+
dimensions: {},
|
|
253
|
+
stats: emptyStats,
|
|
254
|
+
} satisfies DashboardAggregates;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Parse timeHistogram
|
|
258
|
+
const timeHistogram = (typeof row.timeHistogram === 'string'
|
|
259
|
+
? JSON.parse(row.timeHistogram)
|
|
260
|
+
: row.timeHistogram) as DashboardAggregates['timeHistogram'] ?? [];
|
|
261
|
+
|
|
262
|
+
// Parse stats
|
|
263
|
+
const rawStats = (typeof row.stats === 'string'
|
|
264
|
+
? JSON.parse(row.stats)
|
|
265
|
+
: row.stats) as Record<string, number> ?? {};
|
|
266
|
+
|
|
267
|
+
// Merge in table_count stats
|
|
268
|
+
for (const stat of tableCountStats) {
|
|
269
|
+
const key = `sec_${stat.id}`;
|
|
270
|
+
const val = row[key];
|
|
271
|
+
rawStats[stat.id] = typeof val === 'number' ? val : 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Parse dimension counts
|
|
275
|
+
const dimensions: Record<string, { value: string; count: number }[]> = {};
|
|
276
|
+
for (const dim of config.dimensions) {
|
|
277
|
+
const key = `dim_${dim.column}`;
|
|
278
|
+
const val = row[key];
|
|
279
|
+
dimensions[dim.column] = (typeof val === 'string' ? JSON.parse(val) : val) as { value: string; count: number }[] ?? [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { timeHistogram, dimensions, stats: rawStats } satisfies DashboardAggregates;
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ────────────────────────────────────────────────────────────
|
|
287
|
+
// Event List RPC
|
|
288
|
+
// ────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
function createEventListHandler(
|
|
291
|
+
tableName: string,
|
|
292
|
+
config: ResourceDashboardConfig,
|
|
293
|
+
db: DbExecutor,
|
|
294
|
+
): (body: unknown) => Promise<unknown> {
|
|
295
|
+
const validSortColumns = new Set(config.listColumns);
|
|
296
|
+
|
|
297
|
+
return async (body: unknown) => {
|
|
298
|
+
const params = (body as Record<string, unknown>) ?? {};
|
|
299
|
+
const hours = Number(params.hours ?? config.defaultHours ?? 168);
|
|
300
|
+
const filters = (params.filters ?? {}) as DashboardFilters;
|
|
301
|
+
const search = params.search as string | undefined;
|
|
302
|
+
const sortBy = validSortColumns.has(params.sortBy as string) ? params.sortBy as string : config.timestampColumn;
|
|
303
|
+
const sortDir = params.sortDir === 'asc' ? 'asc' : 'desc';
|
|
304
|
+
const limit = Math.min(Math.max(1, Number(params.limit ?? 50)), 200);
|
|
305
|
+
const offset = Math.max(0, Number(params.offset ?? 0));
|
|
306
|
+
|
|
307
|
+
const where = buildWhereClause(config, filters, hours);
|
|
308
|
+
|
|
309
|
+
// Build search condition
|
|
310
|
+
let searchCondition: SQL = sql`1=1`;
|
|
311
|
+
if (search && search.trim().length > 0) {
|
|
312
|
+
const searchCols = config.searchColumns ?? [];
|
|
313
|
+
if (searchCols.length > 0) {
|
|
314
|
+
const pattern = `%${search.trim()}%`;
|
|
315
|
+
let searchSql = sql`${sql.raw(searchCols[0])} ILIKE ${pattern}`;
|
|
316
|
+
for (let i = 1; i < searchCols.length; i++) {
|
|
317
|
+
searchSql = sql`${searchSql} OR ${sql.raw(searchCols[i])} ILIKE ${pattern}`;
|
|
318
|
+
}
|
|
319
|
+
searchCondition = sql`(${searchSql})`;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Build SELECT columns — include primary key(s) for row identification
|
|
324
|
+
const pkCols = config.primaryKey
|
|
325
|
+
? (Array.isArray(config.primaryKey) ? config.primaryKey : [config.primaryKey])
|
|
326
|
+
: ['id'];
|
|
327
|
+
const allCols = [...new Set([...pkCols, ...config.listColumns])];
|
|
328
|
+
|
|
329
|
+
const result = await db.execute(sql`
|
|
330
|
+
WITH filtered AS (
|
|
331
|
+
SELECT ${sql.raw(allCols.join(', '))}
|
|
332
|
+
FROM ${sql.raw(tableName)}
|
|
333
|
+
WHERE ${where} AND ${searchCondition}
|
|
334
|
+
)
|
|
335
|
+
SELECT
|
|
336
|
+
(SELECT COUNT(*)::int FROM filtered) AS total,
|
|
337
|
+
(SELECT COALESCE(json_agg(row_to_json(r)), '[]'::json)
|
|
338
|
+
FROM (
|
|
339
|
+
SELECT * FROM filtered
|
|
340
|
+
ORDER BY ${sql.raw(sortBy)} ${sql.raw(sortDir)}
|
|
341
|
+
LIMIT ${limit} OFFSET ${offset}
|
|
342
|
+
) r
|
|
343
|
+
) AS rows
|
|
344
|
+
`);
|
|
345
|
+
|
|
346
|
+
const rows = extractRows(result);
|
|
347
|
+
const row = rows[0] as Record<string, unknown> | undefined;
|
|
348
|
+
|
|
349
|
+
if (!row) {
|
|
350
|
+
return { rows: [], total: 0 } satisfies EventListResult;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const eventRows = (typeof row.rows === 'string'
|
|
354
|
+
? JSON.parse(row.rows)
|
|
355
|
+
: row.rows) as Record<string, unknown>[] ?? [];
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
rows: eventRows,
|
|
359
|
+
total: (row.total as number) ?? 0,
|
|
360
|
+
} satisfies EventListResult;
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ────────────────────────────────────────────────────────────
|
|
365
|
+
// Public API
|
|
366
|
+
// ────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
export interface ResourceDashboardRpcs {
|
|
369
|
+
dashboardData: (body: unknown) => Promise<unknown>;
|
|
370
|
+
eventList: (body: unknown) => Promise<unknown>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Create dashboard + event list RPC handlers for a resource.
|
|
375
|
+
*
|
|
376
|
+
* @param tableName - Database table name (snake_case)
|
|
377
|
+
* @param config - ResourceDashboardConfig from ResourceConfig.dashboard
|
|
378
|
+
* @param db - Drizzle db executor (must support .execute())
|
|
379
|
+
*/
|
|
380
|
+
export function createResourceDashboardRpcs(
|
|
381
|
+
tableName: string,
|
|
382
|
+
config: ResourceDashboardConfig,
|
|
383
|
+
db: DbExecutor,
|
|
384
|
+
): ResourceDashboardRpcs {
|
|
385
|
+
return {
|
|
386
|
+
dashboardData: createDashboardDataHandler(tableName, config, db),
|
|
387
|
+
eventList: createEventListHandler(tableName, config, db),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Create dashboard RPCs for all resources that have a dashboard config.
|
|
393
|
+
*
|
|
394
|
+
* Returns a flat Record<string, RpcHandler> ready to spread into the handler's rpc config.
|
|
395
|
+
* RPC names follow the pattern: {resourceName}DashboardData, {resourceName}EventList
|
|
396
|
+
*
|
|
397
|
+
* @param resources - Array of ResourceConfig (with optional .dashboard)
|
|
398
|
+
* @param db - Drizzle db executor
|
|
399
|
+
*/
|
|
400
|
+
export function createAllDashboardRpcs(
|
|
401
|
+
resources: Array<{ name: string; dashboard?: ResourceDashboardConfig }>,
|
|
402
|
+
db: DbExecutor,
|
|
403
|
+
): Record<string, (body: unknown) => Promise<unknown>> {
|
|
404
|
+
const rpcs: Record<string, (body: unknown) => Promise<unknown>> = {};
|
|
405
|
+
|
|
406
|
+
for (const resource of resources) {
|
|
407
|
+
if (!resource.dashboard) continue;
|
|
408
|
+
const handlers = createResourceDashboardRpcs(resource.name, resource.dashboard, db);
|
|
409
|
+
// Convert resource name to camelCase RPC name prefix
|
|
410
|
+
const prefix = resource.name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
411
|
+
rpcs[`${prefix}DashboardData`] = handlers.dashboardData;
|
|
412
|
+
rpcs[`${prefix}EventList`] = handlers.eventList;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return rpcs;
|
|
416
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminDashboard
|
|
3
|
+
*
|
|
4
|
+
* Renders dashboard widgets from AdminConfig.dashboards[].widgets.
|
|
5
|
+
* Supports stat cards (useCount/useStats) and chart widgets (useGroupedCount).
|
|
6
|
+
* Grid width: width >= 2 → full-width, else half-width.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { View, Text } from 'react-native';
|
|
11
|
+
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
12
|
+
import { useCount, useStats, useGroupedCount } from '../hooks/useAggregates';
|
|
13
|
+
import { BarChart, DonutChart, LineChart, type DataPoint, type DataSeries } from '@everystack/ui';
|
|
14
|
+
import type { WidgetConfig } from '../types';
|
|
15
|
+
|
|
16
|
+
export interface AdminDashboardProps {
|
|
17
|
+
/** Dashboard ID to render (defaults to first dashboard) */
|
|
18
|
+
dashboardId?: string;
|
|
19
|
+
/** Called when a widget is pressed */
|
|
20
|
+
onWidgetPress?: (widget: WidgetConfig) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function AdminDashboard({ dashboardId, onWidgetPress }: AdminDashboardProps): React.ReactElement {
|
|
24
|
+
const config = useAdminConfig();
|
|
25
|
+
const dashboard = dashboardId
|
|
26
|
+
? config.dashboards?.find(d => d.id === dashboardId)
|
|
27
|
+
: config.dashboards?.[0];
|
|
28
|
+
|
|
29
|
+
if (!dashboard) {
|
|
30
|
+
return (
|
|
31
|
+
<View className="flex-1 items-center justify-center py-12">
|
|
32
|
+
<Text className="text-sm text-gray-500">No dashboard configured</Text>
|
|
33
|
+
</View>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<View className="flex-1 p-4">
|
|
39
|
+
<Text className="text-xl font-bold text-gray-900 mb-1">{dashboard.title}</Text>
|
|
40
|
+
{dashboard.description && (
|
|
41
|
+
<Text className="text-sm text-gray-500 mb-4">{dashboard.description}</Text>
|
|
42
|
+
)}
|
|
43
|
+
|
|
44
|
+
{/* Widget grid */}
|
|
45
|
+
<View className="flex-row flex-wrap -mx-2">
|
|
46
|
+
{dashboard.widgets.map(widget => {
|
|
47
|
+
const isWide = (widget.grid?.width ?? 1) >= 2;
|
|
48
|
+
return (
|
|
49
|
+
<View key={widget.id} className={isWide ? 'w-full px-2 mb-4' : 'w-1/2 px-2 mb-4'}>
|
|
50
|
+
<WidgetDispatcher widget={widget} onPress={onWidgetPress} />
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</View>
|
|
55
|
+
</View>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// Widget Dispatcher
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
interface WidgetDispatcherProps {
|
|
64
|
+
widget: WidgetConfig;
|
|
65
|
+
onPress?: (widget: WidgetConfig) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function WidgetDispatcher({ widget, onPress }: WidgetDispatcherProps): React.ReactElement {
|
|
69
|
+
switch (widget.type) {
|
|
70
|
+
case 'chart':
|
|
71
|
+
return <ChartWidget widget={widget} onPress={onPress} />;
|
|
72
|
+
case 'stat':
|
|
73
|
+
default:
|
|
74
|
+
return <StatWidget widget={widget} onPress={onPress} />;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// StatWidget
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
interface StatWidgetProps {
|
|
83
|
+
widget: WidgetConfig;
|
|
84
|
+
onPress?: (widget: WidgetConfig) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function StatWidget({ widget, onPress }: StatWidgetProps): React.ReactElement {
|
|
88
|
+
const resource = widget.data?.resource ?? '';
|
|
89
|
+
const aggregation = widget.data?.aggregation;
|
|
90
|
+
const field = widget.data?.field;
|
|
91
|
+
|
|
92
|
+
// Use the appropriate hook based on aggregation type
|
|
93
|
+
const { count, loading: countLoading } = useCount(resource, {
|
|
94
|
+
skip: aggregation !== 'count' && aggregation !== undefined,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const { stats, loading: statsLoading } = useStats(resource, {
|
|
98
|
+
sum: aggregation === 'sum' ? field : undefined,
|
|
99
|
+
avg: aggregation === 'avg' ? field : undefined,
|
|
100
|
+
min: aggregation === 'min' ? field : undefined,
|
|
101
|
+
max: aggregation === 'max' ? field : undefined,
|
|
102
|
+
skip: aggregation === 'count' || aggregation === undefined,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const loading = countLoading || statsLoading;
|
|
106
|
+
let value: string | number = '—';
|
|
107
|
+
|
|
108
|
+
if (!loading) {
|
|
109
|
+
if (aggregation === 'count' || aggregation === undefined) {
|
|
110
|
+
value = count ?? 0;
|
|
111
|
+
} else if (aggregation && stats[aggregation] != null) {
|
|
112
|
+
value = formatStat(stats[aggregation] as number, widget.stat?.format);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<View className="bg-white rounded-xl p-4 border border-gray-200">
|
|
118
|
+
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
|
|
119
|
+
{widget.title}
|
|
120
|
+
</Text>
|
|
121
|
+
<Text className="text-2xl font-bold text-gray-900">
|
|
122
|
+
{loading ? '...' : String(value)}
|
|
123
|
+
</Text>
|
|
124
|
+
</View>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// =============================================================================
|
|
129
|
+
// ChartWidget
|
|
130
|
+
// =============================================================================
|
|
131
|
+
|
|
132
|
+
interface ChartWidgetProps {
|
|
133
|
+
widget: WidgetConfig;
|
|
134
|
+
onPress?: (widget: WidgetConfig) => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ChartWidget({ widget, onPress }: ChartWidgetProps): React.ReactElement {
|
|
138
|
+
const resource = widget.data?.resource ?? '';
|
|
139
|
+
const groupBy = widget.data?.groupBy ?? '';
|
|
140
|
+
const chartType = widget.chart?.type ?? 'bar';
|
|
141
|
+
|
|
142
|
+
const { data, loading, error } = useGroupedCount(resource, groupBy, {
|
|
143
|
+
filter: widget.data?.query as Record<string, unknown> | undefined,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Transform grouped data to chart data points
|
|
147
|
+
const chartData: DataPoint[] = data.map(row => ({
|
|
148
|
+
label: String(row[groupBy] ?? 'Unknown'),
|
|
149
|
+
value: row.count ?? 0,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<View className="bg-white rounded-xl p-4 border border-gray-200">
|
|
154
|
+
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
|
|
155
|
+
{widget.title}
|
|
156
|
+
</Text>
|
|
157
|
+
|
|
158
|
+
{loading ? (
|
|
159
|
+
<View className="h-40 items-center justify-center">
|
|
160
|
+
<Text className="text-sm text-gray-400">Loading...</Text>
|
|
161
|
+
</View>
|
|
162
|
+
) : error ? (
|
|
163
|
+
<View className="h-40 items-center justify-center">
|
|
164
|
+
<Text className="text-sm text-red-400">Failed to load</Text>
|
|
165
|
+
</View>
|
|
166
|
+
) : (
|
|
167
|
+
<ChartRenderer type={chartType} data={chartData} />
|
|
168
|
+
)}
|
|
169
|
+
</View>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// =============================================================================
|
|
174
|
+
// Chart Renderer
|
|
175
|
+
// =============================================================================
|
|
176
|
+
|
|
177
|
+
interface ChartRendererProps {
|
|
178
|
+
type: string;
|
|
179
|
+
data: DataPoint[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function ChartRenderer({ type, data }: ChartRendererProps): React.ReactElement {
|
|
183
|
+
switch (type) {
|
|
184
|
+
case 'donut':
|
|
185
|
+
case 'pie':
|
|
186
|
+
return <DonutChart data={data} size={140} showLegend />;
|
|
187
|
+
|
|
188
|
+
case 'line':
|
|
189
|
+
case 'area': {
|
|
190
|
+
const series: DataSeries[] = [{
|
|
191
|
+
name: 'Value',
|
|
192
|
+
data,
|
|
193
|
+
color: '#6366f1',
|
|
194
|
+
}];
|
|
195
|
+
return <LineChart series={series} height={160} fillArea={type === 'area'} showGrid />;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'bar':
|
|
199
|
+
default:
|
|
200
|
+
return <BarChart data={data} height={160} showLabels showValues={false} orientation="horizontal" />;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// =============================================================================
|
|
205
|
+
// Helpers
|
|
206
|
+
// =============================================================================
|
|
207
|
+
|
|
208
|
+
function formatStat(value: number, format?: 'number' | 'currency' | 'percent'): string {
|
|
209
|
+
switch (format) {
|
|
210
|
+
case 'currency':
|
|
211
|
+
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
|
212
|
+
case 'percent':
|
|
213
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
214
|
+
default:
|
|
215
|
+
return value.toLocaleString();
|
|
216
|
+
}
|
|
217
|
+
}
|