@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,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminLayout
|
|
3
|
+
*
|
|
4
|
+
* Main admin layout with sidebar navigation and content area.
|
|
5
|
+
* Reads menu items from AdminConfig via useAdminConfig().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState } from 'react';
|
|
9
|
+
import { View, Text, Pressable, ScrollView } from 'react-native';
|
|
10
|
+
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
|
+
import type { MenuItem } from '../types';
|
|
12
|
+
|
|
13
|
+
export interface AdminLayoutProps {
|
|
14
|
+
children?: React.ReactNode;
|
|
15
|
+
/** Currently active menu item ID */
|
|
16
|
+
activeItem?: string;
|
|
17
|
+
/** Called when a menu item is pressed */
|
|
18
|
+
onNavigate?: (item: MenuItem) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AdminLayout({ children, activeItem, onNavigate }: AdminLayoutProps): React.ReactElement {
|
|
22
|
+
const config = useAdminConfig();
|
|
23
|
+
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<View className="flex-1 flex-row bg-gray-50">
|
|
27
|
+
{/* Sidebar */}
|
|
28
|
+
{sidebarOpen && (
|
|
29
|
+
<View className="w-64 bg-white border-r border-gray-200">
|
|
30
|
+
{/* Header */}
|
|
31
|
+
<View className="px-4 py-5 border-b border-gray-200">
|
|
32
|
+
<Text className="text-lg font-bold text-gray-900">{config.title}</Text>
|
|
33
|
+
{config.subtitle && (
|
|
34
|
+
<Text className="text-xs text-gray-500 mt-0.5">{config.subtitle}</Text>
|
|
35
|
+
)}
|
|
36
|
+
</View>
|
|
37
|
+
|
|
38
|
+
{/* Menu */}
|
|
39
|
+
<ScrollView className="flex-1 py-2">
|
|
40
|
+
{config.menu?.map(item => (
|
|
41
|
+
<MenuItemRow
|
|
42
|
+
key={item.id}
|
|
43
|
+
item={item}
|
|
44
|
+
active={activeItem === item.id}
|
|
45
|
+
onPress={onNavigate}
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
</ScrollView>
|
|
49
|
+
</View>
|
|
50
|
+
)}
|
|
51
|
+
|
|
52
|
+
{/* Main content */}
|
|
53
|
+
<View className="flex-1">
|
|
54
|
+
{/* Top bar */}
|
|
55
|
+
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-200">
|
|
56
|
+
<Pressable
|
|
57
|
+
className="p-2 rounded-lg mr-2"
|
|
58
|
+
onPress={() => setSidebarOpen(!sidebarOpen)}
|
|
59
|
+
>
|
|
60
|
+
<Text className="text-lg text-gray-600">{sidebarOpen ? '\u2190' : '\u2192'}</Text>
|
|
61
|
+
</Pressable>
|
|
62
|
+
</View>
|
|
63
|
+
|
|
64
|
+
{/* Content */}
|
|
65
|
+
<View className="flex-1">{children}</View>
|
|
66
|
+
</View>
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// MenuItemRow
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
interface MenuItemRowProps {
|
|
76
|
+
item: MenuItem;
|
|
77
|
+
active: boolean;
|
|
78
|
+
onPress?: (item: MenuItem) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function MenuItemRow({ item, active, onPress }: MenuItemRowProps): React.ReactElement {
|
|
82
|
+
return (
|
|
83
|
+
<Pressable
|
|
84
|
+
className={active ? "flex-row items-center px-4 py-2.5 mx-2 rounded-lg bg-blue-50" : "flex-row items-center px-4 py-2.5 mx-2 rounded-lg"}
|
|
85
|
+
onPress={() => onPress?.(item)}
|
|
86
|
+
>
|
|
87
|
+
<Text className={active ? "text-sm text-blue-700 font-semibold" : "text-sm text-gray-700"}>
|
|
88
|
+
{item.label}
|
|
89
|
+
</Text>
|
|
90
|
+
{item.badge != null && (
|
|
91
|
+
<View className="ml-auto bg-gray-200 rounded-full px-2 py-0.5">
|
|
92
|
+
<Text className="text-xs text-gray-600">{String(item.badge)}</Text>
|
|
93
|
+
</View>
|
|
94
|
+
)}
|
|
95
|
+
</Pressable>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResourceCreate
|
|
3
|
+
*
|
|
4
|
+
* Create form for a resource. Generates form inputs based on ResourceConfig.fields.
|
|
5
|
+
* Field type determines which input component is used.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
|
9
|
+
import { View, Text, ScrollView } from 'react-native';
|
|
10
|
+
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
|
+
import { useDataAdapter } from '../hooks/useAdminData';
|
|
12
|
+
import { FormField, TextInput, SelectInput, SwitchInput, Button, ErrorState } from '@everystack/ui';
|
|
13
|
+
import { ImageFieldInput } from '../components/ImageFieldInput';
|
|
14
|
+
import { AvatarFieldInput } from '../components/AvatarFieldInput';
|
|
15
|
+
import type { FieldDefinition } from '../types';
|
|
16
|
+
|
|
17
|
+
export interface ResourceCreateProps {
|
|
18
|
+
/** Resource name */
|
|
19
|
+
resource: string;
|
|
20
|
+
/** Called after successful creation with the new record */
|
|
21
|
+
onSuccess?: (record: Record<string, unknown>) => void;
|
|
22
|
+
/** Called when the cancel button is pressed */
|
|
23
|
+
onCancel?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ResourceCreate({ resource, onSuccess, onCancel }: ResourceCreateProps): React.ReactElement {
|
|
27
|
+
const config = useAdminConfig();
|
|
28
|
+
const adapter = useDataAdapter();
|
|
29
|
+
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
30
|
+
|
|
31
|
+
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
32
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
33
|
+
const [submitting, setSubmitting] = useState(false);
|
|
34
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const fields = useMemo(() => {
|
|
37
|
+
if (!resourceConfig) return [];
|
|
38
|
+
const fieldNames = resourceConfig.createFields ?? resourceConfig.fields.map(f => f.name);
|
|
39
|
+
const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
|
|
40
|
+
return fieldNames
|
|
41
|
+
.map(name => fieldMap.get(name))
|
|
42
|
+
.filter((f): f is FieldDefinition => f != null)
|
|
43
|
+
.filter(f => f.name !== (resourceConfig.primaryKey ?? 'id'));
|
|
44
|
+
}, [resourceConfig]);
|
|
45
|
+
|
|
46
|
+
const setFieldValue = useCallback((name: string, value: unknown) => {
|
|
47
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
48
|
+
setErrors(prev => {
|
|
49
|
+
const next = { ...prev };
|
|
50
|
+
delete next[name];
|
|
51
|
+
return next;
|
|
52
|
+
});
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const validate = useCallback((): boolean => {
|
|
56
|
+
const newErrors: Record<string, string> = {};
|
|
57
|
+
for (const field of fields) {
|
|
58
|
+
const value = formData[field.name];
|
|
59
|
+
if (field.validation?.required && (value == null || value === '')) {
|
|
60
|
+
newErrors[field.name] = field.validation.message ?? `${field.label ?? field.name} is required`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
setErrors(newErrors);
|
|
64
|
+
return Object.keys(newErrors).length === 0;
|
|
65
|
+
}, [fields, formData]);
|
|
66
|
+
|
|
67
|
+
const handleSubmit = useCallback(async () => {
|
|
68
|
+
if (!validate()) return;
|
|
69
|
+
|
|
70
|
+
setSubmitting(true);
|
|
71
|
+
setSubmitError(null);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const record = await adapter.create(resource, formData as Record<string, unknown> & { id?: never });
|
|
75
|
+
onSuccess?.(record);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
setSubmitError(err instanceof Error ? err.message : String(err));
|
|
78
|
+
} finally {
|
|
79
|
+
setSubmitting(false);
|
|
80
|
+
}
|
|
81
|
+
}, [adapter, resource, formData, validate, onSuccess]);
|
|
82
|
+
|
|
83
|
+
if (!resourceConfig) {
|
|
84
|
+
return <ErrorState message={`Resource "${resource}" not found in admin configuration`} />;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const label = resourceConfig.label ?? resourceConfig.name;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<ScrollView className="flex-1">
|
|
91
|
+
{/* Header */}
|
|
92
|
+
<View className="flex-row items-center justify-between px-4 py-4">
|
|
93
|
+
<Text className="text-xl font-bold text-gray-900">Create {label}</Text>
|
|
94
|
+
<View className="flex-row gap-2">
|
|
95
|
+
{onCancel && (
|
|
96
|
+
<Button label="Cancel" variant="ghost" onPress={onCancel} />
|
|
97
|
+
)}
|
|
98
|
+
<Button label="Create" onPress={handleSubmit} loading={submitting} />
|
|
99
|
+
</View>
|
|
100
|
+
</View>
|
|
101
|
+
|
|
102
|
+
{/* Error */}
|
|
103
|
+
{submitError && (
|
|
104
|
+
<View className="mx-4 mb-4 bg-red-50 border border-red-200 rounded-lg px-4 py-3">
|
|
105
|
+
<Text className="text-sm text-red-700">{submitError}</Text>
|
|
106
|
+
</View>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{/* Form fields */}
|
|
110
|
+
<View className="px-4">
|
|
111
|
+
{fields.map(field => (
|
|
112
|
+
<FieldInput
|
|
113
|
+
key={field.name}
|
|
114
|
+
field={field}
|
|
115
|
+
value={formData[field.name]}
|
|
116
|
+
error={errors[field.name]}
|
|
117
|
+
onChange={value => setFieldValue(field.name, value)}
|
|
118
|
+
/>
|
|
119
|
+
))}
|
|
120
|
+
</View>
|
|
121
|
+
</ScrollView>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// =============================================================================
|
|
126
|
+
// FieldInput — maps FieldDefinition.type to input component
|
|
127
|
+
// =============================================================================
|
|
128
|
+
|
|
129
|
+
interface FieldInputProps {
|
|
130
|
+
field: FieldDefinition;
|
|
131
|
+
value: unknown;
|
|
132
|
+
error?: string;
|
|
133
|
+
onChange: (value: unknown) => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function FieldInput({ field, value, error, onChange }: FieldInputProps): React.ReactElement {
|
|
137
|
+
const label = field.label ?? field.name;
|
|
138
|
+
const required = field.validation?.required;
|
|
139
|
+
|
|
140
|
+
switch (field.type) {
|
|
141
|
+
case 'boolean':
|
|
142
|
+
return (
|
|
143
|
+
<FormField label={label} error={error} required={required}>
|
|
144
|
+
<SwitchInput
|
|
145
|
+
value={Boolean(value)}
|
|
146
|
+
onValueChange={onChange}
|
|
147
|
+
/>
|
|
148
|
+
</FormField>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
case 'enum':
|
|
152
|
+
return (
|
|
153
|
+
<FormField label={label} error={error} required={required}>
|
|
154
|
+
<SelectInput
|
|
155
|
+
value={String(value ?? '')}
|
|
156
|
+
options={(field.enumValues ?? []).map(v => ({ label: v, value: v }))}
|
|
157
|
+
onValueChange={onChange}
|
|
158
|
+
placeholder={`Select ${label.toLowerCase()}`}
|
|
159
|
+
/>
|
|
160
|
+
</FormField>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
case 'number':
|
|
164
|
+
return (
|
|
165
|
+
<FormField label={label} error={error} required={required}>
|
|
166
|
+
<TextInput
|
|
167
|
+
value={value != null ? String(value) : ''}
|
|
168
|
+
onChangeText={text => onChange(text ? Number(text) : null)}
|
|
169
|
+
keyboardType="numeric"
|
|
170
|
+
placeholder={`Enter ${label.toLowerCase()}`}
|
|
171
|
+
/>
|
|
172
|
+
</FormField>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
case 'email':
|
|
176
|
+
return (
|
|
177
|
+
<FormField label={label} error={error} required={required}>
|
|
178
|
+
<TextInput
|
|
179
|
+
value={String(value ?? '')}
|
|
180
|
+
onChangeText={onChange}
|
|
181
|
+
keyboardType="email-address"
|
|
182
|
+
placeholder={`Enter ${label.toLowerCase()}`}
|
|
183
|
+
/>
|
|
184
|
+
</FormField>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
case 'url':
|
|
188
|
+
return (
|
|
189
|
+
<FormField label={label} error={error} required={required}>
|
|
190
|
+
<TextInput
|
|
191
|
+
value={String(value ?? '')}
|
|
192
|
+
onChangeText={onChange}
|
|
193
|
+
keyboardType="url"
|
|
194
|
+
placeholder={`Enter ${label.toLowerCase()}`}
|
|
195
|
+
/>
|
|
196
|
+
</FormField>
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
case 'json':
|
|
200
|
+
return (
|
|
201
|
+
<FormField label={label} error={error} required={required}>
|
|
202
|
+
<TextInput
|
|
203
|
+
value={typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2)}
|
|
204
|
+
onChangeText={text => {
|
|
205
|
+
try { onChange(JSON.parse(text)); } catch { onChange(text); }
|
|
206
|
+
}}
|
|
207
|
+
multiline
|
|
208
|
+
placeholder="{}"
|
|
209
|
+
/>
|
|
210
|
+
</FormField>
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
case 'image':
|
|
214
|
+
if (field.renderer === 'avatar') {
|
|
215
|
+
return <AvatarFieldInput field={field} value={value} error={error} onChange={onChange} />;
|
|
216
|
+
}
|
|
217
|
+
return <ImageFieldInput field={field} value={value} error={error} onChange={onChange} />;
|
|
218
|
+
|
|
219
|
+
case 'file':
|
|
220
|
+
return (
|
|
221
|
+
<FormField label={label} error={error} required={required}>
|
|
222
|
+
<TextInput
|
|
223
|
+
value={String(value ?? '')}
|
|
224
|
+
onChangeText={onChange}
|
|
225
|
+
keyboardType="url"
|
|
226
|
+
placeholder="Enter file URL"
|
|
227
|
+
/>
|
|
228
|
+
</FormField>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
default:
|
|
232
|
+
return (
|
|
233
|
+
<FormField label={label} error={error} required={required}>
|
|
234
|
+
<TextInput
|
|
235
|
+
value={String(value ?? '')}
|
|
236
|
+
onChangeText={onChange}
|
|
237
|
+
placeholder={`Enter ${label.toLowerCase()}`}
|
|
238
|
+
multiline={field.type === 'string' && (field.validation?.max ?? 0) > 255}
|
|
239
|
+
/>
|
|
240
|
+
</FormField>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResourceDashboard — Config-driven dashboard for any resource.
|
|
3
|
+
*
|
|
4
|
+
* Reads ResourceDashboardConfig from the AdminConfig context and renders:
|
|
5
|
+
* 1. StatDials (N configurable stat circles)
|
|
6
|
+
* 2. TimeHistogram (brushable time series)
|
|
7
|
+
* 3. Dimension charts (bars or donut per config)
|
|
8
|
+
* 4. EventTable (paginated, searchable, sortable)
|
|
9
|
+
*
|
|
10
|
+
* All data comes from two server-side RPC endpoints:
|
|
11
|
+
* - {prefix}DashboardData → aggregates
|
|
12
|
+
* - {prefix}EventList → paginated rows
|
|
13
|
+
*
|
|
14
|
+
* The resource name determines the RPC prefix (snake_case → camelCase).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useState, useMemo, useCallback } from 'react';
|
|
18
|
+
import { View, Text, ScrollView, Pressable, useWindowDimensions } from 'react-native';
|
|
19
|
+
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
20
|
+
import { useRpc } from '../hooks/useRpc';
|
|
21
|
+
import { StatDials } from '../components/dashboard/StatDials';
|
|
22
|
+
import { TimeHistogram } from '../components/dashboard/TimeHistogram';
|
|
23
|
+
import { DimensionBars } from '../components/dashboard/DimensionBars';
|
|
24
|
+
import { DimensionDonut } from '../components/dashboard/DimensionDonut';
|
|
25
|
+
import { EventTable } from '../components/dashboard/EventTable';
|
|
26
|
+
import type { ResourceDashboardConfig } from '../types';
|
|
27
|
+
import type { DashboardFilters, DashboardAggregates } from '../rpc/resource-dashboard-rpc';
|
|
28
|
+
|
|
29
|
+
export interface ResourceDashboardProps {
|
|
30
|
+
/** Resource name — must match a ResourceConfig with .dashboard defined */
|
|
31
|
+
resource: string;
|
|
32
|
+
/** API base URL for RPC calls */
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
/** Auth token provider */
|
|
35
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
36
|
+
/** Called when a row is pressed in the event table */
|
|
37
|
+
onRowPress?: (record: Record<string, unknown>) => void;
|
|
38
|
+
/** Optional action button in the header */
|
|
39
|
+
action?: { label: string; onPress: () => void };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toCamelCase(snakeCase: string): string {
|
|
43
|
+
return snakeCase.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ResourceDashboard({
|
|
47
|
+
resource,
|
|
48
|
+
baseUrl,
|
|
49
|
+
getToken,
|
|
50
|
+
onRowPress,
|
|
51
|
+
action,
|
|
52
|
+
}: ResourceDashboardProps): React.ReactElement {
|
|
53
|
+
const config = useAdminConfig();
|
|
54
|
+
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
55
|
+
const dashboard = resourceConfig?.dashboard;
|
|
56
|
+
|
|
57
|
+
if (!dashboard) {
|
|
58
|
+
return (
|
|
59
|
+
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }}>
|
|
60
|
+
<Text style={{ color: '#6b7280', fontSize: 14 }}>
|
|
61
|
+
No dashboard configuration found for "{resource}"
|
|
62
|
+
</Text>
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const displayTitle = dashboard.title ?? resourceConfig?.labelPlural ?? resource;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<ResourceDashboardInner
|
|
71
|
+
resource={resource}
|
|
72
|
+
dashboard={dashboard}
|
|
73
|
+
displayTitle={displayTitle}
|
|
74
|
+
baseUrl={baseUrl}
|
|
75
|
+
getToken={getToken}
|
|
76
|
+
onRowPress={onRowPress}
|
|
77
|
+
action={action}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Inner component with dashboard config guaranteed
|
|
83
|
+
function ResourceDashboardInner({
|
|
84
|
+
resource,
|
|
85
|
+
dashboard,
|
|
86
|
+
displayTitle,
|
|
87
|
+
baseUrl,
|
|
88
|
+
getToken,
|
|
89
|
+
onRowPress,
|
|
90
|
+
action,
|
|
91
|
+
}: {
|
|
92
|
+
resource: string;
|
|
93
|
+
dashboard: ResourceDashboardConfig;
|
|
94
|
+
displayTitle: string;
|
|
95
|
+
baseUrl: string;
|
|
96
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
97
|
+
onRowPress?: (record: Record<string, unknown>) => void;
|
|
98
|
+
action?: { label: string; onPress: () => void };
|
|
99
|
+
}): React.ReactElement {
|
|
100
|
+
const { width: windowWidth } = useWindowDimensions();
|
|
101
|
+
const chartWidth = Math.min(windowWidth - 48, 800);
|
|
102
|
+
|
|
103
|
+
const prefix = toCamelCase(resource);
|
|
104
|
+
const dashboardDataFn = `${prefix}DashboardData`;
|
|
105
|
+
const eventListFn = `${prefix}EventList`;
|
|
106
|
+
|
|
107
|
+
// Filter state — keyed by dimension columns
|
|
108
|
+
const [filters, setFilters] = useState<DashboardFilters>({});
|
|
109
|
+
|
|
110
|
+
const hours = dashboard.defaultHours ?? 168;
|
|
111
|
+
|
|
112
|
+
const body = useMemo(() => ({
|
|
113
|
+
hours,
|
|
114
|
+
filters,
|
|
115
|
+
}), [hours, filters]);
|
|
116
|
+
|
|
117
|
+
const { data, loading } = useRpc<DashboardAggregates>({
|
|
118
|
+
baseUrl,
|
|
119
|
+
fn: dashboardDataFn,
|
|
120
|
+
body,
|
|
121
|
+
getToken,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Handle time brush
|
|
125
|
+
const handleTimeBrush = useCallback((extent: [string, string] | null) => {
|
|
126
|
+
setFilters(prev => {
|
|
127
|
+
const next = { ...prev };
|
|
128
|
+
if (extent) {
|
|
129
|
+
next.time = extent;
|
|
130
|
+
} else {
|
|
131
|
+
delete next.time;
|
|
132
|
+
}
|
|
133
|
+
return next;
|
|
134
|
+
});
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
// Handle dimension filter toggle
|
|
138
|
+
const handleDimensionFilter = useCallback((column: string, value: string) => {
|
|
139
|
+
setFilters(prev => {
|
|
140
|
+
const next = { ...prev };
|
|
141
|
+
const current = (next[column] as string[] | undefined) ?? [];
|
|
142
|
+
if (current.includes(value)) {
|
|
143
|
+
const filtered = current.filter(v => v !== value);
|
|
144
|
+
if (filtered.length === 0) {
|
|
145
|
+
delete next[column];
|
|
146
|
+
} else {
|
|
147
|
+
next[column] = filtered;
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
next[column] = [...current, value];
|
|
151
|
+
}
|
|
152
|
+
return next;
|
|
153
|
+
});
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
// Clear all filters
|
|
157
|
+
const handleClearFilters = useCallback(() => {
|
|
158
|
+
setFilters({});
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const hasFilters = Object.keys(filters).length > 0;
|
|
162
|
+
|
|
163
|
+
// Build stat dials from config + data
|
|
164
|
+
const dials = useMemo(() => {
|
|
165
|
+
if (!data?.stats) return dashboard.stats.map(s => ({
|
|
166
|
+
label: s.label,
|
|
167
|
+
value: 0,
|
|
168
|
+
color: s.color,
|
|
169
|
+
ratio: undefined as number | undefined,
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
const total = data.stats.total ?? data.stats[dashboard.stats[0]?.id] ?? 1;
|
|
173
|
+
|
|
174
|
+
return dashboard.stats.map(stat => {
|
|
175
|
+
const value = data.stats[stat.id] ?? 0;
|
|
176
|
+
let ratio: number | undefined;
|
|
177
|
+
if (stat.ratio === 'of_total' && total > 0 && stat.id !== 'total') {
|
|
178
|
+
ratio = value / total;
|
|
179
|
+
} else if (typeof stat.ratio === 'number') {
|
|
180
|
+
ratio = stat.ratio;
|
|
181
|
+
}
|
|
182
|
+
return { label: stat.label, value, color: stat.color, ratio };
|
|
183
|
+
});
|
|
184
|
+
}, [data?.stats, dashboard.stats]);
|
|
185
|
+
|
|
186
|
+
// Build event table columns from config
|
|
187
|
+
const tableColumns = useMemo(() => {
|
|
188
|
+
return dashboard.listColumns.map(col => ({
|
|
189
|
+
key: col,
|
|
190
|
+
label: col.charAt(0).toUpperCase() + col.slice(1).replace(/_/g, ' '),
|
|
191
|
+
flex: col === 'message' ? 3 : col === 'timestamp' ? 1.5 : 1,
|
|
192
|
+
}));
|
|
193
|
+
}, [dashboard.listColumns]);
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, paddingBottom: 48 }}>
|
|
197
|
+
{/* Header */}
|
|
198
|
+
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
|
199
|
+
<View>
|
|
200
|
+
<Text style={{ fontSize: 20, fontWeight: '700', color: '#0f172a' }}>
|
|
201
|
+
{displayTitle}
|
|
202
|
+
</Text>
|
|
203
|
+
{hasFilters && (
|
|
204
|
+
<Pressable onPress={handleClearFilters} style={{ marginTop: 4 }}>
|
|
205
|
+
<Text style={{ fontSize: 12, color: '#6366f1' }}>Clear all filters</Text>
|
|
206
|
+
</Pressable>
|
|
207
|
+
)}
|
|
208
|
+
</View>
|
|
209
|
+
{action && (
|
|
210
|
+
<Pressable
|
|
211
|
+
onPress={action.onPress}
|
|
212
|
+
style={{
|
|
213
|
+
paddingHorizontal: 16, paddingVertical: 8,
|
|
214
|
+
backgroundColor: '#6366f1', borderRadius: 6,
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600' }}>{action.label}</Text>
|
|
218
|
+
</Pressable>
|
|
219
|
+
)}
|
|
220
|
+
</View>
|
|
221
|
+
|
|
222
|
+
{/* Stat Dials */}
|
|
223
|
+
<StatDials dials={dials} />
|
|
224
|
+
|
|
225
|
+
{/* Time Histogram */}
|
|
226
|
+
<TimeHistogram
|
|
227
|
+
data={data?.timeHistogram ?? []}
|
|
228
|
+
width={chartWidth}
|
|
229
|
+
title={dashboard.title ? `${dashboard.title} Over Time` : undefined}
|
|
230
|
+
onBrush={handleTimeBrush}
|
|
231
|
+
/>
|
|
232
|
+
|
|
233
|
+
{/* Dimension Charts */}
|
|
234
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24, marginTop: 24 }}>
|
|
235
|
+
{dashboard.dimensions.map(dim => {
|
|
236
|
+
const dimData = data?.dimensions?.[dim.column] ?? [];
|
|
237
|
+
const activeFilters = (filters[dim.column] as string[] | undefined) ?? [];
|
|
238
|
+
|
|
239
|
+
if (dim.chartType === 'donut') {
|
|
240
|
+
return (
|
|
241
|
+
<DimensionDonut
|
|
242
|
+
key={dim.column}
|
|
243
|
+
title={dim.label}
|
|
244
|
+
data={dimData}
|
|
245
|
+
colors={dim.colors}
|
|
246
|
+
order={dim.order}
|
|
247
|
+
activeFilters={activeFilters}
|
|
248
|
+
onFilter={(value) => handleDimensionFilter(dim.column, value)}
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<DimensionBars
|
|
255
|
+
key={dim.column}
|
|
256
|
+
title={dim.label}
|
|
257
|
+
data={dimData}
|
|
258
|
+
colors={dim.colors}
|
|
259
|
+
activeFilters={activeFilters}
|
|
260
|
+
onFilter={(value) => handleDimensionFilter(dim.column, value)}
|
|
261
|
+
/>
|
|
262
|
+
);
|
|
263
|
+
})}
|
|
264
|
+
</View>
|
|
265
|
+
|
|
266
|
+
{/* Event Table */}
|
|
267
|
+
<EventTable
|
|
268
|
+
baseUrl={baseUrl}
|
|
269
|
+
rpcFn={eventListFn}
|
|
270
|
+
columns={tableColumns}
|
|
271
|
+
filters={filters}
|
|
272
|
+
getToken={getToken}
|
|
273
|
+
onRowPress={dashboard.linkToDetail !== false ? onRowPress : undefined}
|
|
274
|
+
defaultSort={dashboard.timestampColumn}
|
|
275
|
+
/>
|
|
276
|
+
</ScrollView>
|
|
277
|
+
);
|
|
278
|
+
}
|