mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: phase 4 integrations — connectors, rybbit analytics, webhooks
- core.integrations (migration 00005) with AES-256-GCM-encrypted credentials (PRIMORA_ENCRYPTION_KEY, hex/base64, dev fallback); has_credentials serialization only — secrets never leave the server - /projects/:id/integrations list/create/delete/test; health check hits the stored base URL with a 5s timeout and records status - Rybbit connector: GET …/integrations/:id/analytics proxies /api/organizations + /api/sites/* server-side into a normalized shape - core.webhooks + core.webhook_deliveries; CRUD, deliveries, test, and POST …/deploy-markers. Events: issue.created (new fingerprint on ingest), deploy.marker, webhook.test. X-Primora-Signature HMAC-SHA256 over the raw body; in-process dispatcher, 3 attempts, quadratic backoff, 10s timeout, pending-scan on boot - HTTPS enforced for public targets; HTTP allowed for loopback/private/ self-hosted sinks - Integrations page (connectors + webhooks tabs, inline analytics, delivery log, secret-shown-once), demo-mode stubs - 12 new primora_* MCP tools - workspace 0.4.0 Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
8cfddf9184
commit
e25eb92339
82 files changed
+5710
-61
No files matched your search
@@ -41,6 +41,7 @@ import {
|
||||
CollectionsPage,
|
||||
DatabasesPage,
|
||||
TelemetryPage,
|
||||
IntegrationsPage,
|
||||
LoginPage,
|
||||
MembersPage,
|
||||
ProjectsPage,
|
||||
@@ -1635,6 +1636,14 @@ export default function App() {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "integrations"}>
|
||||
<IntegrationsPage
|
||||
projectID={activeProject()?.id}
|
||||
canManage={canUpdateProject()}
|
||||
demoMode={isDemo}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "storage"}>
|
||||
<StoragePage
|
||||
buckets={buckets()}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconCollections,
|
||||
IconDatabases,
|
||||
IconTelemetry,
|
||||
IconIntegrations,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
IconSettings,
|
||||
@@ -28,6 +29,7 @@ export type ViewType =
|
||||
| "collections"
|
||||
| "databases"
|
||||
| "telemetry"
|
||||
| "integrations"
|
||||
| "auth"
|
||||
| "audit"
|
||||
| "settings";
|
||||
@@ -43,14 +45,15 @@ const projectNav: NavEntry[] = [
|
||||
{ id: "dashboard", label: "Overview", icon: IconOverview },
|
||||
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
|
||||
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
|
||||
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
|
||||
];
|
||||
|
||||
// Instagram-style bottom bar caps at five slots — Collections and Telemetry stay reachable via Menu.
|
||||
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections" && e.id !== "telemetry");
|
||||
// Instagram-style bottom bar caps at five slots — the rest stay reachable via Menu.
|
||||
const bottomNav: NavEntry[] = projectNav.filter((e) => !["collections", "telemetry", "integrations"].includes(e.id));
|
||||
|
||||
const workspaceNav: NavEntry[] = [
|
||||
{ id: "projects", label: "Projects", icon: IconProjects },
|
||||
@@ -88,6 +91,7 @@ const viewTitle: Record<ViewType, string> = {
|
||||
collections: "Collections",
|
||||
databases: "Databases",
|
||||
telemetry: "Telemetry",
|
||||
integrations: "Integrations",
|
||||
auth: "Authentication",
|
||||
audit: "Audit log",
|
||||
settings: "Settings",
|
||||
|
||||
@@ -128,6 +128,7 @@ export const IconMoon = icon(() => (<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 002
|
||||
export const IconLayers = icon(() => (<><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" /></>));
|
||||
export const IconZap = icon(() => (<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />));
|
||||
export const IconTelemetry = icon(() => (<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />));
|
||||
export const IconIntegrations = icon(() => (<><path d="M9 17H7A5 5 0 0 1 7 7h2" /><path d="M15 7h2a5 5 0 1 1 0 10h-2" /><line x1="8" x2="16" y1="12" y2="12" /></>));
|
||||
export const IconClock = icon(() => (<><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>));
|
||||
export const IconLink = icon(() => (<><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /></>));
|
||||
export const IconOrganization = icon(() => (<><rect x="4" y="2" width="16" height="20" rx="2" /><path d="M9 22v-4h6v4" /><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M8 10h.01M16 10h.01M8 14h.01M16 14h.01M12 14h.01" /></>));
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Integration,
|
||||
OrganizationInvitation,
|
||||
OrganizationMember,
|
||||
ProjectMember,
|
||||
WebhookDelivery,
|
||||
} from "@primora/api-client";
|
||||
import type {
|
||||
ApiKey,
|
||||
@@ -15,7 +17,10 @@ import type {
|
||||
BucketObject,
|
||||
Collection,
|
||||
DBConnection,
|
||||
DeployMarker,
|
||||
Document,
|
||||
IntegrationAnalytics,
|
||||
IntegrationTestResult,
|
||||
MeResponse,
|
||||
MetricPoint,
|
||||
OrganizationSummary,
|
||||
@@ -25,6 +30,8 @@ import type {
|
||||
TelemetryEvent,
|
||||
TelemetryIssue,
|
||||
TelemetryStats,
|
||||
Webhook,
|
||||
WebhookCreateResponse,
|
||||
} from "@primora/api-client";
|
||||
|
||||
const STORAGE_KEY = "primora_demo_mode";
|
||||
@@ -421,6 +428,63 @@ const pushAudit = (action: string, resourceType: string, resourceId: string, met
|
||||
/* demo service — same call shapes the app expects from the real API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const demoRybbitSites = [
|
||||
{ site_id: "ryb-site-1", name: "Marketing site", domain: "acme.dev" },
|
||||
{ site_id: "ryb-site-2", name: "Docs", domain: "docs.acme.dev" },
|
||||
];
|
||||
|
||||
const demoIntegrations: Integration[] = [
|
||||
{
|
||||
id: "demo-int-1",
|
||||
project_id: "demo-project-1",
|
||||
type: Integration.type.RYBBIT,
|
||||
name: "rybbit-prod",
|
||||
base_url: "https://analytics.acme.dev",
|
||||
site_id: "ryb-site-1",
|
||||
status: Integration.status.OK,
|
||||
last_health_at: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
has_credentials: true,
|
||||
created_at: new Date(Date.now() - 14 * 86_400_000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const demoWebhooks: Webhook[] = [
|
||||
{
|
||||
id: "demo-hook-1",
|
||||
project_id: "demo-project-1",
|
||||
url: "https://hooks.acme.dev/primora/issues",
|
||||
events: ["issue.created"],
|
||||
enabled: true,
|
||||
has_secret: true,
|
||||
created_at: new Date(Date.now() - 9 * 86_400_000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const demoDeliveries: WebhookDelivery[] = [
|
||||
{
|
||||
id: "demo-del-1",
|
||||
webhook_id: "demo-hook-1",
|
||||
event_type: "issue.created",
|
||||
payload: { event: "issue.created", data: { fingerprint: "9f2a1c", message: "TypeError: x is undefined" } },
|
||||
status: WebhookDelivery.status.DELIVERED,
|
||||
attempts: 1,
|
||||
last_status_code: 200,
|
||||
delivered_at: new Date(Date.now() - 2 * 3_600_000).toISOString(),
|
||||
created_at: new Date(Date.now() - 2 * 3_600_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "demo-del-2",
|
||||
webhook_id: "demo-hook-1",
|
||||
event_type: "deploy.marker",
|
||||
payload: { event: "deploy.marker", data: { version: "0.3.0", environment: "production" } },
|
||||
status: WebhookDelivery.status.FAILED,
|
||||
attempts: 3,
|
||||
last_status_code: 503,
|
||||
last_error: "endpoint returned 503",
|
||||
created_at: new Date(Date.now() - 26 * 3_600_000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
class DemoService {
|
||||
private delay(ms = 250) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -1017,6 +1081,159 @@ class DemoService {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* integrations + webhooks */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
async listIntegrations(): Promise<{ items: Integration[] }> {
|
||||
await this.delay();
|
||||
return { items: demoIntegrations };
|
||||
}
|
||||
|
||||
async createIntegration(data: {
|
||||
requestBody?: { name?: string; type?: string; base_url?: string; api_key?: string; site_id?: string };
|
||||
}): Promise<Integration> {
|
||||
await this.delay();
|
||||
const item: Integration = {
|
||||
id: `demo-int-${demoIntegrations.length + 1}`,
|
||||
project_id: "demo-project-1",
|
||||
type: Integration.type.RYBBIT,
|
||||
name: data.requestBody?.name ?? "connector",
|
||||
base_url: data.requestBody?.base_url ?? "https://analytics.example.com",
|
||||
site_id: data.requestBody?.site_id || undefined,
|
||||
status: Integration.status.UNKNOWN,
|
||||
has_credentials: !!data.requestBody?.api_key,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
demoIntegrations.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
async deleteIntegration(data: { integrationId?: string }) {
|
||||
await this.delay();
|
||||
const idx = demoIntegrations.findIndex((i) => i.id === data.integrationId);
|
||||
if (idx >= 0) demoIntegrations.splice(idx, 1);
|
||||
return {};
|
||||
}
|
||||
|
||||
async testIntegration(data: { integrationId?: string }): Promise<IntegrationTestResult> {
|
||||
await this.delay();
|
||||
const item = demoIntegrations.find((i) => i.id === data.integrationId);
|
||||
if (item) {
|
||||
item.status = Integration.status.OK;
|
||||
item.last_health_at = new Date().toISOString();
|
||||
}
|
||||
return { ok: true, status_code: 200 };
|
||||
}
|
||||
|
||||
async getIntegrationAnalytics(data: { integrationId?: string; site?: string; days?: number }): Promise<IntegrationAnalytics> {
|
||||
await this.delay();
|
||||
const days = data.days ?? 30;
|
||||
const site = demoRybbitSites.find((s) => s.site_id === data.site) ?? demoRybbitSites[0];
|
||||
return {
|
||||
integration_id: data.integrationId ?? "demo-int-1",
|
||||
site,
|
||||
sites: demoRybbitSites,
|
||||
overview: {
|
||||
sessions: 18_432,
|
||||
pageviews: 61_908,
|
||||
users: 12_077,
|
||||
pages_per_session: 3.4,
|
||||
bounce_rate: 41.2,
|
||||
session_duration: 187,
|
||||
},
|
||||
series: Array.from({ length: days }, (_, i) => ({
|
||||
time: new Date(Date.now() - (days - 1 - i) * 86_400_000).toISOString(),
|
||||
sessions: 480 + Math.round(Math.sin(i / 3) * 120),
|
||||
pageviews: 1600 + Math.round(Math.sin(i / 3) * 500),
|
||||
users: 320 + Math.round(Math.sin(i / 4) * 90),
|
||||
bounce_rate: 38 + Math.sin(i / 5) * 6,
|
||||
})),
|
||||
top_pages: [
|
||||
{ value: "/", count: 14_203 },
|
||||
{ value: "/pricing", count: 6_811 },
|
||||
{ value: "/docs/getting-started", count: 5_902 },
|
||||
{ value: "/blog/launch", count: 3_114 },
|
||||
],
|
||||
top_referrers: [
|
||||
{ value: "google.com", count: 7_540 },
|
||||
{ value: "news.ycombinator.com", count: 2_307 },
|
||||
{ value: "github.com", count: 1_882 },
|
||||
],
|
||||
window_days: days,
|
||||
};
|
||||
}
|
||||
|
||||
async listWebhooks(): Promise<{ items: Webhook[] }> {
|
||||
await this.delay();
|
||||
return { items: demoWebhooks };
|
||||
}
|
||||
|
||||
async createWebhook(data: {
|
||||
requestBody?: { url?: string; secret?: string; events?: string[]; enabled?: boolean };
|
||||
}): Promise<WebhookCreateResponse> {
|
||||
await this.delay();
|
||||
const generated = !data.requestBody?.secret;
|
||||
const hook: Webhook = {
|
||||
id: `demo-hook-${demoWebhooks.length + 1}`,
|
||||
project_id: "demo-project-1",
|
||||
url: data.requestBody?.url ?? "https://hooks.example.com/primora",
|
||||
events: data.requestBody?.events ?? [],
|
||||
enabled: data.requestBody?.enabled ?? true,
|
||||
has_secret: true,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
demoWebhooks.push(hook);
|
||||
return { ...hook, secret: generated ? "demo-secret-" + Math.random().toString(36).slice(2, 18) : undefined };
|
||||
}
|
||||
|
||||
async updateWebhook(data: { webhookId?: string; requestBody?: Partial<Webhook> & { secret?: string } }): Promise<WebhookCreateResponse> {
|
||||
await this.delay();
|
||||
const hook = demoWebhooks.find((w) => w.id === data.webhookId);
|
||||
if (hook && data.requestBody) {
|
||||
if (data.requestBody.url !== undefined) hook.url = data.requestBody.url;
|
||||
if (data.requestBody.events !== undefined) hook.events = data.requestBody.events;
|
||||
if (data.requestBody.enabled !== undefined) hook.enabled = data.requestBody.enabled;
|
||||
if (data.requestBody.secret !== undefined) hook.has_secret = true;
|
||||
}
|
||||
return { ...(hook as Webhook) };
|
||||
}
|
||||
|
||||
async deleteWebhook(data: { webhookId?: string }) {
|
||||
await this.delay();
|
||||
const idx = demoWebhooks.findIndex((w) => w.id === data.webhookId);
|
||||
if (idx >= 0) demoWebhooks.splice(idx, 1);
|
||||
return {};
|
||||
}
|
||||
|
||||
async listWebhookDeliveries(data: { webhookId?: string }): Promise<{ items: WebhookDelivery[] }> {
|
||||
await this.delay();
|
||||
return { items: demoDeliveries.filter((d) => d.webhook_id === data.webhookId) };
|
||||
}
|
||||
|
||||
async testWebhook(data: { webhookId?: string }): Promise<WebhookDelivery> {
|
||||
await this.delay();
|
||||
const delivery: WebhookDelivery = {
|
||||
id: `demo-del-${Date.now()}`,
|
||||
webhook_id: data.webhookId ?? "demo-hook-1",
|
||||
event_type: "webhook.test",
|
||||
status: WebhookDelivery.status.DELIVERED,
|
||||
attempts: 1,
|
||||
last_status_code: 200,
|
||||
delivered_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
demoDeliveries.unshift(delivery);
|
||||
return delivery;
|
||||
}
|
||||
|
||||
async createDeployMarker(data: {
|
||||
requestBody?: { version?: string; ref?: string; environment?: string; note?: string };
|
||||
}): Promise<DeployMarker> {
|
||||
await this.delay();
|
||||
return { ...(data.requestBody ?? {}), recorded_at: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
export const demoService = new DemoService();
|
||||
@@ -0,0 +1,764 @@
|
||||
import { For, Show, createEffect, createSignal } from "solid-js";
|
||||
import {
|
||||
CreateIntegrationRequest,
|
||||
IntegrationsService,
|
||||
WebhooksService,
|
||||
type Integration,
|
||||
type IntegrationAnalytics,
|
||||
type Webhook,
|
||||
type WebhookDelivery,
|
||||
} from "@primora/api-client";
|
||||
import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Input, Select } from "../components/Input";
|
||||
import {
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
IconPlay,
|
||||
IconChevronRight,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
} from "../components/Icons";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
projectID?: string;
|
||||
canManage: boolean;
|
||||
demoMode: boolean;
|
||||
}
|
||||
|
||||
type Tab = "integrations" | "webhooks";
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const intSvc = (demo: boolean) =>
|
||||
(demo ? (demoService as unknown as typeof IntegrationsService) : IntegrationsService);
|
||||
const hookSvc = (demo: boolean) =>
|
||||
(demo ? (demoService as unknown as typeof WebhooksService) : WebhooksService);
|
||||
|
||||
const fmtTime = (ts?: string | null) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||
const fmtNum = (n?: number) => (n == null ? "—" : n.toLocaleString());
|
||||
|
||||
const statusVariant = (s?: string) =>
|
||||
s === "ok" || s === "delivered"
|
||||
? "success"
|
||||
: s === "error" || s === "failed"
|
||||
? "error"
|
||||
: s === "pending"
|
||||
? "warning"
|
||||
: "neutral";
|
||||
|
||||
const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test"] as const;
|
||||
|
||||
export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const [tab, setTab] = createSignal<Tab>("integrations");
|
||||
const [integrations, setIntegrations] = createSignal<Integration[]>([]);
|
||||
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
|
||||
const [deliveries, setDeliveries] = createSignal<Record<string, WebhookDelivery[]>>({});
|
||||
const [analytics, setAnalytics] = createSignal<Record<string, IntegrationAnalytics>>({});
|
||||
const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({});
|
||||
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
|
||||
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
||||
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
|
||||
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
|
||||
const [integrationForm, setIntegrationForm] = createSignal({ name: "", base_url: "", api_key: "", site_id: "" });
|
||||
const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true });
|
||||
const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" });
|
||||
const [createdSecret, setCreatedSecret] = createSignal("");
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [busy, setBusy] = createSignal<string>();
|
||||
const [message, setMessage] = createSignal("");
|
||||
const [error, setError] = createSignal("");
|
||||
|
||||
const refresh = async () => {
|
||||
if (!props.projectID) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [int, hooks] = await Promise.all([
|
||||
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
||||
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
|
||||
]);
|
||||
setIntegrations(int.items ?? []);
|
||||
setWebhooks(hooks.items ?? []);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (props.projectID) void refresh();
|
||||
});
|
||||
|
||||
const run = async (key: string, task: () => Promise<unknown>, done?: string) => {
|
||||
setBusy(key);
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
await task();
|
||||
if (done) setMessage(done);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const createIntegration = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const form = integrationForm();
|
||||
void run("create-integration", async () => {
|
||||
await intSvc(props.demoMode).createIntegration({
|
||||
projectId: props.projectID!,
|
||||
requestBody: {
|
||||
name: form.name.trim(),
|
||||
type: CreateIntegrationRequest.type.RYBBIT,
|
||||
base_url: form.base_url.trim(),
|
||||
api_key: form.api_key.trim() || undefined,
|
||||
site_id: form.site_id.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setShowCreateIntegration(false);
|
||||
setIntegrationForm({ name: "", base_url: "", api_key: "", site_id: "" });
|
||||
}, "Integration created");
|
||||
};
|
||||
|
||||
const testIntegration = (id: string) =>
|
||||
run(`test-${id}`, async () => {
|
||||
const res = await intSvc(props.demoMode).testIntegration({ projectId: props.projectID!, integrationId: id });
|
||||
setMessage(res.ok ? `Health check passed (${res.status_code ?? "—"})` : `Health check failed: ${res.error ?? "unknown"}`);
|
||||
});
|
||||
|
||||
const deleteIntegration = (id: string) =>
|
||||
run(
|
||||
`del-int-${id}`,
|
||||
() => intSvc(props.demoMode).deleteIntegration({ projectId: props.projectID!, integrationId: id }),
|
||||
"Integration deleted",
|
||||
);
|
||||
|
||||
const loadAnalytics = async (integration: Integration, site?: string) => {
|
||||
if (!props.projectID) return;
|
||||
setBusy(`analytics-${integration.id}`);
|
||||
setError("");
|
||||
try {
|
||||
const res = await intSvc(props.demoMode).getIntegrationAnalytics({
|
||||
projectId: props.projectID,
|
||||
integrationId: integration.id,
|
||||
site: site || analyticsSite()[integration.id] || integration.site_id || undefined,
|
||||
days: 30,
|
||||
});
|
||||
setAnalytics((c) => ({ ...c, [integration.id]: res }));
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAnalytics = (integration: Integration) => {
|
||||
if (expandedIntegration() === integration.id) {
|
||||
setExpandedIntegration(undefined);
|
||||
return;
|
||||
}
|
||||
setExpandedIntegration(integration.id);
|
||||
void loadAnalytics(integration);
|
||||
};
|
||||
|
||||
const createWebhook = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const form = webhookForm();
|
||||
void run("create-webhook", async () => {
|
||||
const res = await hookSvc(props.demoMode).createWebhook({
|
||||
projectId: props.projectID!,
|
||||
requestBody: {
|
||||
url: form.url.trim(),
|
||||
secret: form.secret.trim() || undefined,
|
||||
events: form.events as Array<"issue.created" | "deploy.marker" | "webhook.test">,
|
||||
enabled: form.enabled,
|
||||
},
|
||||
});
|
||||
if (res.secret) setCreatedSecret(res.secret);
|
||||
setShowCreateWebhook(false);
|
||||
setWebhookForm({ url: "", secret: "", events: [], enabled: true });
|
||||
}, "Webhook created");
|
||||
};
|
||||
|
||||
const toggleWebhook = (hook: Webhook) =>
|
||||
run(
|
||||
`toggle-${hook.id}`,
|
||||
() =>
|
||||
hookSvc(props.demoMode).updateWebhook({
|
||||
projectId: props.projectID!,
|
||||
webhookId: hook.id,
|
||||
requestBody: { enabled: !hook.enabled },
|
||||
}),
|
||||
hook.enabled ? "Webhook disabled" : "Webhook enabled",
|
||||
);
|
||||
|
||||
const deleteWebhook = (id: string) =>
|
||||
run(
|
||||
`del-hook-${id}`,
|
||||
() => hookSvc(props.demoMode).deleteWebhook({ projectId: props.projectID!, webhookId: id }),
|
||||
"Webhook deleted",
|
||||
);
|
||||
|
||||
const testWebhook = (id: string) =>
|
||||
run(
|
||||
`test-hook-${id}`,
|
||||
async () => {
|
||||
await hookSvc(props.demoMode).testWebhook({ projectId: props.projectID!, webhookId: id });
|
||||
await loadDeliveries(id);
|
||||
},
|
||||
"Test delivery queued",
|
||||
);
|
||||
|
||||
const loadDeliveries = async (webhookId: string) => {
|
||||
if (!props.projectID) return;
|
||||
try {
|
||||
const res = await hookSvc(props.demoMode).listWebhookDeliveries({ projectId: props.projectID, webhookId, limit: 25 });
|
||||
setDeliveries((c) => ({ ...c, [webhookId]: res.items ?? [] }));
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeliveries = (hook: Webhook) => {
|
||||
if (expandedWebhook() === hook.id) {
|
||||
setExpandedWebhook(undefined);
|
||||
return;
|
||||
}
|
||||
setExpandedWebhook(hook.id);
|
||||
void loadDeliveries(hook.id);
|
||||
};
|
||||
|
||||
const recordMarker = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const form = markerForm();
|
||||
void run(
|
||||
"marker",
|
||||
async () => {
|
||||
await hookSvc(props.demoMode).createDeployMarker({
|
||||
projectId: props.projectID!,
|
||||
requestBody: {
|
||||
version: form.version.trim() || undefined,
|
||||
ref: form.ref.trim() || undefined,
|
||||
environment: form.environment.trim() || undefined,
|
||||
note: form.note.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setMarkerForm({ version: "", ref: "", environment: "", note: "" });
|
||||
},
|
||||
"Deploy marker recorded — deploy.marker fired",
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEvent = (ev: string) =>
|
||||
setWebhookForm((c) => ({
|
||||
...c,
|
||||
events: c.events.includes(ev) ? c.events.filter((x) => x !== ev) : [...c.events, ev],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Integrations</h1>
|
||||
<p class="page-description">External connectors and outbound webhooks for this project.</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onClick={() => void refresh()} disabled={loading()}>
|
||||
<IconRefresh class="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={message()}>
|
||||
<div class="message message-success mb-4">{message()}</div>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<div class="message message-error mb-4">{error()}</div>
|
||||
</Show>
|
||||
<Show when={createdSecret()}>
|
||||
<div class="message message-warning mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<strong>Webhook secret — shown once.</strong>
|
||||
<code class="block mt-1" style="word-break: break-all">{createdSecret()}</code>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" onClick={() => navigator.clipboard?.writeText(createdSecret())} title="Copy secret">
|
||||
<IconCopy class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onClick={() => setCreatedSecret("")} title="Dismiss">
|
||||
<IconCheck class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="tabs mb-4">
|
||||
<button class={`tab ${tab() === "integrations" ? "active" : ""}`} onClick={() => setTab("integrations")}>
|
||||
Connectors
|
||||
</button>
|
||||
<button class={`tab ${tab() === "webhooks" ? "active" : ""}`} onClick={() => setTab("webhooks")}>
|
||||
Webhooks
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={tab() === "integrations"}>
|
||||
<div class="card card-flush">
|
||||
<div class="card-header">
|
||||
<div class="flex-1">
|
||||
<div class="card-header-title">Connectors</div>
|
||||
<div class="card-header-description">
|
||||
External services attached to this project. Credentials are encrypted at rest and never leave the server.
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.canManage}>
|
||||
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateIntegration(true)}>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
Add connector
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={integrations().length > 0}
|
||||
fallback={
|
||||
<p class="text-text-2 text-sm p-4">
|
||||
No connectors yet. Add a Rybbit instance to pull analytics into this project.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Base URL</th>
|
||||
<th>Status</th>
|
||||
<th class="hidden md:table-cell">Last check</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={integrations()}>
|
||||
{(item) => (
|
||||
<>
|
||||
<tr>
|
||||
<td class="font-medium">{item.name}</td>
|
||||
<td>
|
||||
<Badge variant="primary">{item.type}</Badge>
|
||||
</td>
|
||||
<td class="text-text-2" style="max-width:12rem;overflow:hidden;text-overflow:ellipsis">{item.base_url}</td>
|
||||
<td>
|
||||
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
|
||||
</td>
|
||||
<td class="hidden md:table-cell text-text-2">{fmtTime(item.last_health_at)}</td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<Show when={item.type === "rybbit"}>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => toggleAnalytics(item)}
|
||||
disabled={busy() === `analytics-${item.id}`}
|
||||
>
|
||||
<IconChevronRight class={`w-4 h-4 ${expandedIntegration() === item.id ? "rotate-90" : ""}`} />
|
||||
Analytics
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => void testIntegration(item.id)}
|
||||
disabled={busy() === `test-${item.id}`}
|
||||
>
|
||||
<IconPlay class="w-4 h-4" />
|
||||
Test
|
||||
</button>
|
||||
<Show when={props.canManage}>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => void deleteIntegration(item.id)}
|
||||
disabled={busy() === `del-int-${item.id}`}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
<Show when={expandedIntegration() === item.id}>
|
||||
<tr>
|
||||
<td colspan={6} style="padding:0">
|
||||
<AnalyticsPanel
|
||||
analytics={analytics()[item.id]}
|
||||
loading={busy() === `analytics-${item.id}`}
|
||||
site={analyticsSite()[item.id] ?? item.site_id ?? ""}
|
||||
onSiteChange={(site) => {
|
||||
setAnalyticsSite((c) => ({ ...c, [item.id]: site }));
|
||||
void loadAnalytics(item, site);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={tab() === "webhooks"}>
|
||||
<div class="card card-flush">
|
||||
<div class="card-header">
|
||||
<div class="flex-1">
|
||||
<div class="card-header-title">Outbound webhooks</div>
|
||||
<div class="card-header-description">
|
||||
Primora signs each delivery with <code>X-Primora-Signature</code> (HMAC-SHA256 over the raw body).
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.canManage}>
|
||||
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateWebhook(true)}>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
Add webhook
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={webhooks().length > 0}
|
||||
fallback={
|
||||
<p class="text-text-2 text-sm p-4">
|
||||
No webhooks yet. Add an HTTPS endpoint to receive issue.created and deploy.marker events.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Endpoint</th>
|
||||
<th>Events</th>
|
||||
<th>State</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={webhooks()}>
|
||||
{(hook) => (
|
||||
<>
|
||||
<tr>
|
||||
<td class="font-medium" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">{hook.url}</td>
|
||||
<td>
|
||||
<Show when={hook.events.length > 0} fallback={<span class="text-text-2">all</span>}>
|
||||
<For each={hook.events}>{(ev) => <Badge variant="neutral" class="mr-1">{ev}</Badge>}</For>
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Badge variant={hook.enabled ? "success" : "neutral"}>
|
||||
{hook.enabled ? "enabled" : "disabled"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<button class="btn btn-ghost btn-sm" onClick={() => toggleDeliveries(hook)}>
|
||||
<IconChevronRight class={`w-4 h-4 ${expandedWebhook() === hook.id ? "rotate-90" : ""}`} />
|
||||
Deliveries
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => void testWebhook(hook.id)}
|
||||
disabled={busy() === `test-hook-${hook.id}`}
|
||||
>
|
||||
<IconPlay class="w-4 h-4" />
|
||||
Test
|
||||
</button>
|
||||
<Show when={props.canManage}>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => void toggleWebhook(hook)}
|
||||
disabled={busy() === `toggle-${hook.id}`}
|
||||
>
|
||||
{hook.enabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => void deleteWebhook(hook.id)}
|
||||
disabled={busy() === `del-hook-${hook.id}`}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
<Show when={expandedWebhook() === hook.id}>
|
||||
<tr>
|
||||
<td colspan={4} style="padding:0">
|
||||
<DeliveriesPanel deliveries={deliveries()[hook.id]} />
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.canManage}>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<div class="flex-1">
|
||||
<div class="card-header-title">Deploy marker</div>
|
||||
<div class="card-header-description">
|
||||
Record a deployment and fire <code>deploy.marker</code> to subscribed webhooks.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={recordMarker} class="grid gap-3 sm:grid-cols-2 p-4">
|
||||
<Input
|
||||
label="Version"
|
||||
placeholder="1.4.2"
|
||||
value={markerForm().version}
|
||||
onInput={(e) => setMarkerForm((c) => ({ ...c, version: e.currentTarget.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Ref"
|
||||
placeholder="git sha or tag"
|
||||
value={markerForm().ref}
|
||||
onInput={(e) => setMarkerForm((c) => ({ ...c, ref: e.currentTarget.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Environment"
|
||||
placeholder="production"
|
||||
value={markerForm().environment}
|
||||
onInput={(e) => setMarkerForm((c) => ({ ...c, environment: e.currentTarget.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Note"
|
||||
placeholder="optional"
|
||||
value={markerForm().note}
|
||||
onInput={(e) => setMarkerForm((c) => ({ ...c, note: e.currentTarget.value }))}
|
||||
/>
|
||||
<div class="sm:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-secondary"
|
||||
disabled={busy() === "marker" || (!markerForm().version.trim() && !markerForm().ref.trim())}
|
||||
>
|
||||
Record deploy marker
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Modal open={showCreateIntegration()} onClose={() => setShowCreateIntegration(false)} title="Add connector" size="md">
|
||||
<form onSubmit={createIntegration} class="space-y-4">
|
||||
<Select label="Type" value="rybbit" disabled options={[{ value: "rybbit", label: "Rybbit — web analytics" }]} />
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="marketing-site"
|
||||
required
|
||||
value={integrationForm().name}
|
||||
onInput={(e) => setIntegrationForm((c) => ({ ...c, name: e.currentTarget.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Base URL"
|
||||
placeholder="https://analytics.example.com"
|
||||
required
|
||||
value={integrationForm().base_url}
|
||||
onInput={(e) => setIntegrationForm((c) => ({ ...c, base_url: e.currentTarget.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="API key"
|
||||
type="password"
|
||||
placeholder="Stored encrypted — never shown again"
|
||||
value={integrationForm().api_key}
|
||||
onInput={(e) => setIntegrationForm((c) => ({ ...c, api_key: e.currentTarget.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Site ID"
|
||||
placeholder="optional — defaults to the first site"
|
||||
value={integrationForm().site_id}
|
||||
onInput={(e) => setIntegrationForm((c) => ({ ...c, site_id: e.currentTarget.value }))}
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateIntegration(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={busy() === "create-integration"}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showCreateWebhook()} onClose={() => setShowCreateWebhook(false)} title="Add webhook" size="md">
|
||||
<form onSubmit={createWebhook} class="space-y-4">
|
||||
<Input
|
||||
label="Endpoint URL"
|
||||
placeholder="https://hooks.example.com/primora"
|
||||
required
|
||||
value={webhookForm().url}
|
||||
onInput={(e) => setWebhookForm((c) => ({ ...c, url: e.currentTarget.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Secret"
|
||||
type="password"
|
||||
placeholder="Leave empty to generate"
|
||||
value={webhookForm().secret}
|
||||
onInput={(e) => setWebhookForm((c) => ({ ...c, secret: e.currentTarget.value }))}
|
||||
/>
|
||||
<div>
|
||||
<span class="label">Events</span>
|
||||
<div class="flex gap-2 flex-wrap mt-1">
|
||||
<For each={EVENT_TYPES}>
|
||||
{(ev) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`btn btn-sm ${webhookForm().events.includes(ev) ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => toggleEvent(ev)}
|
||||
>
|
||||
{ev}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p class="text-text-2 text-xs mt-1">No selection means the endpoint receives every event.</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateWebhook(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={busy() === "create-webhook"}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsPanel(props: {
|
||||
analytics?: IntegrationAnalytics;
|
||||
loading: boolean;
|
||||
site: string;
|
||||
onSiteChange: (site: string) => void;
|
||||
}) {
|
||||
const stats = () => props.analytics?.overview;
|
||||
const stat = (label: string, value?: number) => (
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">{label}</span>
|
||||
<span class="stat-value">{fmtNum(value)}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div class="p-4" style="background: var(--surface-2)">
|
||||
<Show when={props.loading}>
|
||||
<p class="text-text-2 text-sm">Loading analytics…</p>
|
||||
</Show>
|
||||
<Show when={!props.loading && props.analytics}>
|
||||
<Show when={(props.analytics?.sites?.length ?? 0) > 1}>
|
||||
<div class="mb-3" style="max-width:20rem">
|
||||
<Select label="Site" value={props.site} onChange={(e) => props.onSiteChange(e.currentTarget.value)}>
|
||||
<For each={props.analytics?.sites ?? []}>
|
||||
{(s) => (
|
||||
<option value={s.site_id}>
|
||||
{s.name}
|
||||
{s.domain ? ` (${s.domain})` : ""}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</Select>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
{stat("Sessions", stats()?.sessions)}
|
||||
{stat("Pageviews", stats()?.pageviews)}
|
||||
{stat("Users", stats()?.users)}
|
||||
{stat("Pages/session", stats()?.pages_per_session)}
|
||||
{stat("Bounce %", stats()?.bounce_rate)}
|
||||
{stat("Session (s)", stats()?.session_duration)}
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 mt-3">
|
||||
<MetricTable title="Top pages" items={props.analytics?.top_pages} />
|
||||
<MetricTable title="Top referrers" items={props.analytics?.top_referrers} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.loading && !props.analytics}>
|
||||
<p class="text-text-2 text-sm">No analytics loaded.</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTable(props: { title: string; items?: { value: string; count: number; percentage?: number }[] }) {
|
||||
return (
|
||||
<div class="card card-flush">
|
||||
<div class="card-header">
|
||||
<div class="card-header-title">{props.title}</div>
|
||||
</div>
|
||||
<Show when={(props.items?.length ?? 0) > 0} fallback={<p class="text-text-2 text-sm p-3">No data in window.</p>}>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<tr>
|
||||
<td style="word-break:break-all">{item.value}</td>
|
||||
<td class="text-text-2" style="text-align:right;white-space:nowrap">
|
||||
{item.count.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveriesPanel(props: { deliveries?: WebhookDelivery[] }) {
|
||||
return (
|
||||
<div class="p-4" style="background: var(--surface-2)">
|
||||
<Show when={(props.deliveries?.length ?? 0) > 0} fallback={<p class="text-text-2 text-sm">No deliveries yet.</p>}>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Status</th>
|
||||
<th>Attempts</th>
|
||||
<th>HTTP</th>
|
||||
<th class="hidden md:table-cell">Error</th>
|
||||
<th class="hidden md:table-cell">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.deliveries}>
|
||||
{(d) => (
|
||||
<tr>
|
||||
<td>
|
||||
<code>{d.event_type}</code>
|
||||
</td>
|
||||
<td>
|
||||
<Badge variant={statusVariant(d.status)}>{d.status}</Badge>
|
||||
</td>
|
||||
<td>{d.attempts}</td>
|
||||
<td>{d.last_status_code ?? "—"}</td>
|
||||
<td class="hidden md:table-cell text-text-2" style="max-width:16rem;overflow:hidden;text-overflow:ellipsis">
|
||||
{d.last_error || "—"}
|
||||
</td>
|
||||
<td class="hidden md:table-cell text-text-2">{fmtTime(d.delivered_at ?? d.created_at)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export { StoragePage } from "./StoragePage";
|
||||
export { CollectionsPage } from "./CollectionsPage";
|
||||
export { DatabasesPage } from "./DatabasesPage";
|
||||
export { TelemetryPage } from "./TelemetryPage";
|
||||
export { IntegrationsPage } from "./IntegrationsPage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { AuditPage } from "./AuditPage";
|
||||
export { AuthPage, type AuthUser } from "./AuthPage";
|
||||
Reference in new issue
Block a user