@alepha/devtools 0.19.1 → 0.19.2

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.
@@ -1,15 +1,15 @@
1
1
  import {
2
- ActionButton,
3
2
  DarkModeButton,
4
3
  DashboardShell,
5
4
  Flex,
6
5
  OmnibarButton,
7
6
  } from "@alepha/ui";
8
7
  import {
9
- IconArrowLeft,
10
8
  IconDashboard,
11
9
  IconDatabase,
12
10
  IconList,
11
+ IconMail,
12
+ IconMessage,
13
13
  IconSettings,
14
14
  IconSitemap,
15
15
  IconTopologyRing,
@@ -68,6 +68,17 @@ export const DevLayout = () => {
68
68
  href: "/conf/env",
69
69
  },
70
70
  { type: "divider" },
71
+ {
72
+ label: "Emails",
73
+ icon: <IconMail />,
74
+ href: "/emails",
75
+ },
76
+ {
77
+ label: "SMS",
78
+ icon: <IconMessage />,
79
+ href: "/sms",
80
+ },
81
+ { type: "divider" },
71
82
  {
72
83
  label: "Graph",
73
84
  icon: <IconTopologyRing />,
@@ -96,19 +107,7 @@ export const DevLayout = () => {
96
107
  navbarFooter={<Flex />}
97
108
  footerHeight={24}
98
109
  headerHeight={60}
99
- navbarHeader={() => (
100
- <Flex align="center" justify="center" h="100%" w="100%">
101
- <ActionButton
102
- href="/"
103
- target="_self"
104
- variant="subtle"
105
- color="gray"
106
- size="sm"
107
- >
108
- <IconArrowLeft size={18} />
109
- </ActionButton>
110
- </Flex>
111
- )}
110
+ navbarHeader={() => <Flex />}
112
111
  sidebarProps={{
113
112
  collapsed: true,
114
113
  items: sidebarItems,
@@ -80,7 +80,7 @@ const EnvLine = ({ variable }: { variable: EnvVariable }) => {
80
80
  const sensitive = isSensitive(variable.name);
81
81
 
82
82
  return (
83
- <Flex>
83
+ <Flex direction="column">
84
84
  {/* Description as comment */}
85
85
  {variable.description?.split("\n").map((line, i) => (
86
86
  <Text key={i} fz={11} ff="monospace" c="dimmed" fs="italic" lh={1.6}>
@@ -1,5 +1,5 @@
1
1
  import { devMetadataSchema } from "@alepha/devtools";
2
- import { Flex, ui } from "@alepha/ui";
2
+ import { ActionButton, Flex, ui } from "@alepha/ui";
3
3
  import {
4
4
  Badge,
5
5
  Card,
@@ -19,6 +19,7 @@ import {
19
19
  IconDatabase,
20
20
  IconFileText,
21
21
  IconPlug,
22
+ IconRefresh,
22
23
  IconRoute,
23
24
  IconServer,
24
25
  IconSettings,
@@ -93,6 +94,7 @@ export const DevDashboard = () => {
93
94
  const [metadata, setMetadata] = useState<any>(null);
94
95
  const [logs, setLogs] = useState<LogEntry[]>([]);
95
96
  const [events, setEvents] = useState<LogEntry[]>([]);
97
+ const [reloading, setReloading] = useState(false);
96
98
 
97
99
  const fetchData = useCallback(async () => {
98
100
  if (document.visibilityState !== "visible") return;
@@ -112,6 +114,19 @@ export const DevDashboard = () => {
112
114
  }
113
115
  }, [http]);
114
116
 
117
+ const handleReload = useCallback(async () => {
118
+ setReloading(true);
119
+ try {
120
+ await http.fetch("/__devtools/api/reload", { method: "POST" });
121
+ setTimeout(() => {
122
+ fetchData();
123
+ setReloading(false);
124
+ }, 1000);
125
+ } catch {
126
+ setReloading(false);
127
+ }
128
+ }, [http, fetchData]);
129
+
115
130
  useEffect(() => {
116
131
  fetchData();
117
132
  const interval = setInterval(fetchData, 10_000);
@@ -191,17 +206,21 @@ export const DevDashboard = () => {
191
206
  <Flex direction="column" gap="lg">
192
207
  {/* App Stats */}
193
208
  <div>
194
- <Title
195
- order={5}
196
- mb="sm"
197
- c="dimmed"
198
- tt="uppercase"
199
- fz="xs"
200
- fw={600}
201
- lts={1}
202
- >
203
- System
204
- </Title>
209
+ <Flex justify="space-between" align="center" mb="sm">
210
+ <Title order={5} c="dimmed" tt="uppercase" fz="xs" fw={600} lts={1}>
211
+ System
212
+ </Title>
213
+ <ActionButton
214
+ size="xs"
215
+ variant="subtle"
216
+ color="gray"
217
+ loading={reloading}
218
+ onClick={handleReload}
219
+ icon={<IconRefresh size={14} />}
220
+ >
221
+ Reload
222
+ </ActionButton>
223
+ </Flex>
205
224
  <SimpleGrid cols={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing="sm">
206
225
  {system && (
207
226
  <>
@@ -16,7 +16,7 @@ import {
16
16
  import { jsonSchemaToTypeBox, t } from "alepha";
17
17
  import { useInject } from "alepha/react";
18
18
  import { useForm } from "alepha/react/form";
19
- import { useRouter } from "alepha/react/router";
19
+ import { useRouter, useRouterState } from "alepha/react/router";
20
20
  import { HttpClient } from "alepha/server";
21
21
  import { useCallback, useEffect, useMemo, useState } from "react";
22
22
  import { TreeView, type TreeViewNode } from "../shared/TreeView.tsx";
@@ -107,12 +107,15 @@ const parseEditorPath = (pathname: string) => {
107
107
  export const DatabaseEditor = ({ entities }: { entities: any[] }) => {
108
108
  const http = useInject(HttpClient);
109
109
  const router = useRouter();
110
+ const state = useRouterState();
110
111
  const [records, setRecords] = useState<any[]>([]);
111
112
  const [loading, setLoading] = useState(false);
112
113
  const [search, setSearch] = useState("");
113
114
  const [pageInfo, setPageInfo] = useState<any>(null);
114
115
 
115
- const { table: selectedEntity, recordId } = parseEditorPath(router.pathname);
116
+ const { table: selectedEntity, recordId } = parseEditorPath(
117
+ state.url.pathname,
118
+ );
116
119
  const isNew = recordId === "new";
117
120
 
118
121
  const entity = entities.find((e) => e.name === selectedEntity);
@@ -0,0 +1,250 @@
1
+ import { Flex, ui } from "@alepha/ui";
2
+ import { Badge, CloseButton, ScrollArea, Text, TextInput } from "@mantine/core";
3
+ import { IconSearch } from "@tabler/icons-react";
4
+ import { useInject } from "alepha/react";
5
+ import { HttpClient } from "alepha/server";
6
+ import { useCallback, useEffect, useState } from "react";
7
+
8
+ interface EmailEntry {
9
+ to: string;
10
+ subject: string;
11
+ body: string;
12
+ sentAt: string;
13
+ }
14
+
15
+ const formatDate = (iso: string): string => {
16
+ const d = new Date(iso);
17
+ return d.toLocaleString();
18
+ };
19
+
20
+ const formatRelative = (iso: string): string => {
21
+ const diff = Date.now() - new Date(iso).getTime();
22
+ if (diff < 1000) return "just now";
23
+ if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
24
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
25
+ return `${Math.floor(diff / 3_600_000)}h ago`;
26
+ };
27
+
28
+ export const DevEmails = () => {
29
+ const http = useInject(HttpClient);
30
+ const [emails, setEmails] = useState<EmailEntry[]>([]);
31
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
32
+ const [search, setSearch] = useState("");
33
+
34
+ const fetchEmails = useCallback(async () => {
35
+ if (document.visibilityState !== "visible") return;
36
+ try {
37
+ const res = await http.fetch("/__devtools/api/emails");
38
+ const data = res.data as any;
39
+ setEmails(data?.emails ?? []);
40
+ } catch {
41
+ // silently fail
42
+ }
43
+ }, [http]);
44
+
45
+ useEffect(() => {
46
+ fetchEmails();
47
+ const interval = setInterval(fetchEmails, 10_000);
48
+ return () => clearInterval(interval);
49
+ }, [fetchEmails]);
50
+
51
+ const filtered = emails.filter((email) => {
52
+ if (!search) return true;
53
+ const q = search.toLowerCase();
54
+ return (
55
+ email.to.toLowerCase().includes(q) ||
56
+ email.subject.toLowerCase().includes(q)
57
+ );
58
+ });
59
+
60
+ const selectedEmail = selectedIndex !== null ? filtered[selectedIndex] : null;
61
+
62
+ return (
63
+ <Flex style={{ flex: 1, overflow: "hidden" }} direction="column">
64
+ {/* Filter bar */}
65
+ <Flex
66
+ px="md"
67
+ py="xs"
68
+ gap="sm"
69
+ align="center"
70
+ style={{
71
+ borderBottom: `1px solid ${ui.colors.border}`,
72
+ flexShrink: 0,
73
+ }}
74
+ >
75
+ <TextInput
76
+ size="xs"
77
+ placeholder="Search..."
78
+ leftSection={<IconSearch size={14} />}
79
+ value={search}
80
+ onChange={(e) => setSearch(e.currentTarget.value)}
81
+ style={{ flex: 1, minWidth: 150, maxWidth: 300 }}
82
+ />
83
+ <Badge variant="light" color="gray" size="sm">
84
+ {filtered.length} emails
85
+ </Badge>
86
+ </Flex>
87
+
88
+ {/* Main area: list + detail */}
89
+ <Flex style={{ flex: 1, overflow: "hidden" }}>
90
+ {/* Email list */}
91
+ <Flex direction="column" style={{ flex: 1, overflow: "hidden" }}>
92
+ <ScrollArea style={{ flex: 1 }}>
93
+ {filtered.length === 0 && (
94
+ <Flex align="center" justify="center" py="xl" c="dimmed">
95
+ <Text fz="sm">No emails to display</Text>
96
+ </Flex>
97
+ )}
98
+ {filtered.map((email, i) => {
99
+ const isSelected = selectedIndex === i;
100
+
101
+ return (
102
+ <Flex
103
+ key={`${email.sentAt}-${i}`}
104
+ direction="column"
105
+ px="md"
106
+ py="xs"
107
+ onClick={() => setSelectedIndex(isSelected ? null : i)}
108
+ style={{
109
+ borderBottom: `1px solid ${ui.colors.border}20`,
110
+ background: isSelected ? ui.colors.elevated : "transparent",
111
+ cursor: "pointer",
112
+ transition: "background 100ms",
113
+ }}
114
+ onMouseEnter={(e) => {
115
+ if (!isSelected) {
116
+ (e.currentTarget as HTMLElement).style.background =
117
+ `${ui.colors.elevated}80`;
118
+ }
119
+ }}
120
+ onMouseLeave={(e) => {
121
+ if (!isSelected) {
122
+ (e.currentTarget as HTMLElement).style.background =
123
+ "transparent";
124
+ }
125
+ }}
126
+ >
127
+ <Flex justify="space-between" align="center">
128
+ <Text fz="sm" truncate>
129
+ {email.to}
130
+ </Text>
131
+ <Text fz={11} c="dimmed" style={{ flexShrink: 0 }}>
132
+ {formatRelative(email.sentAt)}
133
+ </Text>
134
+ </Flex>
135
+ <Text fz="xs" c="dimmed" truncate>
136
+ {email.subject}
137
+ </Text>
138
+ </Flex>
139
+ );
140
+ })}
141
+ </ScrollArea>
142
+ </Flex>
143
+
144
+ {/* Detail panel */}
145
+ {selectedEmail && (
146
+ <Flex
147
+ w={500}
148
+ direction="column"
149
+ style={{
150
+ borderLeft: `1px solid ${ui.colors.border}`,
151
+ flexShrink: 0,
152
+ overflow: "hidden",
153
+ }}
154
+ >
155
+ <Flex
156
+ px="md"
157
+ py="xs"
158
+ align="center"
159
+ justify="space-between"
160
+ style={{
161
+ borderBottom: `1px solid ${ui.colors.border}`,
162
+ flexShrink: 0,
163
+ }}
164
+ >
165
+ <Text fz="xs" fw={600} tt="uppercase" c="dimmed" lts={0.5}>
166
+ Email Detail
167
+ </Text>
168
+ <CloseButton size="xs" onClick={() => setSelectedIndex(null)} />
169
+ </Flex>
170
+ <ScrollArea style={{ flex: 1 }} p="md">
171
+ <Flex direction="column" gap="md">
172
+ {/* To */}
173
+ <Flex direction="column">
174
+ <Text
175
+ fz={10}
176
+ c="dimmed"
177
+ tt="uppercase"
178
+ fw={600}
179
+ lts={0.5}
180
+ mb={4}
181
+ >
182
+ To
183
+ </Text>
184
+ <Text fz="xs">{selectedEmail.to}</Text>
185
+ </Flex>
186
+
187
+ {/* Date */}
188
+ <Flex direction="column">
189
+ <Text
190
+ fz={10}
191
+ c="dimmed"
192
+ tt="uppercase"
193
+ fw={600}
194
+ lts={0.5}
195
+ mb={4}
196
+ >
197
+ Date
198
+ </Text>
199
+ <Text fz="xs">{formatDate(selectedEmail.sentAt)}</Text>
200
+ </Flex>
201
+
202
+ {/* Subject */}
203
+ <Flex direction="column">
204
+ <Text
205
+ fz={10}
206
+ c="dimmed"
207
+ tt="uppercase"
208
+ fw={600}
209
+ lts={0.5}
210
+ mb={4}
211
+ >
212
+ Subject
213
+ </Text>
214
+ <Text fz="xs">{selectedEmail.subject}</Text>
215
+ </Flex>
216
+
217
+ {/* Body */}
218
+ <Flex direction="column">
219
+ <Text
220
+ fz={10}
221
+ c="dimmed"
222
+ tt="uppercase"
223
+ fw={600}
224
+ lts={0.5}
225
+ mb={4}
226
+ >
227
+ Body
228
+ </Text>
229
+ <div
230
+ style={{
231
+ background: "white",
232
+ color: "#333",
233
+ border: "1px solid #ccc",
234
+ borderRadius: 4,
235
+ padding: 12,
236
+ }}
237
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: devtools renders developer's own email HTML
238
+ dangerouslySetInnerHTML={{ __html: selectedEmail.body }}
239
+ />
240
+ </Flex>
241
+ </Flex>
242
+ </ScrollArea>
243
+ </Flex>
244
+ )}
245
+ </Flex>
246
+ </Flex>
247
+ );
248
+ };
249
+
250
+ export default DevEmails;
@@ -87,7 +87,7 @@ const buildTree = (metadata: any): TreeNode[] => {
87
87
  for (const page of metadata.pages) {
88
88
  pageNodeMap.set(page.name, {
89
89
  id: `page:${page.name}`,
90
- label: page.label || page.name,
90
+ label: page.name,
91
91
  type: "page" as const,
92
92
  data: page,
93
93
  children: [],
@@ -0,0 +1,225 @@
1
+ import { Flex, ui } from "@alepha/ui";
2
+ import { Badge, CloseButton, ScrollArea, Text, TextInput } from "@mantine/core";
3
+ import { IconSearch } from "@tabler/icons-react";
4
+ import { useInject } from "alepha/react";
5
+ import { HttpClient } from "alepha/server";
6
+ import { useCallback, useEffect, useState } from "react";
7
+
8
+ interface SmsEntry {
9
+ to: string;
10
+ message: string;
11
+ sentAt: string;
12
+ }
13
+
14
+ const formatDate = (iso: string): string => {
15
+ const d = new Date(iso);
16
+ return d.toLocaleString();
17
+ };
18
+
19
+ const formatRelative = (iso: string): string => {
20
+ const diff = Date.now() - new Date(iso).getTime();
21
+ if (diff < 1000) return "just now";
22
+ if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
23
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
24
+ return `${Math.floor(diff / 3_600_000)}h ago`;
25
+ };
26
+
27
+ export const DevSms = () => {
28
+ const http = useInject(HttpClient);
29
+ const [messages, setMessages] = useState<SmsEntry[]>([]);
30
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
31
+ const [search, setSearch] = useState("");
32
+
33
+ const fetchMessages = useCallback(async () => {
34
+ if (document.visibilityState !== "visible") return;
35
+ try {
36
+ const res = await http.fetch("/__devtools/api/sms");
37
+ const data = res.data as any;
38
+ setMessages(data?.messages ?? []);
39
+ } catch {
40
+ // silently fail
41
+ }
42
+ }, [http]);
43
+
44
+ useEffect(() => {
45
+ fetchMessages();
46
+ const interval = setInterval(fetchMessages, 10_000);
47
+ return () => clearInterval(interval);
48
+ }, [fetchMessages]);
49
+
50
+ const filtered = messages.filter((sms) => {
51
+ if (!search) return true;
52
+ const q = search.toLowerCase();
53
+ return (
54
+ sms.to.toLowerCase().includes(q) || sms.message.toLowerCase().includes(q)
55
+ );
56
+ });
57
+
58
+ const selectedSms = selectedIndex !== null ? filtered[selectedIndex] : null;
59
+
60
+ return (
61
+ <Flex style={{ flex: 1, overflow: "hidden" }} direction="column">
62
+ {/* Filter bar */}
63
+ <Flex
64
+ px="md"
65
+ py="xs"
66
+ gap="sm"
67
+ align="center"
68
+ style={{
69
+ borderBottom: `1px solid ${ui.colors.border}`,
70
+ flexShrink: 0,
71
+ }}
72
+ >
73
+ <TextInput
74
+ size="xs"
75
+ placeholder="Search..."
76
+ leftSection={<IconSearch size={14} />}
77
+ value={search}
78
+ onChange={(e) => setSearch(e.currentTarget.value)}
79
+ style={{ flex: 1, minWidth: 150, maxWidth: 300 }}
80
+ />
81
+ <Badge variant="light" color="gray" size="sm">
82
+ {filtered.length} messages
83
+ </Badge>
84
+ </Flex>
85
+
86
+ {/* Main area: list + detail */}
87
+ <Flex style={{ flex: 1, overflow: "hidden" }}>
88
+ {/* SMS list */}
89
+ <Flex direction="column" style={{ flex: 1, overflow: "hidden" }}>
90
+ <ScrollArea style={{ flex: 1 }}>
91
+ {filtered.length === 0 && (
92
+ <Flex align="center" justify="center" py="xl" c="dimmed">
93
+ <Text fz="sm">No messages to display</Text>
94
+ </Flex>
95
+ )}
96
+ {filtered.map((sms, i) => {
97
+ const isSelected = selectedIndex === i;
98
+
99
+ return (
100
+ <Flex
101
+ key={`${sms.sentAt}-${i}`}
102
+ direction="column"
103
+ px="md"
104
+ py="xs"
105
+ onClick={() => setSelectedIndex(isSelected ? null : i)}
106
+ style={{
107
+ borderBottom: `1px solid ${ui.colors.border}20`,
108
+ background: isSelected ? ui.colors.elevated : "transparent",
109
+ cursor: "pointer",
110
+ transition: "background 100ms",
111
+ }}
112
+ onMouseEnter={(e) => {
113
+ if (!isSelected) {
114
+ (e.currentTarget as HTMLElement).style.background =
115
+ `${ui.colors.elevated}80`;
116
+ }
117
+ }}
118
+ onMouseLeave={(e) => {
119
+ if (!isSelected) {
120
+ (e.currentTarget as HTMLElement).style.background =
121
+ "transparent";
122
+ }
123
+ }}
124
+ >
125
+ <Flex justify="space-between" align="center">
126
+ <Text fz="sm" truncate>
127
+ {sms.to}
128
+ </Text>
129
+ <Text fz={11} c="dimmed" style={{ flexShrink: 0 }}>
130
+ {formatRelative(sms.sentAt)}
131
+ </Text>
132
+ </Flex>
133
+ <Text fz="xs" c="dimmed" truncate>
134
+ {sms.message}
135
+ </Text>
136
+ </Flex>
137
+ );
138
+ })}
139
+ </ScrollArea>
140
+ </Flex>
141
+
142
+ {/* Detail panel */}
143
+ {selectedSms && (
144
+ <Flex
145
+ w={400}
146
+ direction="column"
147
+ style={{
148
+ borderLeft: `1px solid ${ui.colors.border}`,
149
+ flexShrink: 0,
150
+ overflow: "hidden",
151
+ }}
152
+ >
153
+ <Flex
154
+ px="md"
155
+ py="xs"
156
+ align="center"
157
+ justify="space-between"
158
+ style={{
159
+ borderBottom: `1px solid ${ui.colors.border}`,
160
+ flexShrink: 0,
161
+ }}
162
+ >
163
+ <Text fz="xs" fw={600} tt="uppercase" c="dimmed" lts={0.5}>
164
+ Message Detail
165
+ </Text>
166
+ <CloseButton size="xs" onClick={() => setSelectedIndex(null)} />
167
+ </Flex>
168
+ <ScrollArea style={{ flex: 1 }} p="md">
169
+ <Flex direction="column" gap="md">
170
+ {/* To */}
171
+ <Flex direction="column">
172
+ <Text
173
+ fz={10}
174
+ c="dimmed"
175
+ tt="uppercase"
176
+ fw={600}
177
+ lts={0.5}
178
+ mb={4}
179
+ >
180
+ To
181
+ </Text>
182
+ <Text fz="xs">{selectedSms.to}</Text>
183
+ </Flex>
184
+
185
+ {/* Date */}
186
+ <Flex direction="column">
187
+ <Text
188
+ fz={10}
189
+ c="dimmed"
190
+ tt="uppercase"
191
+ fw={600}
192
+ lts={0.5}
193
+ mb={4}
194
+ >
195
+ Date
196
+ </Text>
197
+ <Text fz="xs">{formatDate(selectedSms.sentAt)}</Text>
198
+ </Flex>
199
+
200
+ {/* Message */}
201
+ <Flex direction="column">
202
+ <Text
203
+ fz={10}
204
+ c="dimmed"
205
+ tt="uppercase"
206
+ fw={600}
207
+ lts={0.5}
208
+ mb={4}
209
+ >
210
+ Message
211
+ </Text>
212
+ <Text fz="xs" style={{ whiteSpace: "pre-wrap" }}>
213
+ {selectedSms.message}
214
+ </Text>
215
+ </Flex>
216
+ </Flex>
217
+ </ScrollArea>
218
+ </Flex>
219
+ )}
220
+ </Flex>
221
+ </Flex>
222
+ );
223
+ };
224
+
225
+ export default DevSms;