@growth-operations/ui-shared 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/alerts/AlertsTab.jsx +246 -0
- package/src/index.js +7 -0
- package/src/lib/format.js +14 -0
- package/src/sdk/app/alerts.js +28 -7
- package/src/usage/UsageTab.jsx +141 -0
package/package.json
CHANGED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// AlertsTab — the shared customer alerts table, extensible per app.
|
|
2
|
+
//
|
|
3
|
+
// Every Growth Ops app surfaces the same base Alert shape ({ id, level, title,
|
|
4
|
+
// message, created_at, dismissable, action_name, action_url }) from the same
|
|
5
|
+
// serve/dismiss contract (common.alerts + the app's alerts router). This owns
|
|
6
|
+
// the shared chrome — level filter, pagination, fetch, and per-row dismiss —
|
|
7
|
+
// and exposes two extension points so apps with richer alert shapes (sparkfly's
|
|
8
|
+
// merge / replace-email / skip; toast's connection-lost retry) keep one table
|
|
9
|
+
// instead of forking it:
|
|
10
|
+
//
|
|
11
|
+
// - renderRowActions(alert, { context, refresh }) -> node
|
|
12
|
+
// Extra action UI rendered in the actions cell ALONGSIDE Dismiss. Use for
|
|
13
|
+
// app-specific buttons that act on the alert then refresh the table.
|
|
14
|
+
// - renderRow(alert, { context, refresh, dismiss, fetchOptions }) -> <TableRow>
|
|
15
|
+
// Full per-row override (escape hatch) for apps whose row needs custom
|
|
16
|
+
// layout — expandable merge panels, inline inputs, extra columns. When
|
|
17
|
+
// provided it replaces the default row entirely; the app owns the cells.
|
|
18
|
+
//
|
|
19
|
+
// Omit both -> default rows (level tag, title/message + action link, time,
|
|
20
|
+
// Dismiss). Self-hosted apps (sparkfly, toast) call with default getAlerts/
|
|
21
|
+
// dismissAlert paths; base-hosted apps pass `basePath` + `portalId` so the SDK
|
|
22
|
+
// targets the base alerts route.
|
|
23
|
+
import React, { useState, useEffect } from "react";
|
|
24
|
+
import {
|
|
25
|
+
Alert,
|
|
26
|
+
EmptyState,
|
|
27
|
+
Text,
|
|
28
|
+
Link,
|
|
29
|
+
Button,
|
|
30
|
+
Table,
|
|
31
|
+
TableHead,
|
|
32
|
+
TableHeader,
|
|
33
|
+
TableBody,
|
|
34
|
+
TableRow,
|
|
35
|
+
TableCell,
|
|
36
|
+
Select,
|
|
37
|
+
Tag,
|
|
38
|
+
Flex,
|
|
39
|
+
LoadingSpinner,
|
|
40
|
+
} from "@hubspot/ui-extensions";
|
|
41
|
+
|
|
42
|
+
import { getAlerts, dismissAlert } from "../sdk/app/alerts";
|
|
43
|
+
import { fmtDate } from "../lib/format";
|
|
44
|
+
|
|
45
|
+
const PAGE_SIZE = 10;
|
|
46
|
+
|
|
47
|
+
const LEVEL_OPTIONS = [
|
|
48
|
+
{ label: "All Levels", value: "all" },
|
|
49
|
+
{ label: "Info", value: "info" },
|
|
50
|
+
{ label: "Warning", value: "warning" },
|
|
51
|
+
{ label: "Error", value: "error" },
|
|
52
|
+
{ label: "Critical", value: "critical" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// Alert level -> HubSpot Tag variant.
|
|
56
|
+
const LEVEL_VARIANT = {
|
|
57
|
+
info: "default",
|
|
58
|
+
warning: "warning",
|
|
59
|
+
error: "error",
|
|
60
|
+
critical: "error",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function DefaultAlertRow({
|
|
64
|
+
context,
|
|
65
|
+
alert,
|
|
66
|
+
refresh,
|
|
67
|
+
fetchOptions,
|
|
68
|
+
renderRowActions,
|
|
69
|
+
}) {
|
|
70
|
+
const [dismissing, setDismissing] = useState(false);
|
|
71
|
+
|
|
72
|
+
const dismiss = async () => {
|
|
73
|
+
setDismissing(true);
|
|
74
|
+
try {
|
|
75
|
+
await dismissAlert(context, alert.id, fetchOptions);
|
|
76
|
+
await refresh();
|
|
77
|
+
} finally {
|
|
78
|
+
setDismissing(false);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<TableRow>
|
|
84
|
+
<TableCell>
|
|
85
|
+
<Tag variant={LEVEL_VARIANT[alert.level] || "default"}>
|
|
86
|
+
{alert.level}
|
|
87
|
+
</Tag>
|
|
88
|
+
</TableCell>
|
|
89
|
+
<TableCell>
|
|
90
|
+
<Flex direction="column" gap="extra-small">
|
|
91
|
+
<Text format={{ fontWeight: "bold" }}>{alert.title}</Text>
|
|
92
|
+
<Text>{alert.message}</Text>
|
|
93
|
+
{alert.action_url && alert.action_name && (
|
|
94
|
+
<Link href={alert.action_url}>{alert.action_name}</Link>
|
|
95
|
+
)}
|
|
96
|
+
</Flex>
|
|
97
|
+
</TableCell>
|
|
98
|
+
<TableCell>{fmtDate(alert.created_at)}</TableCell>
|
|
99
|
+
<TableCell>
|
|
100
|
+
<Flex direction="row" gap="small" align="center">
|
|
101
|
+
{/* App-specific actions render before Dismiss. */}
|
|
102
|
+
{renderRowActions && renderRowActions(alert, { context, refresh })}
|
|
103
|
+
{alert.dismissable && (
|
|
104
|
+
<Button
|
|
105
|
+
variant="secondary"
|
|
106
|
+
size="xs"
|
|
107
|
+
disabled={dismissing}
|
|
108
|
+
onClick={dismiss}
|
|
109
|
+
>
|
|
110
|
+
{dismissing ? "Dismissing…" : "Dismiss"}
|
|
111
|
+
</Button>
|
|
112
|
+
)}
|
|
113
|
+
</Flex>
|
|
114
|
+
</TableCell>
|
|
115
|
+
</TableRow>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// context: extension context. basePath/portalId: forwarded to getAlerts/
|
|
120
|
+
// dismissAlert (base-hosted apps pass both; self-hosted apps omit them).
|
|
121
|
+
// renderRowActions / renderRow: per-app extension points (see file header).
|
|
122
|
+
export function AlertsTab({
|
|
123
|
+
context,
|
|
124
|
+
basePath,
|
|
125
|
+
portalId,
|
|
126
|
+
renderRowActions,
|
|
127
|
+
renderRow,
|
|
128
|
+
}) {
|
|
129
|
+
const [alerts, setAlerts] = useState([]);
|
|
130
|
+
const [total, setTotal] = useState(0);
|
|
131
|
+
const [page, setPage] = useState(1);
|
|
132
|
+
const [level, setLevel] = useState("all");
|
|
133
|
+
const [loading, setLoading] = useState(true);
|
|
134
|
+
const [error, setError] = useState(null);
|
|
135
|
+
|
|
136
|
+
// The options the SDK needs to target the right backend (base vs self-hosted).
|
|
137
|
+
const fetchOptions = { basePath, portalId };
|
|
138
|
+
|
|
139
|
+
const load = async (pageNumber, levelValue) => {
|
|
140
|
+
setLoading(true);
|
|
141
|
+
try {
|
|
142
|
+
const response = await getAlerts(context, {
|
|
143
|
+
offset: (pageNumber - 1) * PAGE_SIZE,
|
|
144
|
+
limit: PAGE_SIZE,
|
|
145
|
+
orderBy: "created_at",
|
|
146
|
+
orderDirection: "DESCENDING",
|
|
147
|
+
filterField: levelValue !== "all" ? "level" : undefined,
|
|
148
|
+
filterValue: levelValue !== "all" ? levelValue : undefined,
|
|
149
|
+
...fetchOptions,
|
|
150
|
+
});
|
|
151
|
+
setAlerts(response.alerts || []);
|
|
152
|
+
setTotal(response.total || 0);
|
|
153
|
+
setPage(pageNumber);
|
|
154
|
+
setError(null);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
setError(err.message);
|
|
157
|
+
setAlerts([]);
|
|
158
|
+
setTotal(0);
|
|
159
|
+
} finally {
|
|
160
|
+
setLoading(false);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
load(1, level);
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
}, [context]);
|
|
168
|
+
|
|
169
|
+
const onLevelChange = (value) => {
|
|
170
|
+
setLevel(value);
|
|
171
|
+
load(1, value);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (error) {
|
|
175
|
+
return (
|
|
176
|
+
<Alert title="Couldn't load alerts" variant="error">
|
|
177
|
+
{error}
|
|
178
|
+
</Alert>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<Flex direction="column" gap="medium">
|
|
184
|
+
<Select
|
|
185
|
+
label="Filter by level"
|
|
186
|
+
name="levelFilter"
|
|
187
|
+
options={LEVEL_OPTIONS}
|
|
188
|
+
value={level}
|
|
189
|
+
onChange={onLevelChange}
|
|
190
|
+
/>
|
|
191
|
+
{loading ? (
|
|
192
|
+
<LoadingSpinner showLabel label="Loading alerts…" />
|
|
193
|
+
) : !alerts.length ? (
|
|
194
|
+
<EmptyState title="No alerts" layout="vertical">
|
|
195
|
+
<Text>There are currently no alerts that need your attention.</Text>
|
|
196
|
+
</EmptyState>
|
|
197
|
+
) : (
|
|
198
|
+
<Table
|
|
199
|
+
paginated={total > PAGE_SIZE}
|
|
200
|
+
page={page}
|
|
201
|
+
pageCount={Math.ceil(total / PAGE_SIZE)}
|
|
202
|
+
onPageChange={(p) => load(p, level)}
|
|
203
|
+
>
|
|
204
|
+
<TableHead>
|
|
205
|
+
<TableHeader width="min">Level</TableHeader>
|
|
206
|
+
<TableHeader width="max">Alert</TableHeader>
|
|
207
|
+
<TableHeader width="min">Time</TableHeader>
|
|
208
|
+
<TableHeader width="min"></TableHeader>
|
|
209
|
+
</TableHead>
|
|
210
|
+
<TableBody>
|
|
211
|
+
{alerts.map((alert) => {
|
|
212
|
+
const refresh = () => load(page, level);
|
|
213
|
+
// Full-row override escape hatch: the app owns the cells.
|
|
214
|
+
if (renderRow) {
|
|
215
|
+
const dismiss = () =>
|
|
216
|
+
dismissAlert(context, alert.id, fetchOptions).then(refresh);
|
|
217
|
+
return (
|
|
218
|
+
<React.Fragment key={alert.id}>
|
|
219
|
+
{renderRow(alert, {
|
|
220
|
+
context,
|
|
221
|
+
refresh,
|
|
222
|
+
dismiss,
|
|
223
|
+
fetchOptions,
|
|
224
|
+
})}
|
|
225
|
+
</React.Fragment>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return (
|
|
229
|
+
<DefaultAlertRow
|
|
230
|
+
key={alert.id}
|
|
231
|
+
context={context}
|
|
232
|
+
alert={alert}
|
|
233
|
+
fetchOptions={fetchOptions}
|
|
234
|
+
refresh={refresh}
|
|
235
|
+
renderRowActions={renderRowActions}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
238
|
+
})}
|
|
239
|
+
</TableBody>
|
|
240
|
+
</Table>
|
|
241
|
+
)}
|
|
242
|
+
</Flex>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export default AlertsTab;
|
package/src/index.js
CHANGED
|
@@ -18,9 +18,16 @@ export { HelpfulLinks } from "./home/HelpfulLinks";
|
|
|
18
18
|
export { BillingTab } from "./billing/BillingTab";
|
|
19
19
|
export { PlanGrid } from "./billing/PlanGrid";
|
|
20
20
|
|
|
21
|
+
// Alerts — the shared customer alerts table (level filter, sort, dismiss).
|
|
22
|
+
export { AlertsTab } from "./alerts/AlertsTab";
|
|
23
|
+
|
|
24
|
+
// Usage — the shared credit usage view (per-day LineChart + window stats).
|
|
25
|
+
export { UsageTab } from "./usage/UsageTab";
|
|
26
|
+
|
|
21
27
|
// Lib helpers.
|
|
22
28
|
export {
|
|
23
29
|
fmtDate,
|
|
30
|
+
fmtDateTime,
|
|
24
31
|
fmtMoney,
|
|
25
32
|
daysUntil,
|
|
26
33
|
humanizeStatus,
|
package/src/lib/format.js
CHANGED
|
@@ -14,6 +14,20 @@ export function fmtDate(value) {
|
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Date + time, for logs where multiple events share a day (usage history). "—"
|
|
18
|
+
// when null/unparseable.
|
|
19
|
+
export function fmtDateTime(value) {
|
|
20
|
+
if (value == null) return "—";
|
|
21
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
22
|
+
if (Number.isNaN(d.getTime())) return "—";
|
|
23
|
+
return d.toLocaleString(undefined, {
|
|
24
|
+
month: "short",
|
|
25
|
+
day: "numeric",
|
|
26
|
+
hour: "numeric",
|
|
27
|
+
minute: "2-digit",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
// Whole days from now until `value` (negative = past). null if unparseable.
|
|
18
32
|
export function daysUntil(value) {
|
|
19
33
|
if (value == null) return null;
|
package/src/sdk/app/alerts.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
// getAlerts / dismissAlert — generic alerts API client.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Two backend shapes share this client:
|
|
4
|
+
// - SELF-HOSTED apps (sparkfly, toast) serve /api/v1/alerts from their OWN
|
|
5
|
+
// service and resolve portalId from the verified token. Default basePath,
|
|
6
|
+
// no portalId needed.
|
|
7
|
+
// - BASE-HOSTED credit apps (line-item, …) serve from the base service at
|
|
8
|
+
// /v1/hubspot/app_pages/{app}/alerts and require ?portalId=. Pass
|
|
9
|
+
// `basePath` + `portalId` to target that route.
|
|
10
|
+
//
|
|
5
11
|
// Goes through callAppApi, so it inherits the hubspot.fetch rules (object body,
|
|
6
|
-
// no custom headers).
|
|
12
|
+
// no custom headers). BASE_URL (the app's own service, or base for base-hosted
|
|
13
|
+
// apps) comes from context.variables.
|
|
7
14
|
import { callAppApi } from "./base";
|
|
8
15
|
|
|
16
|
+
const DEFAULT_BASE_PATH = "/api/v1/alerts";
|
|
17
|
+
|
|
9
18
|
// context: extension context. params: { offset, limit, orderBy, orderDirection,
|
|
10
|
-
// filterField, filterValue }.
|
|
19
|
+
// filterField, filterValue, basePath, portalId }. basePath defaults to the
|
|
20
|
+
// self-hosted /api/v1/alerts; portalId is appended when set (base-hosted apps).
|
|
21
|
+
// Returns the backend's alerts response.
|
|
11
22
|
export async function getAlerts(context, params = {}) {
|
|
12
23
|
const {
|
|
13
24
|
offset = 0,
|
|
@@ -16,6 +27,8 @@ export async function getAlerts(context, params = {}) {
|
|
|
16
27
|
orderDirection,
|
|
17
28
|
filterField,
|
|
18
29
|
filterValue,
|
|
30
|
+
basePath = DEFAULT_BASE_PATH,
|
|
31
|
+
portalId,
|
|
19
32
|
} = params;
|
|
20
33
|
const queryParams = new URLSearchParams();
|
|
21
34
|
queryParams.append("offset", offset);
|
|
@@ -31,11 +44,19 @@ export async function getAlerts(context, params = {}) {
|
|
|
31
44
|
queryParams.append("filter_field", filterField);
|
|
32
45
|
queryParams.append("filter_value", filterValue);
|
|
33
46
|
}
|
|
34
|
-
|
|
47
|
+
if (portalId != null) {
|
|
48
|
+
queryParams.append("portalId", portalId);
|
|
49
|
+
}
|
|
50
|
+
const path = `${basePath}?${queryParams.toString()}`;
|
|
35
51
|
return callAppApi(context, path, "GET");
|
|
36
52
|
}
|
|
37
53
|
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
// context, alertId, options: { basePath, portalId }. basePath defaults to the
|
|
55
|
+
// self-hosted /api/v1/alerts; portalId is appended when set (base-hosted apps).
|
|
56
|
+
export async function dismissAlert(context, alertId, options = {}) {
|
|
57
|
+
const { basePath = DEFAULT_BASE_PATH, portalId } = options;
|
|
58
|
+
const query =
|
|
59
|
+
portalId != null ? `?portalId=${encodeURIComponent(portalId)}` : "";
|
|
60
|
+
const path = `${basePath}/${alertId}/dismiss${query}`;
|
|
40
61
|
return callAppApi(context, path, "POST");
|
|
41
62
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// UsageTab — the shared in-app credit usage view for credit apps.
|
|
2
|
+
//
|
|
3
|
+
// The off-Anvil answer to "show usage directly in-app", chart-first (matches
|
|
4
|
+
// the sparkfly/toast usage tabs): a LineChart of credits used per day over a
|
|
5
|
+
// window + headline Statistics (used in window / remaining / granted). Fed by
|
|
6
|
+
// the backend usage series at {basePath}/usage?portalId=&days= — credit_meter
|
|
7
|
+
// .recent on the home payload is only the last ~20 events, so the chart reads
|
|
8
|
+
// the dedicated, zero-filled, server-bucketed series instead.
|
|
9
|
+
//
|
|
10
|
+
// Self-hosted apps (sparkfly/toast) serve /api/v1/usage from their own service;
|
|
11
|
+
// base-hosted credit apps (line-item) pass `basePath` + `portalId` to hit base's
|
|
12
|
+
// /v1/hubspot/app_pages/{app}/usage. Trial/non-credit apps don't render this.
|
|
13
|
+
import React, { useState } from "react";
|
|
14
|
+
import {
|
|
15
|
+
hubspot,
|
|
16
|
+
Flex,
|
|
17
|
+
Heading,
|
|
18
|
+
Text,
|
|
19
|
+
Tile,
|
|
20
|
+
Statistics,
|
|
21
|
+
StatisticsItem,
|
|
22
|
+
LineChart,
|
|
23
|
+
Select,
|
|
24
|
+
LoadingSpinner,
|
|
25
|
+
Alert,
|
|
26
|
+
EmptyState,
|
|
27
|
+
} from "@hubspot/ui-extensions";
|
|
28
|
+
|
|
29
|
+
import { useStrictModeEffect } from "../lib/useStrictModeEffect";
|
|
30
|
+
|
|
31
|
+
const DEFAULT_BASE_PATH = "/api/v1/usage";
|
|
32
|
+
|
|
33
|
+
const WINDOW_OPTIONS = [
|
|
34
|
+
{ label: "Last 7 days", value: "7" },
|
|
35
|
+
{ label: "Last 30 days", value: "30" },
|
|
36
|
+
{ label: "Last 90 days", value: "90" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// context: extension context. basePath/portalId: target the right backend
|
|
40
|
+
// (base-hosted apps pass both). appName: chart copy.
|
|
41
|
+
export function UsageTab({
|
|
42
|
+
context,
|
|
43
|
+
basePath = DEFAULT_BASE_PATH,
|
|
44
|
+
portalId,
|
|
45
|
+
appName = "this app",
|
|
46
|
+
}) {
|
|
47
|
+
const [data, setData] = useState(null);
|
|
48
|
+
const [days, setDays] = useState("30");
|
|
49
|
+
const [loading, setLoading] = useState(true);
|
|
50
|
+
const [error, setError] = useState(null);
|
|
51
|
+
|
|
52
|
+
useStrictModeEffect(
|
|
53
|
+
async ({ mounted }) => {
|
|
54
|
+
setLoading(true);
|
|
55
|
+
try {
|
|
56
|
+
const base = context.variables?.BASE_URL ?? "";
|
|
57
|
+
const pid = portalId ?? context.portal.id;
|
|
58
|
+
const url = `${base}${basePath}?portalId=${pid}&days=${days}`;
|
|
59
|
+
const res = await hubspot
|
|
60
|
+
.fetch(url, { method: "GET", timeout: 15000 })
|
|
61
|
+
.then((r) => r.json());
|
|
62
|
+
if (mounted.current) {
|
|
63
|
+
setData(res);
|
|
64
|
+
setError(null);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (mounted.current) setError(String(err));
|
|
68
|
+
} finally {
|
|
69
|
+
if (mounted.current) setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
[context, days]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (loading) return <LoadingSpinner showLabel label="Loading usage…" />;
|
|
76
|
+
|
|
77
|
+
if (error) {
|
|
78
|
+
return (
|
|
79
|
+
<Flex direction="column" gap="medium">
|
|
80
|
+
<Heading>Usage</Heading>
|
|
81
|
+
<Alert title="Couldn't load usage" variant="warning">
|
|
82
|
+
<Text>{error}</Text>
|
|
83
|
+
</Alert>
|
|
84
|
+
</Flex>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const series = data?.series ?? [];
|
|
89
|
+
const usedInWindow = data?.used_in_window ?? 0;
|
|
90
|
+
const remaining = data?.remaining ?? 0;
|
|
91
|
+
const granted = data?.granted ?? 0;
|
|
92
|
+
const hasActivity = usedInWindow > 0;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Flex direction="column" gap="medium">
|
|
96
|
+
<Flex direction="row" justify="between" align="end">
|
|
97
|
+
<Heading>Usage</Heading>
|
|
98
|
+
<Select
|
|
99
|
+
label="Window"
|
|
100
|
+
name="window"
|
|
101
|
+
options={WINDOW_OPTIONS}
|
|
102
|
+
value={days}
|
|
103
|
+
onChange={setDays}
|
|
104
|
+
/>
|
|
105
|
+
</Flex>
|
|
106
|
+
|
|
107
|
+
<Tile>
|
|
108
|
+
<Statistics>
|
|
109
|
+
<StatisticsItem label="Credits used (window)" number={usedInWindow} />
|
|
110
|
+
<StatisticsItem label="Remaining" number={remaining} />
|
|
111
|
+
<StatisticsItem label="Granted" number={granted} />
|
|
112
|
+
</Statistics>
|
|
113
|
+
</Tile>
|
|
114
|
+
|
|
115
|
+
{!hasActivity ? (
|
|
116
|
+
<EmptyState title="No usage yet" layout="vertical">
|
|
117
|
+
<Text>
|
|
118
|
+
Once you run a metered {appName} action, your credit usage shows here
|
|
119
|
+
over time.
|
|
120
|
+
</Text>
|
|
121
|
+
</EmptyState>
|
|
122
|
+
) : (
|
|
123
|
+
<Tile>
|
|
124
|
+
<Flex direction="column" gap="small">
|
|
125
|
+
<Text format={{ fontWeight: "bold" }}>Credits used per day</Text>
|
|
126
|
+
<LineChart
|
|
127
|
+
data={series}
|
|
128
|
+
axes={{
|
|
129
|
+
x: { field: "date", fieldType: "datetime", label: "Day" },
|
|
130
|
+
y: { field: "credits", fieldType: "linear", label: "Credits" },
|
|
131
|
+
}}
|
|
132
|
+
options={{ showTooltips: true }}
|
|
133
|
+
/>
|
|
134
|
+
</Flex>
|
|
135
|
+
</Tile>
|
|
136
|
+
)}
|
|
137
|
+
</Flex>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default UsageTab;
|