@ekanos/harness 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/LICENSE +21 -0
- package/README.md +879 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.js +11 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +34 -0
- package/dist/internal/components/ask-assistant-bridge.d.ts +10 -0
- package/dist/internal/components/ask-assistant-bridge.js +50 -0
- package/dist/internal/components/dashboard-grid.d.ts +45 -0
- package/dist/internal/components/dashboard-grid.js +84 -0
- package/dist/internal/components/dev-toolbar.d.ts +15 -0
- package/dist/internal/components/dev-toolbar.js +155 -0
- package/dist/internal/components/harness-providers.d.ts +16 -0
- package/dist/internal/components/harness-providers.js +83 -0
- package/dist/internal/components/harness-widget-provider.d.ts +60 -0
- package/dist/internal/components/harness-widget-provider.js +84 -0
- package/dist/internal/components/i18n-provider.d.ts +9 -0
- package/dist/internal/components/i18n-provider.js +9 -0
- package/dist/internal/components/row-groups.d.ts +23 -0
- package/dist/internal/components/row-groups.js +37 -0
- package/dist/internal/components/surface-nav.d.ts +4 -0
- package/dist/internal/components/surface-nav.js +70 -0
- package/dist/internal/components/viewport-frame.d.ts +14 -0
- package/dist/internal/components/viewport-frame.js +27 -0
- package/dist/internal/components/widget-boundary.d.ts +25 -0
- package/dist/internal/components/widget-boundary.js +44 -0
- package/dist/internal/components/widget-surface.d.ts +20 -0
- package/dist/internal/components/widget-surface.js +76 -0
- package/dist/internal/lib/fonts.d.ts +2 -0
- package/dist/internal/lib/fonts.js +22 -0
- package/dist/internal/lib/harness-fetch-interceptor.d.ts +89 -0
- package/dist/internal/lib/harness-fetch-interceptor.js +101 -0
- package/dist/internal/lib/harness-live-fetch.d.ts +66 -0
- package/dist/internal/lib/harness-live-fetch.js +121 -0
- package/dist/internal/lib/harness-query-client.d.ts +43 -0
- package/dist/internal/lib/harness-query-client.js +103 -0
- package/dist/internal/lib/http-fixtures.d.ts +145 -0
- package/dist/internal/lib/http-fixtures.js +256 -0
- package/dist/internal/lib/i18n.d.ts +2 -0
- package/dist/internal/lib/i18n.js +17 -0
- package/dist/internal/lib/redact.d.ts +33 -0
- package/dist/internal/lib/redact.js +43 -0
- package/dist/internal/lib/toolbar-context.d.ts +59 -0
- package/dist/internal/lib/toolbar-context.js +124 -0
- package/dist/internal/registry-context.d.ts +27 -0
- package/dist/internal/registry-context.js +51 -0
- package/dist/internal/routes/activation-page.d.ts +33 -0
- package/dist/internal/routes/activation-page.js +242 -0
- package/dist/internal/routes/index-page.d.ts +13 -0
- package/dist/internal/routes/index-page.js +62 -0
- package/dist/internal/routes/integration-layout.d.ts +9 -0
- package/dist/internal/routes/integration-layout.js +84 -0
- package/dist/internal/routes/root-layout.d.ts +34 -0
- package/dist/internal/routes/root-layout.js +39 -0
- package/dist/internal/routes/single-widget-page.d.ts +6 -0
- package/dist/internal/routes/single-widget-page.js +30 -0
- package/dist/internal/routes/tile-page.d.ts +1 -0
- package/dist/internal/routes/tile-page.js +88 -0
- package/dist/internal/routes/triggers-page.d.ts +1 -0
- package/dist/internal/routes/triggers-page.js +386 -0
- package/dist/internal/routes/widgets-page.d.ts +1 -0
- package/dist/internal/routes/widgets-page.js +19 -0
- package/dist/internal/surfaces.d.ts +25 -0
- package/dist/internal/surfaces.js +29 -0
- package/dist/mocks/team-account-workspace.d.ts +78 -0
- package/dist/mocks/team-account-workspace.js +86 -0
- package/dist/registry.d.ts +418 -0
- package/dist/registry.js +82 -0
- package/dist/routes.d.ts +24 -0
- package/dist/routes.js +15 -0
- package/dist/styles.css +236 -0
- package/package.json +101 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
WidgetContext
|
|
6
|
+
} from "@ekanos/sdk/components";
|
|
7
|
+
function HarnessWidgetProvider({
|
|
8
|
+
widgetId,
|
|
9
|
+
title,
|
|
10
|
+
subtitle,
|
|
11
|
+
description,
|
|
12
|
+
className,
|
|
13
|
+
state: renderState,
|
|
14
|
+
isEnabled = true,
|
|
15
|
+
isCollapsible = false,
|
|
16
|
+
isPinnable = false,
|
|
17
|
+
defaultCollapsed = false,
|
|
18
|
+
defaultPinned = false,
|
|
19
|
+
maxContentHeight,
|
|
20
|
+
askContext,
|
|
21
|
+
aiFooterEnabled = false,
|
|
22
|
+
health,
|
|
23
|
+
children
|
|
24
|
+
}) {
|
|
25
|
+
const [toggles, setToggles] = useState({
|
|
26
|
+
collapsed: defaultCollapsed,
|
|
27
|
+
pinned: defaultPinned
|
|
28
|
+
});
|
|
29
|
+
const toggleCollapsed = useCallback(() => {
|
|
30
|
+
setToggles((current) => ({ ...current, collapsed: !current.collapsed }));
|
|
31
|
+
}, []);
|
|
32
|
+
const togglePinned = useCallback(() => {
|
|
33
|
+
setToggles((current) => ({ ...current, pinned: !current.pinned }));
|
|
34
|
+
}, []);
|
|
35
|
+
const baseRenderState = renderState !== "active" ? renderState : isEnabled ? "active" : "disabled";
|
|
36
|
+
const effectiveRenderState = baseRenderState === "active" && health?.status === "unhealthy" && health?.lastSuccessAt ? "disabled" : baseRenderState;
|
|
37
|
+
const value = useMemo(
|
|
38
|
+
() => ({
|
|
39
|
+
state: {
|
|
40
|
+
state: effectiveRenderState,
|
|
41
|
+
collapsed: toggles.collapsed,
|
|
42
|
+
pinned: toggles.pinned,
|
|
43
|
+
pendingCollapse: false,
|
|
44
|
+
pendingPin: false
|
|
45
|
+
},
|
|
46
|
+
actions: { toggleCollapsed, togglePinned },
|
|
47
|
+
meta: {
|
|
48
|
+
widgetId,
|
|
49
|
+
title,
|
|
50
|
+
subtitle,
|
|
51
|
+
description,
|
|
52
|
+
maxContentHeight,
|
|
53
|
+
isCollapsible,
|
|
54
|
+
isPinnable,
|
|
55
|
+
askContext,
|
|
56
|
+
aiFooterEnabled,
|
|
57
|
+
className,
|
|
58
|
+
health
|
|
59
|
+
}
|
|
60
|
+
}),
|
|
61
|
+
[
|
|
62
|
+
effectiveRenderState,
|
|
63
|
+
toggles.collapsed,
|
|
64
|
+
toggles.pinned,
|
|
65
|
+
toggleCollapsed,
|
|
66
|
+
togglePinned,
|
|
67
|
+
widgetId,
|
|
68
|
+
title,
|
|
69
|
+
subtitle,
|
|
70
|
+
description,
|
|
71
|
+
maxContentHeight,
|
|
72
|
+
isCollapsible,
|
|
73
|
+
isPinnable,
|
|
74
|
+
askContext,
|
|
75
|
+
aiFooterEnabled,
|
|
76
|
+
className,
|
|
77
|
+
health
|
|
78
|
+
]
|
|
79
|
+
);
|
|
80
|
+
return /* @__PURE__ */ jsx(WidgetContext, { value, children });
|
|
81
|
+
}
|
|
82
|
+
export {
|
|
83
|
+
HarnessWidgetProvider
|
|
84
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import '../lib/i18n.js';
|
|
3
|
+
/**
|
|
4
|
+
* Initialization happens as a module side effect on import, so it runs once,
|
|
5
|
+
* synchronously, before anything below renders.
|
|
6
|
+
*/
|
|
7
|
+
export declare function I18nProvider({ children }: {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { WidgetConfig } from '@ekanos/sdk';
|
|
2
|
+
export type RowGroup = {
|
|
3
|
+
type: 'full';
|
|
4
|
+
widget: WidgetConfig;
|
|
5
|
+
} | {
|
|
6
|
+
type: 'half';
|
|
7
|
+
widgets: WidgetConfig[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Build row groups that preserve widget order. Contiguous runs of half-width
|
|
11
|
+
* widgets are grouped together; full-width widgets each get their own row.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildRowGroups(widgets: WidgetConfig[]): RowGroup[];
|
|
14
|
+
export declare function getColSpan(widget: WidgetConfig): 1 | 2;
|
|
15
|
+
/**
|
|
16
|
+
* Splits a run of half-width widgets into two balanced columns, alternating
|
|
17
|
+
* into whichever column is currently shorter (left wins ties) — preserves
|
|
18
|
+
* the exact alternation `MasonryGrid` rendered inline before extraction.
|
|
19
|
+
*/
|
|
20
|
+
export declare function splitBalanced(widgets: WidgetConfig[]): {
|
|
21
|
+
left: WidgetConfig[];
|
|
22
|
+
right: WidgetConfig[];
|
|
23
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function buildRowGroups(widgets) {
|
|
2
|
+
const groups = [];
|
|
3
|
+
let halfBatch = [];
|
|
4
|
+
for (const widget of widgets) {
|
|
5
|
+
if (getColSpan(widget) === 2) {
|
|
6
|
+
if (halfBatch.length > 0) {
|
|
7
|
+
groups.push({ type: "half", widgets: halfBatch });
|
|
8
|
+
halfBatch = [];
|
|
9
|
+
}
|
|
10
|
+
groups.push({ type: "full", widget });
|
|
11
|
+
} else {
|
|
12
|
+
halfBatch.push(widget);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (halfBatch.length > 0) {
|
|
16
|
+
groups.push({ type: "half", widgets: halfBatch });
|
|
17
|
+
}
|
|
18
|
+
return groups;
|
|
19
|
+
}
|
|
20
|
+
function getColSpan(widget) {
|
|
21
|
+
const w = widget.layouts?.lg?.w ?? 6;
|
|
22
|
+
return w > 6 ? 2 : 1;
|
|
23
|
+
}
|
|
24
|
+
function splitBalanced(widgets) {
|
|
25
|
+
const left = [];
|
|
26
|
+
const right = [];
|
|
27
|
+
for (const w of widgets) {
|
|
28
|
+
if (left.length <= right.length) left.push(w);
|
|
29
|
+
else right.push(w);
|
|
30
|
+
}
|
|
31
|
+
return { left, right };
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
buildRowGroups,
|
|
35
|
+
getColSpan,
|
|
36
|
+
splitBalanced
|
|
37
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { Icon } from "@ekanos/ui/icon";
|
|
6
|
+
import { cn } from "@ekanos/ui/utils";
|
|
7
|
+
import { HARNESS_SURFACES } from "../surfaces.js";
|
|
8
|
+
function SurfaceNav({
|
|
9
|
+
integration
|
|
10
|
+
}) {
|
|
11
|
+
const pathname = usePathname();
|
|
12
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-5 border-b px-6 py-5", children: [
|
|
13
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-3", children: [
|
|
14
|
+
/* @__PURE__ */ jsxs(
|
|
15
|
+
Link,
|
|
16
|
+
{
|
|
17
|
+
href: "/",
|
|
18
|
+
className: "text-muted-foreground hover:text-foreground text-sm",
|
|
19
|
+
children: [
|
|
20
|
+
/* @__PURE__ */ jsx(Icon, { name: "fa-solid fa-arrow-left", className: "mr-2 h-4 w-4" }),
|
|
21
|
+
"All integrations"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
),
|
|
25
|
+
/* @__PURE__ */ jsx("h1", { className: "font-heading text-2xl tracking-tight", children: integration.name }),
|
|
26
|
+
/* @__PURE__ */ jsx("code", { className: "text-muted-foreground text-xs", children: integration.slug })
|
|
27
|
+
] }),
|
|
28
|
+
/* @__PURE__ */ jsx("nav", { className: "flex flex-wrap gap-1", children: HARNESS_SURFACES.map((surface) => {
|
|
29
|
+
const href = `/${integration.slug}/${surface.segment}`;
|
|
30
|
+
const active = pathname.startsWith(href);
|
|
31
|
+
return /* @__PURE__ */ jsxs(
|
|
32
|
+
Link,
|
|
33
|
+
{
|
|
34
|
+
href,
|
|
35
|
+
className: cn(
|
|
36
|
+
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
|
|
37
|
+
active ? "bg-accent text-accent-foreground font-medium" : "text-muted-foreground hover:bg-accent/50"
|
|
38
|
+
),
|
|
39
|
+
children: [
|
|
40
|
+
/* @__PURE__ */ jsx(Icon, { name: surface.icon, className: "h-5 w-5" }),
|
|
41
|
+
surface.label
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
surface.segment
|
|
45
|
+
);
|
|
46
|
+
}) }),
|
|
47
|
+
integration.widgets.length > 0 ? /* @__PURE__ */ jsxs("nav", { className: "flex flex-wrap gap-1", children: [
|
|
48
|
+
/* @__PURE__ */ jsx("span", { className: "text-muted-foreground px-3 py-2 text-xs", children: "Focus one widget:" }),
|
|
49
|
+
integration.widgets.map((widget) => {
|
|
50
|
+
const href = `/${integration.slug}/widgets/${widget.id}`;
|
|
51
|
+
const active = pathname === href;
|
|
52
|
+
return /* @__PURE__ */ jsx(
|
|
53
|
+
Link,
|
|
54
|
+
{
|
|
55
|
+
href,
|
|
56
|
+
className: cn(
|
|
57
|
+
"rounded-md px-3 py-2 text-sm transition-colors",
|
|
58
|
+
active ? "bg-accent text-accent-foreground font-medium" : "text-muted-foreground hover:bg-accent/50"
|
|
59
|
+
),
|
|
60
|
+
children: widget.title
|
|
61
|
+
},
|
|
62
|
+
widget.id
|
|
63
|
+
);
|
|
64
|
+
})
|
|
65
|
+
] }) : null
|
|
66
|
+
] });
|
|
67
|
+
}
|
|
68
|
+
export {
|
|
69
|
+
SurfaceNav
|
|
70
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Constrains the preview to a viewport preset.
|
|
4
|
+
*
|
|
5
|
+
* This narrows a real container rather than rendering into an iframe, which is
|
|
6
|
+
* the right trade for widgets: the whole dashboard grid is built on Tailwind
|
|
7
|
+
* `@container` queries (`@4xl/dashboard:grid-cols-2`), so container width — not
|
|
8
|
+
* window width — is what actually drives the responsive behaviour a partner
|
|
9
|
+
* needs to check. Media-query breakpoints still read the real window; see the
|
|
10
|
+
* "Known gaps" section of the README.
|
|
11
|
+
*/
|
|
12
|
+
export declare function ViewportFrame({ children }: {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { VIEWPORTS, useToolbar } from "../lib/toolbar-context.js";
|
|
4
|
+
function ViewportFrame({ children }) {
|
|
5
|
+
const { state } = useToolbar();
|
|
6
|
+
const { width, label } = VIEWPORTS[state.viewport];
|
|
7
|
+
if (width === null) return /* @__PURE__ */ jsx(Fragment, { children });
|
|
8
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2", children: [
|
|
9
|
+
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground text-xs", children: [
|
|
10
|
+
label,
|
|
11
|
+
" \xB7 ",
|
|
12
|
+
width,
|
|
13
|
+
"px"
|
|
14
|
+
] }),
|
|
15
|
+
/* @__PURE__ */ jsx(
|
|
16
|
+
"div",
|
|
17
|
+
{
|
|
18
|
+
className: "w-full overflow-hidden rounded-lg border p-5",
|
|
19
|
+
style: { maxWidth: width },
|
|
20
|
+
children
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
] });
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
ViewportFrame
|
|
27
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Component, type ReactNode } from 'react';
|
|
2
|
+
interface Props {
|
|
3
|
+
widgetId: string;
|
|
4
|
+
/** Changing this remounts the boundary, clearing a caught error. */
|
|
5
|
+
resetKey?: string;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
interface State {
|
|
9
|
+
error: Error | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `Widget.Active` already wraps its children in an error boundary, but only its
|
|
13
|
+
* children — a widget that throws during its own render, before reaching the
|
|
14
|
+
* compound components (a bad hook call, a fixture shaped wrongly), escapes it
|
|
15
|
+
* and takes the page with it. This catches that outer case so one broken widget
|
|
16
|
+
* degrades to one broken card.
|
|
17
|
+
*/
|
|
18
|
+
export declare class WidgetBoundary extends Component<Props, State> {
|
|
19
|
+
state: State;
|
|
20
|
+
static getDerivedStateFromError(error: Error): State;
|
|
21
|
+
componentDidUpdate(previous: Props): void;
|
|
22
|
+
componentDidCatch(error: Error): void;
|
|
23
|
+
render(): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
|
|
24
|
+
}
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Component } from "react";
|
|
4
|
+
import { Icon } from "@ekanos/ui/icon";
|
|
5
|
+
class WidgetBoundary extends Component {
|
|
6
|
+
constructor() {
|
|
7
|
+
super(...arguments);
|
|
8
|
+
this.state = { error: null };
|
|
9
|
+
}
|
|
10
|
+
static getDerivedStateFromError(error) {
|
|
11
|
+
return { error };
|
|
12
|
+
}
|
|
13
|
+
componentDidUpdate(previous) {
|
|
14
|
+
if (previous.resetKey !== this.props.resetKey && this.state.error) {
|
|
15
|
+
this.setState({ error: null });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
componentDidCatch(error) {
|
|
19
|
+
console.error(`[harness] Widget "${this.props.widgetId}" crashed:`, error);
|
|
20
|
+
}
|
|
21
|
+
render() {
|
|
22
|
+
const { error } = this.state;
|
|
23
|
+
if (!error) return this.props.children;
|
|
24
|
+
return /* @__PURE__ */ jsxs("div", { className: "bg-card flex flex-col gap-3 rounded-lg border p-6", children: [
|
|
25
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
|
|
26
|
+
/* @__PURE__ */ jsx(
|
|
27
|
+
Icon,
|
|
28
|
+
{
|
|
29
|
+
name: "fa-solid fa-triangle-exclamation",
|
|
30
|
+
className: "text-destructive h-5 w-5"
|
|
31
|
+
}
|
|
32
|
+
),
|
|
33
|
+
/* @__PURE__ */ jsxs("p", { className: "text-sm font-medium", children: [
|
|
34
|
+
this.props.widgetId,
|
|
35
|
+
" threw during render"
|
|
36
|
+
] })
|
|
37
|
+
] }),
|
|
38
|
+
/* @__PURE__ */ jsx("pre", { className: "bg-muted text-destructive overflow-x-auto rounded-md p-3 text-xs whitespace-pre-wrap", children: error.message })
|
|
39
|
+
] });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
WidgetBoundary
|
|
44
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WidgetConfig } from '@ekanos/sdk';
|
|
2
|
+
import { HARNESS_ACCOUNT_ID, HARNESS_SOURCE_ID, type HarnessWidget } from '../../registry.js';
|
|
3
|
+
/**
|
|
4
|
+
* Adapts the harness registry's widget entries into the `WidgetConfig` shape
|
|
5
|
+
* the real dashboard grid consumes.
|
|
6
|
+
*
|
|
7
|
+
* Note `health` is only set when the toolbar's "Unhealthy" switch is on.
|
|
8
|
+
* Leaving it undefined the rest of the time matters: `DashboardWidgetProvider`
|
|
9
|
+
* downgrades an active widget to `disabled` whenever health is `unhealthy` AND
|
|
10
|
+
* `lastSuccessAt` is set, which would silently override the render state the
|
|
11
|
+
* toolbar is asking for. Behind the switch, it becomes something you can
|
|
12
|
+
* exercise deliberately instead of trip over.
|
|
13
|
+
*/
|
|
14
|
+
export declare function toWidgetConfig(widget: HarnessWidget, productSlug: string, unhealthy: boolean): WidgetConfig;
|
|
15
|
+
export declare function WidgetSurface({ widgets, productSlug, layout, }: {
|
|
16
|
+
widgets: HarnessWidget[];
|
|
17
|
+
productSlug: string;
|
|
18
|
+
layout?: 'masonry' | 'two-column';
|
|
19
|
+
}): import("react").JSX.Element;
|
|
20
|
+
export { HARNESS_ACCOUNT_ID, HARNESS_SOURCE_ID };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import {
|
|
5
|
+
HARNESS_ACCOUNT_ID,
|
|
6
|
+
HARNESS_SOURCE_ID
|
|
7
|
+
} from "../../registry.js";
|
|
8
|
+
import { useToolbar } from "../lib/toolbar-context.js";
|
|
9
|
+
import { DashboardGrid } from "./dashboard-grid.js";
|
|
10
|
+
import { ViewportFrame } from "./viewport-frame.js";
|
|
11
|
+
function toWidgetConfig(widget, productSlug, unhealthy) {
|
|
12
|
+
return {
|
|
13
|
+
id: widget.id,
|
|
14
|
+
name: widget.title,
|
|
15
|
+
component: widget.component,
|
|
16
|
+
// `w > 6` is what the grid reads as full-width.
|
|
17
|
+
widgetState: "active",
|
|
18
|
+
layouts: { lg: { x: 0, y: 0, w: widget.width === "full" ? 12 : 6, h: 4 } },
|
|
19
|
+
// Passed through ONLY when the registry entry declared them, so the
|
|
20
|
+
// default lives in exactly one place: `dashboard-grid.tsx`, which is a copy
|
|
21
|
+
// of the host's own grid and applies the host's own `?? false`.
|
|
22
|
+
//
|
|
23
|
+
// This used to default `isCollapsible` to `true` here, which silently
|
|
24
|
+
// shadowed that copy — `?? false` one layer down never saw `undefined`, so
|
|
25
|
+
// every widget grew a collapse control whether it asked for one or not, and
|
|
26
|
+
// the harness showed chrome production would not. Two defaults where one is
|
|
27
|
+
// unreachable is also the shape of thing that gets "fixed" later by
|
|
28
|
+
// deleting the wrong one.
|
|
29
|
+
...widget.isCollapsible === void 0 ? {} : { isCollapsible: widget.isCollapsible },
|
|
30
|
+
...widget.isPinnable === void 0 ? {} : { isPinnable: widget.isPinnable },
|
|
31
|
+
aiFooterEnabled: widget.aiFooterEnabled,
|
|
32
|
+
integrationMetadata: {
|
|
33
|
+
productId: productSlug,
|
|
34
|
+
productSlug,
|
|
35
|
+
integrationName: productSlug,
|
|
36
|
+
description: "",
|
|
37
|
+
category: "harness",
|
|
38
|
+
version: "0.0.0"
|
|
39
|
+
},
|
|
40
|
+
health: unhealthy ? {
|
|
41
|
+
status: "unhealthy",
|
|
42
|
+
lastSuccessAt: "2026-08-27T10:00:00.000Z",
|
|
43
|
+
errorMessage: "Simulated by the harness toolbar."
|
|
44
|
+
} : void 0
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function WidgetSurface({
|
|
48
|
+
widgets,
|
|
49
|
+
productSlug,
|
|
50
|
+
layout = "masonry"
|
|
51
|
+
}) {
|
|
52
|
+
const { state } = useToolbar();
|
|
53
|
+
const configs = useMemo(
|
|
54
|
+
() => widgets.map(
|
|
55
|
+
(widget) => toWidgetConfig(widget, productSlug, state.unhealthy)
|
|
56
|
+
),
|
|
57
|
+
[widgets, productSlug, state.unhealthy]
|
|
58
|
+
);
|
|
59
|
+
return /* @__PURE__ */ jsx(ViewportFrame, { children: /* @__PURE__ */ jsx(
|
|
60
|
+
DashboardGrid,
|
|
61
|
+
{
|
|
62
|
+
widgets: configs,
|
|
63
|
+
accountId: HARNESS_ACCOUNT_ID,
|
|
64
|
+
layout,
|
|
65
|
+
stateOverride: state.renderState,
|
|
66
|
+
collapsedOverride: state.collapsed,
|
|
67
|
+
resetKey: `${state.variant}-${String(state.unhealthy)}`
|
|
68
|
+
}
|
|
69
|
+
) });
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
HARNESS_ACCOUNT_ID,
|
|
73
|
+
HARNESS_SOURCE_ID,
|
|
74
|
+
WidgetSurface,
|
|
75
|
+
toWidgetConfig
|
|
76
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Bricolage_Grotesque as HeadingFont,
|
|
3
|
+
Figtree as SansFont
|
|
4
|
+
} from "next/font/google";
|
|
5
|
+
const sans = SansFont({
|
|
6
|
+
subsets: ["latin"],
|
|
7
|
+
variable: "--font-sans-fallback",
|
|
8
|
+
fallback: ["system-ui", "Helvetica Neue", "Helvetica", "Arial"],
|
|
9
|
+
preload: true,
|
|
10
|
+
weight: ["300", "400", "500", "600", "700"]
|
|
11
|
+
});
|
|
12
|
+
const heading = HeadingFont({
|
|
13
|
+
subsets: ["latin"],
|
|
14
|
+
variable: "--font-heading-fallback",
|
|
15
|
+
fallback: ["system-ui", "Helvetica Neue", "Helvetica", "Arial"],
|
|
16
|
+
preload: true,
|
|
17
|
+
weight: ["200", "300", "400", "500", "600", "700", "800"]
|
|
18
|
+
});
|
|
19
|
+
const fontVariables = [sans, heading].map((f) => f.variable).join(" ");
|
|
20
|
+
export {
|
|
21
|
+
fontVariables
|
|
22
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { HttpFixture } from '../../registry.js';
|
|
2
|
+
import { type CompiledFixture, NoRecordedResponseError } from './http-fixtures.js';
|
|
3
|
+
/**
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* THE ONE PLACE A REQUEST CAN LEAVE THE HARNESS
|
|
6
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* `globalThis.fetch` is patched, once, at module import. That is a heavy
|
|
9
|
+
* instrument and it is the only one that works, for a reason worth stating
|
|
10
|
+
* plainly:
|
|
11
|
+
*
|
|
12
|
+
* AN INTERCEPTION A PARTNER CAN DECLINE TO WIRE IS NOT REALLY AN INTERCEPTION.
|
|
13
|
+
*
|
|
14
|
+
* The previous design had two interception points — a forced react-query
|
|
15
|
+
* `queryFn`, and a fetch handed to the partner's own `FetchProvider`. Both are
|
|
16
|
+
* sound where they apply, and between them they left a third path completely
|
|
17
|
+
* open: a widget using neither, a `useEffect` calling the global `fetch`,
|
|
18
|
+
* reached the real network in fixtures mode. The provider was mounted only
|
|
19
|
+
* when the partner supplied one, so omitting it was an escape hatch.
|
|
20
|
+
*
|
|
21
|
+
* Worse, that third path is the SAME population the old query-key fixture
|
|
22
|
+
* format shut out: a partner not using react-query had no working fixture
|
|
23
|
+
* surface *and* no interception. The one least served by the design was the
|
|
24
|
+
* one whose requests escaped. Patching the global closes both at once.
|
|
25
|
+
*
|
|
26
|
+
* ── What is deliberately NOT intercepted ─────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* Same-origin traffic passes through untouched — with ONE exception.
|
|
29
|
+
*
|
|
30
|
+
* Measured against a running harness, same-origin traffic is `/_next/static`,
|
|
31
|
+
* the document, and the `?_rsc=` payloads client navigation fetches: the
|
|
32
|
+
* framework's own plumbing, none of it the integration talking to an API.
|
|
33
|
+
* Intercepting it would break the app to no purpose.
|
|
34
|
+
*
|
|
35
|
+
* `/api/` is the exception, because it is the integration's own namespace. A
|
|
36
|
+
* first-party integration's widgets call `/api/integrations/<slug>/…` rather
|
|
37
|
+
* than the vendor directly — the vendor call happens server-side, where the
|
|
38
|
+
* credential lives — and our own Acme example is shaped exactly that way. The
|
|
39
|
+
* harness serves no backend, so passing those through guarantees a 404; the
|
|
40
|
+
* useful answer is a fixture, or a refusal that names the URL.
|
|
41
|
+
*
|
|
42
|
+
* Scoped to `/api/` rather than "everything except `/_next/`" deliberately: a
|
|
43
|
+
* rule that has to enumerate the framework's internals is a rule that breaks
|
|
44
|
+
* the next time Next adds one.
|
|
45
|
+
*
|
|
46
|
+
* ── Fail closed ──────────────────────────────────────────────────────────────
|
|
47
|
+
*
|
|
48
|
+
* The route table starts REFUSING EVERYTHING and only opens once a provider
|
|
49
|
+
* publishes one. react-query begins fetching during the mount commit, so a
|
|
50
|
+
* table installed from an effect would arrive after the first requests; the
|
|
51
|
+
* safe direction for that race is to refuse, never to allow.
|
|
52
|
+
*/
|
|
53
|
+
/** How the interceptor should answer third-party requests right now. */
|
|
54
|
+
export interface HarnessRoutingTable {
|
|
55
|
+
mode: 'fixtures' | 'live';
|
|
56
|
+
/** Compiled fixtures for the active variant. */
|
|
57
|
+
fixtures: readonly CompiledFixture[];
|
|
58
|
+
/** Origins live mode may reach. Empty in fixtures mode. */
|
|
59
|
+
egress: readonly string[];
|
|
60
|
+
/** Notified for every third-party call. Never throws into the caller. */
|
|
61
|
+
onCall?: (call: {
|
|
62
|
+
url: string;
|
|
63
|
+
method: string;
|
|
64
|
+
outcome: 'fixture' | 'network' | 'refused';
|
|
65
|
+
}) => void;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Publish the table the interceptor answers from.
|
|
69
|
+
*
|
|
70
|
+
* Called synchronously during render, which is a deliberate impurity: the
|
|
71
|
+
* alternative is an effect, and an effect runs AFTER react-query has begun
|
|
72
|
+
* fetching. The write is idempotent, so StrictMode's double invocation is
|
|
73
|
+
* harmless. Do not "fix" this into a `useEffect` — that reopens the race the
|
|
74
|
+
* fail-closed default exists to survive.
|
|
75
|
+
*/
|
|
76
|
+
export declare function publishHarnessRouting(table: HarnessRoutingTable): void;
|
|
77
|
+
/** Restore the fail-closed default — for unmount, and for tests. */
|
|
78
|
+
export declare function closeHarnessRouting(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Patch `globalThis.fetch`. Idempotent; safe to call from module scope.
|
|
81
|
+
*
|
|
82
|
+
* Returns the original, which is what same-origin traffic is passed through
|
|
83
|
+
* to — captured once so a second patch cannot chain onto our own wrapper.
|
|
84
|
+
*/
|
|
85
|
+
export declare function installHarnessFetch(): typeof globalThis.fetch;
|
|
86
|
+
/** Test seam: undo the patch and reset the table. */
|
|
87
|
+
export declare function uninstallHarnessFetch(original: typeof globalThis.fetch): void;
|
|
88
|
+
export { NoRecordedResponseError };
|
|
89
|
+
export type { HttpFixture };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { EgressDeniedError, isEgressAllowed } from "@ekanos/sdk/context";
|
|
3
|
+
import {
|
|
4
|
+
NoRecordedResponseError,
|
|
5
|
+
fixtureResponse,
|
|
6
|
+
matchHttpFixture
|
|
7
|
+
} from "./http-fixtures.js";
|
|
8
|
+
const CLOSED = {
|
|
9
|
+
mode: "fixtures",
|
|
10
|
+
fixtures: [],
|
|
11
|
+
egress: []
|
|
12
|
+
};
|
|
13
|
+
let active = CLOSED;
|
|
14
|
+
let installed = false;
|
|
15
|
+
let originalFetch;
|
|
16
|
+
let patchedFetch;
|
|
17
|
+
function redact(url) {
|
|
18
|
+
const safe = new URL(url.toString());
|
|
19
|
+
safe.username = "";
|
|
20
|
+
safe.password = "";
|
|
21
|
+
const hadQuery = safe.search !== "";
|
|
22
|
+
safe.search = "";
|
|
23
|
+
return `${safe.toString()}${hadQuery ? "?\u2026" : ""}`;
|
|
24
|
+
}
|
|
25
|
+
function notify(url, method, outcome) {
|
|
26
|
+
try {
|
|
27
|
+
active.onCall?.({ url: redact(url), method, outcome });
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function publishHarnessRouting(table) {
|
|
32
|
+
active = table;
|
|
33
|
+
}
|
|
34
|
+
function closeHarnessRouting() {
|
|
35
|
+
active = CLOSED;
|
|
36
|
+
}
|
|
37
|
+
function installHarnessFetch() {
|
|
38
|
+
if (installed && originalFetch !== void 0 && globalThis.fetch === patchedFetch) {
|
|
39
|
+
return originalFetch;
|
|
40
|
+
}
|
|
41
|
+
const original = globalThis.fetch.bind(globalThis);
|
|
42
|
+
installed = true;
|
|
43
|
+
originalFetch = original;
|
|
44
|
+
const intercept = async (input, init) => {
|
|
45
|
+
const target = typeof input === "string" || input instanceof URL ? String(input) : input.url;
|
|
46
|
+
const method = (init?.method ?? (typeof input === "object" && "method" in input ? input.method : "GET") ?? "GET").toUpperCase();
|
|
47
|
+
let url;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(target, globalThis.location?.href);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new NoRecordedResponseError(method, target, true);
|
|
52
|
+
}
|
|
53
|
+
if (globalThis.location !== void 0 && url.origin === globalThis.location.origin && !url.pathname.startsWith("/api/")) {
|
|
54
|
+
return original(input, init);
|
|
55
|
+
}
|
|
56
|
+
if (active.mode === "live") {
|
|
57
|
+
if (!isEgressAllowed(url.toString(), active.egress)) {
|
|
58
|
+
notify(url, method, "refused");
|
|
59
|
+
throw new EgressDeniedError(url.origin);
|
|
60
|
+
}
|
|
61
|
+
notify(url, method, "network");
|
|
62
|
+
return original(input, {
|
|
63
|
+
...init,
|
|
64
|
+
// An allowlist is a promise about ONE url; following a redirect would
|
|
65
|
+
// silently make it a promise about wherever that points next, and a
|
|
66
|
+
// 307/308 carries the method and body along. A browser cannot re-vet
|
|
67
|
+
// hops — `redirect: 'manual'` yields an opaque response with no
|
|
68
|
+
// readable Location — so refusing loudly is the only sound choice.
|
|
69
|
+
redirect: "error"
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const fixture = matchHttpFixture(method, url.toString(), active.fixtures);
|
|
73
|
+
if (!fixture) {
|
|
74
|
+
notify(url, method, "refused");
|
|
75
|
+
throw new NoRecordedResponseError(
|
|
76
|
+
method,
|
|
77
|
+
redact(url),
|
|
78
|
+
globalThis.location === void 0 && url.pathname.startsWith("/api/")
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
notify(url, method, "fixture");
|
|
82
|
+
return fixtureResponse(fixture);
|
|
83
|
+
};
|
|
84
|
+
globalThis.fetch = intercept;
|
|
85
|
+
patchedFetch = intercept;
|
|
86
|
+
return original;
|
|
87
|
+
}
|
|
88
|
+
function uninstallHarnessFetch(original) {
|
|
89
|
+
globalThis.fetch = original;
|
|
90
|
+
installed = false;
|
|
91
|
+
originalFetch = void 0;
|
|
92
|
+
patchedFetch = void 0;
|
|
93
|
+
closeHarnessRouting();
|
|
94
|
+
}
|
|
95
|
+
export {
|
|
96
|
+
NoRecordedResponseError,
|
|
97
|
+
closeHarnessRouting,
|
|
98
|
+
installHarnessFetch,
|
|
99
|
+
publishHarnessRouting,
|
|
100
|
+
uninstallHarnessFetch
|
|
101
|
+
};
|