@sentry/junior-dashboard 0.234.0 → 0.236.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +100 -18
- package/dist/client.js +47 -46
- package/dist/index.d.ts +3 -1
- package/dist/index.js +100 -18
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/app.ts +7 -1
- package/src/assets/junior-avatar.png +0 -0
- package/src/assets/junior-favicon.png +0 -0
- package/src/assets.ts +1 -0
- package/src/client/App.tsx +35 -0
- package/src/client/api.ts +63 -0
- package/src/client/components/README.md +8 -4
- package/src/client/components/SegmentedNav.tsx +167 -0
- package/src/client/components/SegmentedTabs.tsx +24 -5
- package/src/client/components/layout/SecondaryNavigation.tsx +40 -83
- package/src/client/conversations/ConversationEventLog.tsx +45 -5
- package/src/client/conversations/ConversationForkLinks.tsx +51 -0
- package/src/client/conversations/ConversationHeader.tsx +12 -1
- package/src/client/conversations/ConversationPage.tsx +72 -2
- package/src/client/conversations/ForkConversationButton.tsx +125 -0
- package/src/client/conversations/TranscriptMessageView.tsx +28 -7
- package/src/client/conversations/TranscriptView.tsx +4 -0
- package/src/client/conversations/conversationRoutes.ts +10 -0
- package/src/client/conversations/queries.ts +45 -0
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +28 -27
- package/src/client/pages/automations/AutomationEditPage.tsx +23 -24
- package/src/client/pages/automations/AutomationEditor.tsx +66 -13
- package/src/client/pages/automations/AutomationFilters.tsx +22 -14
- package/src/client/pages/automations/AutomationLifecycle.tsx +6 -4
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +18 -0
- package/src/client/pages/automations/AutomationRow.tsx +172 -187
- package/src/client/pages/automations/AutomationVersionsPage.tsx +356 -0
- package/src/client/pages/automations/AutomationsPage.tsx +40 -42
- package/src/client/pages/automations/AutomationsPageLayout.tsx +6 -5
- package/src/client/pages/automations/README.md +14 -3
- package/src/client/pages/automations/automationFormat.ts +43 -0
- package/src/client/pages/code/CodeChangeRow.tsx +41 -0
- package/src/client/pages/code/CodePage.tsx +18 -93
- package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
- package/src/client/pages/code/RepositoryPage.tsx +227 -0
- package/src/client/pages/code/RepositorySections.tsx +108 -0
- package/src/client/pages/code/RepositoryTabs.tsx +146 -0
- package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
- package/src/client/pages/dev/ComponentsPage.tsx +5 -22
- package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
- package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
- package/src/client/pages/system/SystemNavigation.tsx +6 -2
- package/src/client/pages/system/SystemPageLayout.tsx +4 -6
- package/src/mock-reporting/code-repository.ts +77 -0
- package/src/mock-reporting/fixtures.ts +8 -2
- package/src/mock-reporting/routes.ts +25 -8
- package/src/shell.ts +21 -5
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Repository page tabs. Overview is the bare repository path. */
|
|
2
|
+
export type CodeRepositoryTab = "overview" | "conversations" | "changes";
|
|
3
|
+
|
|
4
|
+
/** Tab order in the repository page navigation. */
|
|
5
|
+
export const codeRepositoryTabs: readonly CodeRepositoryTab[] = [
|
|
6
|
+
"overview",
|
|
7
|
+
"conversations",
|
|
8
|
+
"changes",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/** Build the dashboard path for one repository page tab. */
|
|
12
|
+
export function codeRepositoryPath(
|
|
13
|
+
repositoryId: string,
|
|
14
|
+
tab: CodeRepositoryTab = "overview",
|
|
15
|
+
): string {
|
|
16
|
+
const base = `/code/${encodeURIComponent(repositoryId)}`;
|
|
17
|
+
return tab === "overview" ? base : `${base}/${tab}`;
|
|
18
|
+
}
|
|
@@ -2,6 +2,7 @@ import { FormNotice } from "../../components/FormNotice";
|
|
|
2
2
|
import { Select } from "../../components/Select";
|
|
3
3
|
import { ObjectFixtures } from "./ObjectFixtures";
|
|
4
4
|
import { ChartFixtures } from "./ChartFixtures";
|
|
5
|
+
import { SegmentedNavFixture, SegmentedTabsFixture } from "./SegmentedFixtures";
|
|
5
6
|
import {
|
|
6
7
|
ConversationFixture,
|
|
7
8
|
MessageAttachmentsFixture,
|
|
@@ -40,7 +41,6 @@ import { ParticipantAvatarStack } from "../../components/ParticipantAvatarStack"
|
|
|
40
41
|
import { StatCard } from "../../components/metrics/StatCard";
|
|
41
42
|
import { StatusChip } from "../../components/StatusChip";
|
|
42
43
|
import { StatusDot } from "../../components/StatusDot";
|
|
43
|
-
import { SegmentedTabs } from "../../components/SegmentedTabs";
|
|
44
44
|
import { TextArea, TextInput } from "../../components/TextInput";
|
|
45
45
|
import { TranscriptMarkdown } from "../../conversations/TranscriptMarkdown";
|
|
46
46
|
import { TranscriptText } from "../../conversations/TranscriptText";
|
|
@@ -303,7 +303,6 @@ function FoundationsGalleryPage() {
|
|
|
303
303
|
const [navigationOpen, setNavigationOpen] = useState(false);
|
|
304
304
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
305
305
|
const [nestedDrawerOpen, setNestedDrawerOpen] = useState(false);
|
|
306
|
-
const [tab, setTab] = useState<"details" | "memories" | "usage">("details");
|
|
307
306
|
|
|
308
307
|
return (
|
|
309
308
|
<GalleryShell
|
|
@@ -354,26 +353,10 @@ function FoundationsGalleryPage() {
|
|
|
354
353
|
</DashboardChromeProvider>
|
|
355
354
|
</Fixture>
|
|
356
355
|
<Fixture title="Segmented tabs">
|
|
357
|
-
<
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
{ label: "Memories", value: "memories" },
|
|
362
|
-
{ label: "Usage", value: "usage" },
|
|
363
|
-
]}
|
|
364
|
-
label="Conversation panels"
|
|
365
|
-
onChange={setTab}
|
|
366
|
-
value={tab}
|
|
367
|
-
>
|
|
368
|
-
<p className="m-0 text-sm leading-relaxed text-dashboard-text-muted">
|
|
369
|
-
{tab === "details"
|
|
370
|
-
? "Conversation summary and linked work."
|
|
371
|
-
: tab === "memories"
|
|
372
|
-
? "What Junior learned from this conversation."
|
|
373
|
-
: "Time, tokens, and cost for this conversation."}
|
|
374
|
-
</p>
|
|
375
|
-
</SegmentedTabs>
|
|
376
|
-
</div>
|
|
356
|
+
<SegmentedTabsFixture />
|
|
357
|
+
</Fixture>
|
|
358
|
+
<Fixture title="Segmented page links">
|
|
359
|
+
<SegmentedNavFixture />
|
|
377
360
|
</Fixture>
|
|
378
361
|
<Fixture title="Narrow details drawer">
|
|
379
362
|
<Button onClick={() => setDrawerOpen(true)}>Open details</Button>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { SegmentedNav } from "../../components/SegmentedNav";
|
|
4
|
+
import { SegmentedTabs } from "../../components/SegmentedTabs";
|
|
5
|
+
|
|
6
|
+
/** Show panel tabs switching the panel below them. */
|
|
7
|
+
export function SegmentedTabsFixture() {
|
|
8
|
+
const [tab, setTab] = useState<"details" | "memories" | "usage">("details");
|
|
9
|
+
return (
|
|
10
|
+
<div className="max-w-md">
|
|
11
|
+
<SegmentedTabs
|
|
12
|
+
items={[
|
|
13
|
+
{ label: "Details", value: "details" },
|
|
14
|
+
{ label: "Memories", value: "memories" },
|
|
15
|
+
{ label: "Usage", value: "usage" },
|
|
16
|
+
]}
|
|
17
|
+
label="Conversation panels"
|
|
18
|
+
onChange={setTab}
|
|
19
|
+
value={tab}
|
|
20
|
+
>
|
|
21
|
+
<p className="m-0 text-sm leading-relaxed text-dashboard-text-muted">
|
|
22
|
+
{tab === "details"
|
|
23
|
+
? "Conversation summary and linked work."
|
|
24
|
+
: tab === "memories"
|
|
25
|
+
? "What Junior learned from this conversation."
|
|
26
|
+
: "Time, tokens, and cost for this conversation."}
|
|
27
|
+
</p>
|
|
28
|
+
</SegmentedTabs>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const PAGE_LINKS = [
|
|
34
|
+
"Overview",
|
|
35
|
+
"People",
|
|
36
|
+
"Locations",
|
|
37
|
+
"Workspaces",
|
|
38
|
+
"Plugins",
|
|
39
|
+
].map((label) => ({
|
|
40
|
+
isActive: () => label === "Overview",
|
|
41
|
+
label,
|
|
42
|
+
to: `#${label.toLowerCase()}`,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
/** Show page links at label width, filling a row, and overflowing a row. */
|
|
46
|
+
export function SegmentedNavFixture() {
|
|
47
|
+
return (
|
|
48
|
+
<div className="grid gap-4">
|
|
49
|
+
<SegmentedNav ariaLabel="Label width" items={PAGE_LINKS} />
|
|
50
|
+
<div className="max-w-md">
|
|
51
|
+
<SegmentedNav ariaLabel="Fill" fill items={PAGE_LINKS.slice(0, 2)} />
|
|
52
|
+
</div>
|
|
53
|
+
<div className="max-w-xs">
|
|
54
|
+
<SegmentedNav ariaLabel="Overflow" fill items={PAGE_LINKS} />
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -24,20 +24,19 @@ function isMemoriesLibraryPath(pathname: string): boolean {
|
|
|
24
24
|
/** Place the shared secondary navigation above one Memories page. */
|
|
25
25
|
export function MemoryPageLayout(props: { children: ReactNode }) {
|
|
26
26
|
return (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
/>
|
|
27
|
+
<SecondaryNavigation
|
|
28
|
+
ariaLabel="Memory navigation"
|
|
29
|
+
items={[
|
|
30
|
+
{ end: true, label: "Overview", to: MEMORY_BASE_PATH },
|
|
31
|
+
{
|
|
32
|
+
isActive: isMemoriesLibraryPath,
|
|
33
|
+
label: "Memories",
|
|
34
|
+
to: MEMORY_LIBRARY_PATH,
|
|
35
|
+
},
|
|
36
|
+
]}
|
|
37
|
+
>
|
|
39
38
|
<PageLayout className="gap-6 sm:gap-8">{props.children}</PageLayout>
|
|
40
|
-
|
|
39
|
+
</SecondaryNavigation>
|
|
41
40
|
);
|
|
42
41
|
}
|
|
43
42
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
1
3
|
import { SecondaryNavigation } from "../../components/layout/SecondaryNavigation";
|
|
2
4
|
import { systemPluginsPath } from "./SystemPlugins";
|
|
3
5
|
|
|
@@ -10,11 +12,13 @@ const systemNavigationItems = [
|
|
|
10
12
|
];
|
|
11
13
|
|
|
12
14
|
/** Render the stable secondary navigation shared by System pages. */
|
|
13
|
-
export function SystemNavigation() {
|
|
15
|
+
export function SystemNavigation(props: { children: ReactNode }) {
|
|
14
16
|
return (
|
|
15
17
|
<SecondaryNavigation
|
|
16
18
|
ariaLabel="System navigation"
|
|
17
19
|
items={systemNavigationItems}
|
|
18
|
-
|
|
20
|
+
>
|
|
21
|
+
{props.children}
|
|
22
|
+
</SecondaryNavigation>
|
|
19
23
|
);
|
|
20
24
|
}
|
|
@@ -8,10 +8,9 @@ import { SystemNavigation } from "./SystemNavigation";
|
|
|
8
8
|
/** Place the shared secondary navigation above one System page. */
|
|
9
9
|
export function SystemPageLayout(props: { children: ReactNode }) {
|
|
10
10
|
return (
|
|
11
|
-
|
|
12
|
-
<SystemNavigation />
|
|
11
|
+
<SystemNavigation>
|
|
13
12
|
<PageLayout>{props.children}</PageLayout>
|
|
14
|
-
|
|
13
|
+
</SystemNavigation>
|
|
15
14
|
);
|
|
16
15
|
}
|
|
17
16
|
|
|
@@ -23,9 +22,8 @@ export function SystemRouteLoading(props: {
|
|
|
23
22
|
variant?: PageContentSkeletonVariant;
|
|
24
23
|
}) {
|
|
25
24
|
return (
|
|
26
|
-
|
|
27
|
-
<SystemNavigation />
|
|
25
|
+
<SystemNavigation>
|
|
28
26
|
<PageRouteLoading {...props} />
|
|
29
|
-
|
|
27
|
+
</SystemNavigation>
|
|
30
28
|
);
|
|
31
29
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CodeRepositoryReport,
|
|
3
|
+
ConversationFeed,
|
|
4
|
+
} from "@sentry/junior/api/schema";
|
|
5
|
+
|
|
6
|
+
import { readMockCodeOverview } from "./fixtures";
|
|
7
|
+
|
|
8
|
+
const MOCK_WORKSPACES: Record<string, CodeRepositoryReport["workspaces"]> = {
|
|
9
|
+
"getsentry/junior": [{ id: "workspace-junior", name: "junior" }],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Build one mock repository report from the mock Code overview. */
|
|
13
|
+
export function readMockCodeRepository(
|
|
14
|
+
repositoryId: string,
|
|
15
|
+
): CodeRepositoryReport | undefined {
|
|
16
|
+
const overview = readMockCodeOverview();
|
|
17
|
+
const repository = overview.repositories.find(
|
|
18
|
+
(entry) => entry.id === repositoryId,
|
|
19
|
+
);
|
|
20
|
+
if (!repository) return undefined;
|
|
21
|
+
return {
|
|
22
|
+
activityDays: overview.activityDays,
|
|
23
|
+
changes: overview.changes.filter(
|
|
24
|
+
(change) => change.repository === repository.name,
|
|
25
|
+
),
|
|
26
|
+
generatedAt: overview.generatedAt,
|
|
27
|
+
repository: {
|
|
28
|
+
id: repository.id,
|
|
29
|
+
name: repository.name,
|
|
30
|
+
provider: repository.provider,
|
|
31
|
+
...(repository.url ? { url: repository.url } : undefined),
|
|
32
|
+
},
|
|
33
|
+
summary: {
|
|
34
|
+
closed: repository.closed,
|
|
35
|
+
created: repository.created,
|
|
36
|
+
merged: repository.merged,
|
|
37
|
+
open: repository.open,
|
|
38
|
+
...(repository.mergeRate === undefined
|
|
39
|
+
? undefined
|
|
40
|
+
: { mergeRate: repository.mergeRate }),
|
|
41
|
+
...(repository.medianCostUsd === undefined
|
|
42
|
+
? undefined
|
|
43
|
+
: {
|
|
44
|
+
// Mock rows have no total cost; estimate it from the median.
|
|
45
|
+
costUsd: repository.medianCostUsd * repository.created,
|
|
46
|
+
medianCostUsd: repository.medianCostUsd,
|
|
47
|
+
}),
|
|
48
|
+
...(overview.summary.medianMergeTimeMs === undefined
|
|
49
|
+
? undefined
|
|
50
|
+
: { medianMergeTimeMs: overview.summary.medianMergeTimeMs }),
|
|
51
|
+
},
|
|
52
|
+
windowEnd: overview.windowEnd,
|
|
53
|
+
windowStart: overview.windowStart,
|
|
54
|
+
workspaces: MOCK_WORKSPACES[repository.name] ?? [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Keep mock conversations whose code change annotations name the repository.
|
|
60
|
+
* The real API links conversations through recorded code changes.
|
|
61
|
+
*/
|
|
62
|
+
export function filterMockConversationsByRepository(
|
|
63
|
+
feed: ConversationFeed,
|
|
64
|
+
repositoryId: string,
|
|
65
|
+
): ConversationFeed {
|
|
66
|
+
const name = readMockCodeRepository(repositoryId)?.repository.name;
|
|
67
|
+
return {
|
|
68
|
+
...feed,
|
|
69
|
+
conversations: name
|
|
70
|
+
? feed.conversations.filter((conversation) =>
|
|
71
|
+
conversation.sidebarAnnotations?.some((annotation) =>
|
|
72
|
+
annotation.key.startsWith(`${name}#`),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -1825,8 +1825,14 @@ export function readMockConversationDetail(
|
|
|
1825
1825
|
(candidate) => candidate.conversationId === conversationId,
|
|
1826
1826
|
);
|
|
1827
1827
|
if (!conversation) return undefined;
|
|
1828
|
-
const { parentConversationId
|
|
1829
|
-
|
|
1828
|
+
const { parentConversationId, ...conversationDetail } = conversation;
|
|
1829
|
+
// Match the API: only readable root Conversations can be forked.
|
|
1830
|
+
const detail = {
|
|
1831
|
+
...conversationDetail,
|
|
1832
|
+
canFork:
|
|
1833
|
+
!parentConversationId &&
|
|
1834
|
+
conversationDetail.eventHistory.status !== "redacted",
|
|
1835
|
+
};
|
|
1830
1836
|
const withArchiveState = mockArchivedConversationIds.has(conversationId)
|
|
1831
1837
|
? {
|
|
1832
1838
|
...detail,
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
conversationStatsReportSchema,
|
|
19
19
|
codeOverviewReportSchema,
|
|
20
20
|
codePersonReportSchema,
|
|
21
|
+
codeRepositoryParamsSchema,
|
|
22
|
+
codeRepositoryReportSchema,
|
|
21
23
|
locationDetailReportSchema,
|
|
22
24
|
locationDirectoryReportSchema,
|
|
23
25
|
locationParamsSchema,
|
|
@@ -31,6 +33,10 @@ import {
|
|
|
31
33
|
automationRunListSchema,
|
|
32
34
|
} from "@sentry/junior/api/schema";
|
|
33
35
|
import { mockChartPng } from "./chart-png";
|
|
36
|
+
import {
|
|
37
|
+
filterMockConversationsByRepository,
|
|
38
|
+
readMockCodeRepository,
|
|
39
|
+
} from "./code-repository";
|
|
34
40
|
import {
|
|
35
41
|
readMockConversationDetail,
|
|
36
42
|
readMockConversationEvents,
|
|
@@ -64,6 +70,16 @@ export function createMockReportingApi(): Hono<{
|
|
|
64
70
|
app.get("/code", () =>
|
|
65
71
|
jsonResponse(codeOverviewReportSchema, readMockCodeOverview()),
|
|
66
72
|
);
|
|
73
|
+
app.get("/code/repositories/:repositoryId", (c) => {
|
|
74
|
+
const params = codeRepositoryParamsSchema.safeParse(c.req.param());
|
|
75
|
+
if (!params.success) {
|
|
76
|
+
return errorResponse("Invalid route parameters.", 400);
|
|
77
|
+
}
|
|
78
|
+
const report = readMockCodeRepository(params.data.repositoryId);
|
|
79
|
+
return report
|
|
80
|
+
? jsonResponse(codeRepositoryReportSchema, report)
|
|
81
|
+
: errorResponse("Repository not found.", 404);
|
|
82
|
+
});
|
|
67
83
|
app.get("/plugin-reports", () =>
|
|
68
84
|
jsonResponse(pluginOperationalReportFeedSchema, readMockPluginReports()),
|
|
69
85
|
);
|
|
@@ -128,17 +144,18 @@ export function createMockReportingApi(): Hono<{
|
|
|
128
144
|
if (!query.success) {
|
|
129
145
|
return errorResponse("Invalid query parameters.", 400);
|
|
130
146
|
}
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
query.data.status
|
|
134
|
-
|
|
147
|
+
const repositoryId = query.data.codeRepositoryId;
|
|
148
|
+
const readFeed = (status: "active" | "archived") => {
|
|
149
|
+
const feed = readMockConversationFeed(query.data.actorEmail, status);
|
|
150
|
+
return repositoryId
|
|
151
|
+
? filterMockConversationsByRepository(feed, repositoryId)
|
|
152
|
+
: feed;
|
|
153
|
+
};
|
|
154
|
+
const report = readFeed(query.data.status);
|
|
135
155
|
if (!query.data.q) {
|
|
136
156
|
return jsonResponse(conversationFeedSchema, report);
|
|
137
157
|
}
|
|
138
|
-
const archived =
|
|
139
|
-
query.data.actorEmail,
|
|
140
|
-
"archived",
|
|
141
|
-
);
|
|
158
|
+
const archived = readFeed("archived");
|
|
142
159
|
const search = query.data.q.toLowerCase();
|
|
143
160
|
const conversations = new Map(
|
|
144
161
|
[...report.conversations, ...archived.conversations].map(
|
package/src/shell.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
dashboardAvatarHeaderAsset,
|
|
5
|
+
dashboardFaviconAsset,
|
|
5
6
|
dashboardClientAsset,
|
|
6
7
|
dashboardClientChunks,
|
|
7
8
|
dashboardInstallIconAsset,
|
|
@@ -120,6 +121,7 @@ export function dashboardPagePaths(
|
|
|
120
121
|
const paths: Array<{ nested?: boolean; path: string }> = [
|
|
121
122
|
{ path: basePath },
|
|
122
123
|
{
|
|
124
|
+
nested: true,
|
|
123
125
|
path: basePath === "/" ? "/code" : `${basePath}/code`,
|
|
124
126
|
},
|
|
125
127
|
{
|
|
@@ -298,12 +300,26 @@ export function renderDashboard(basePath: string, agentName: string): Response {
|
|
|
298
300
|
);
|
|
299
301
|
}
|
|
300
302
|
|
|
301
|
-
|
|
303
|
+
function readDashboardFavicon(): ArrayBuffer {
|
|
304
|
+
if (dashboardFaviconAsset) {
|
|
305
|
+
return Uint8Array.from(Buffer.from(dashboardFaviconAsset, "base64")).buffer;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const assetUrl = new URL("./assets/junior-favicon.png", import.meta.url);
|
|
309
|
+
if (!existsSync(assetUrl)) {
|
|
310
|
+
throw new Error("Junior dashboard favicon was not found");
|
|
311
|
+
}
|
|
312
|
+
return Uint8Array.from(readFileSync(assetUrl)).buffer;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Serve the head-only dashboard favicon, which stays readable at 16px. */
|
|
302
316
|
export function renderFavicon(): Response {
|
|
303
|
-
return new Response(
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
317
|
+
return new Response(readDashboardFavicon(), {
|
|
318
|
+
headers: {
|
|
319
|
+
"cache-control": "public, max-age=0, must-revalidate",
|
|
320
|
+
"content-type": "image/png",
|
|
321
|
+
},
|
|
322
|
+
});
|
|
307
323
|
}
|
|
308
324
|
|
|
309
325
|
/** Render a browser-readable access denied page for blocked dashboard users. */
|