mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-25 01:22:45 +00:00
Compare commits
1 Commits
bo/hook_ui
...
fix/agent-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cda87c105 |
@@ -91,8 +91,6 @@ class HookResponse(BaseModel):
|
||||
# Nullable to match the DB column — endpoint_url is required on creation but
|
||||
# future hook point types may not use an external endpoint (e.g. built-in handlers).
|
||||
endpoint_url: str | None
|
||||
# Partially-masked API key (e.g. "abcd••••••••wxyz"), or None if no key is set.
|
||||
api_key_masked: str | None
|
||||
fail_strategy: HookFailStrategy
|
||||
timeout_seconds: float # always resolved — None from request is replaced with spec default before DB write
|
||||
is_active: bool
|
||||
|
||||
@@ -62,9 +62,6 @@ def _hook_to_response(hook: Hook, creator_email: str | None = None) -> HookRespo
|
||||
name=hook.name,
|
||||
hook_point=hook.hook_point,
|
||||
endpoint_url=hook.endpoint_url,
|
||||
api_key_masked=(
|
||||
hook.api_key.get_value(apply_mask=True) if hook.api_key else None
|
||||
),
|
||||
fail_strategy=hook.fail_strategy,
|
||||
timeout_seconds=hook.timeout_seconds,
|
||||
is_active=hook.is_active,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 910 KiB |
@@ -12,7 +12,7 @@
|
||||
.input-normal:focus:not(:active),
|
||||
.input-normal:focus-within:not(:active) {
|
||||
border-color: var(--border-05);
|
||||
box-shadow: 0px 0px 0px 2px rgba(204, 204, 207, 1);
|
||||
box-shadow: inset 0px 0px 0px 2px var(--background-tint-04);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { HookResponse } from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
export function useHooks() {
|
||||
const { data, isLoading, error, mutate } = useSWR<HookResponse[]>(
|
||||
"/api/admin/hooks",
|
||||
errorHandlingFetcher,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
return { hooks: data, isLoading, error, mutate };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export const wrapperClasses: ClassNamesMap = {
|
||||
|
||||
export const innerClasses: ClassNamesMap = {
|
||||
primary:
|
||||
"text-text-04 placeholder:font-main-ui-body placeholder:text-text-02",
|
||||
"text-text-04 placeholder:!font-secondary-body placeholder:text-text-02",
|
||||
internal: null,
|
||||
error: null,
|
||||
disabled: "text-text-02",
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { SvgHookNodes } from "@opal/icons";
|
||||
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { createHook, updateHook } from "@/refresh-pages/admin/HooksPage/svc";
|
||||
import type {
|
||||
HookFailStrategy,
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HookFormModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** When provided, the modal is in edit mode for this hook. */
|
||||
hook?: HookResponse;
|
||||
/** When provided (create mode), the hook point is pre-selected and locked. */
|
||||
spec?: HookPointMeta;
|
||||
onSuccess: (hook: HookResponse) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
endpoint_url: string;
|
||||
api_key: string;
|
||||
fail_strategy: HookFailStrategy;
|
||||
timeout_seconds: string;
|
||||
}
|
||||
|
||||
function buildInitialState(
|
||||
hook: HookResponse | undefined,
|
||||
spec: HookPointMeta | undefined
|
||||
): FormState {
|
||||
if (hook) {
|
||||
return {
|
||||
name: hook.name,
|
||||
endpoint_url: hook.endpoint_url ?? "",
|
||||
api_key: hook.api_key_masked ?? "",
|
||||
fail_strategy: hook.fail_strategy,
|
||||
timeout_seconds: String(hook.timeout_seconds),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: "",
|
||||
endpoint_url: "",
|
||||
api_key: "",
|
||||
fail_strategy: spec?.default_fail_strategy ?? "soft",
|
||||
timeout_seconds: spec ? String(spec.default_timeout_seconds) : "5",
|
||||
};
|
||||
}
|
||||
|
||||
const SOFT_DESCRIPTION =
|
||||
"If the endpoint returns an error, Onyx logs it and continues the pipeline as normal, ignoring the hook result.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Field({ label, required, description, children }: FieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<span className="font-main-ui-action text-text-04 px-[0.125rem]">
|
||||
{label}
|
||||
{required && <span className="text-status-error-05 ml-0.5">*</span>}
|
||||
</span>
|
||||
{children}
|
||||
{description && (
|
||||
<Text secondaryBody text03>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function HookFormModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
hook,
|
||||
spec,
|
||||
onSuccess,
|
||||
}: HookFormModalProps) {
|
||||
const isEdit = !!hook;
|
||||
const [form, setForm] = useState<FormState>(() =>
|
||||
buildInitialState(hook, spec)
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
setTimeout(() => {
|
||||
setForm(buildInitialState(hook, spec));
|
||||
setIsSubmitting(false);
|
||||
}, 200);
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const timeoutNum = parseFloat(form.timeout_seconds);
|
||||
const isValid =
|
||||
form.name.trim().length > 0 &&
|
||||
form.endpoint_url.trim().length > 0 &&
|
||||
!isNaN(timeoutNum) &&
|
||||
timeoutNum > 0;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let result: HookResponse;
|
||||
if (isEdit && hook) {
|
||||
const req: Record<string, unknown> = {};
|
||||
if (form.name !== hook.name) req.name = form.name;
|
||||
if (form.endpoint_url !== (hook.endpoint_url ?? ""))
|
||||
req.endpoint_url = form.endpoint_url;
|
||||
if (form.fail_strategy !== hook.fail_strategy)
|
||||
req.fail_strategy = form.fail_strategy;
|
||||
if (timeoutNum !== hook.timeout_seconds)
|
||||
req.timeout_seconds = timeoutNum;
|
||||
const maskedPlaceholder = hook.api_key_masked ?? "";
|
||||
if (form.api_key !== maskedPlaceholder) {
|
||||
req.api_key = form.api_key || null;
|
||||
}
|
||||
if (Object.keys(req).length === 0) {
|
||||
handleOpenChange(false);
|
||||
return;
|
||||
}
|
||||
result = await updateHook(hook.id, req);
|
||||
} else {
|
||||
const hookPoint = spec!.hook_point;
|
||||
result = await createHook({
|
||||
name: form.name,
|
||||
hook_point: hookPoint,
|
||||
endpoint_url: form.endpoint_url,
|
||||
...(form.api_key ? { api_key: form.api_key } : {}),
|
||||
fail_strategy: form.fail_strategy,
|
||||
timeout_seconds: timeoutNum,
|
||||
});
|
||||
}
|
||||
toast.success(isEdit ? "Hook updated." : "Hook created.");
|
||||
onSuccess(result);
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hookPointDisplayName = isEdit
|
||||
? hook!.hook_point
|
||||
: spec?.display_name ?? spec?.hook_point ?? "";
|
||||
const hookPointDescription = isEdit ? undefined : spec?.description;
|
||||
const docsUrl = isEdit ? undefined : spec?.docs_url;
|
||||
|
||||
const failStrategyDescription =
|
||||
form.fail_strategy === "soft"
|
||||
? SOFT_DESCRIPTION
|
||||
: spec?.fail_hard_description ?? undefined;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgHookNodes}
|
||||
title="Set Up Hook Extension"
|
||||
description="Connect a external API endpoints to extend the hook point."
|
||||
onClose={() => handleOpenChange(false)}
|
||||
/>
|
||||
|
||||
<Modal.Body>
|
||||
{/* Hook point section header */}
|
||||
<div className="flex flex-row items-start justify-between gap-1">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-main-ui-action text-text-04 px-[0.125rem]">
|
||||
{hookPointDisplayName}
|
||||
</span>
|
||||
{hookPointDescription && (
|
||||
<span className="font-secondary-body text-text-03 px-[0.125rem]">
|
||||
{hookPointDescription}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end shrink-0 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<SvgHookNodes
|
||||
style={{ width: "1rem", height: "1rem" }}
|
||||
className="text-text-03 shrink-0 p-0.5"
|
||||
/>
|
||||
<span className="font-secondary-body text-text-03">
|
||||
Hook Point
|
||||
</span>
|
||||
</div>
|
||||
{docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-secondary-body text-text-03 underline"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Display Name" required>
|
||||
<InputTypeIn
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="Name your extension at this hook point"
|
||||
variant={isSubmitting ? "disabled" : undefined}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Fail Strategy" description={failStrategyDescription}>
|
||||
<InputSelect
|
||||
value={form.fail_strategy}
|
||||
onValueChange={(v) => set("fail_strategy", v as HookFailStrategy)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select strategy" />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="soft">
|
||||
Log Error and Continue (Default)
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="hard">
|
||||
Block Pipeline on Failure
|
||||
</InputSelect.Item>
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Timeout (seconds)"
|
||||
required
|
||||
description="Maximum time Onyx will wait for the endpoint to respond before applying the fail strategy."
|
||||
>
|
||||
<InputTypeIn
|
||||
type="number"
|
||||
value={form.timeout_seconds}
|
||||
onChange={(e) => set("timeout_seconds", e.target.value)}
|
||||
placeholder="5"
|
||||
variant={isSubmitting ? "disabled" : undefined}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="External API Endpoint URL"
|
||||
required
|
||||
description="Only connect to servers you trust. You are responsible for actions taken and data shared with this connection."
|
||||
>
|
||||
<InputTypeIn
|
||||
value={form.endpoint_url}
|
||||
onChange={(e) => set("endpoint_url", e.target.value)}
|
||||
placeholder="https://your-api-endpoint.com"
|
||||
variant={isSubmitting ? "disabled" : undefined}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="API Key"
|
||||
description="Onyx will use this key to authenticate with your API endpoint."
|
||||
>
|
||||
<PasswordInputTypeIn
|
||||
value={form.api_key}
|
||||
onChange={(e) => set("api_key", e.target.value)}
|
||||
placeholder={
|
||||
isEdit && hook?.api_key_masked
|
||||
? "Leave blank to keep current key"
|
||||
: undefined
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Field>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
cancel={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting || !isValid}>
|
||||
<Button onClick={handleSubmit}>
|
||||
{isEdit ? "Save" : "Connect"}
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -3,34 +3,20 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useHookSpecs } from "@/hooks/useHookSpecs";
|
||||
import { useHooks } from "@/hooks/useHooks";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import InputSearch from "@/refresh-components/inputs/InputSearch";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgBubbleText,
|
||||
SvgCheckCircle,
|
||||
SvgExternalLink,
|
||||
SvgFileBroadcast,
|
||||
SvgHookNodes,
|
||||
SvgRefreshCw,
|
||||
SvgSettings,
|
||||
SvgXCircle,
|
||||
} from "@opal/icons";
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
import HookFormModal from "@/refresh-pages/admin/HooksPage/HookFormModal";
|
||||
import type {
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
import {
|
||||
activateHook,
|
||||
deactivateHook,
|
||||
} from "@/refresh-pages/admin/HooksPage/svc";
|
||||
|
||||
const HOOK_POINT_ICONS: Record<string, IconFunctionComponent> = {
|
||||
document_ingestion: SvgFileBroadcast,
|
||||
@@ -41,152 +27,22 @@ function getHookPointIcon(hookPoint: string): IconFunctionComponent {
|
||||
return HOOK_POINT_ICONS[hookPoint] ?? SvgHookNodes;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: connected hook card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConnectedHookCardProps {
|
||||
hook: HookResponse;
|
||||
spec: HookPointMeta | undefined;
|
||||
onEdit: () => void;
|
||||
onDeleted: () => void;
|
||||
onToggled: (updated: HookResponse) => void;
|
||||
}
|
||||
|
||||
function ConnectedHookCard({
|
||||
hook,
|
||||
spec,
|
||||
onEdit,
|
||||
onDeleted: _onDeleted,
|
||||
onToggled,
|
||||
}: ConnectedHookCardProps) {
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
async function handleToggle() {
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const updated = hook.is_active
|
||||
? await deactivateHook(hook.id)
|
||||
: await activateHook(hook.id);
|
||||
onToggled(updated);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to update hook status."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const HookIcon = getHookPointIcon(hook.hook_point);
|
||||
|
||||
return (
|
||||
<Card variant="primary" padding={0.5} gap={0}>
|
||||
<div className="flex flex-row items-start w-full">
|
||||
{/* Left: manually replicate ContentMd main-content layout so the docs
|
||||
link sits as a natural third line with zero artificial gap. */}
|
||||
<div className="flex flex-row flex-1 min-w-0 items-start gap-1 p-2">
|
||||
<div className="shrink-0 p-0.5 text-text-04">
|
||||
<HookIcon style={{ width: "1rem", height: "1rem" }} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start min-w-0 flex-1">
|
||||
<span
|
||||
className="font-main-ui-action text-text-04"
|
||||
style={{ height: "1.25rem" }}
|
||||
>
|
||||
{hook.name}
|
||||
</span>
|
||||
{/* matches opal-content-md-description: font-secondary-body px-[0.125rem] */}
|
||||
<div className="font-secondary-body text-text-03">
|
||||
{`Hook Point: ${spec?.display_name ?? hook.hook_point}`}
|
||||
</div>
|
||||
{spec?.docs_url && (
|
||||
<div className="font-secondary-body text-text-03">
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<span className="underline">Documentation</span>
|
||||
<SvgExternalLink size={12} className="shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: status + actions, top-aligned */}
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<span className="font-main-ui-action text-text-03">
|
||||
{hook.is_active ? "Connected" : "Inactive"}
|
||||
</span>
|
||||
{hook.is_active ? (
|
||||
<SvgCheckCircle size={16} className="text-status-success-05" />
|
||||
) : (
|
||||
<SvgXCircle size={16} className="text-text-03" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 pl-2 pr-0.5">
|
||||
<Disabled disabled={isBusy}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgRefreshCw}
|
||||
onClick={handleToggle}
|
||||
tooltip={hook.is_active ? "Deactivate" : "Activate"}
|
||||
aria-label={
|
||||
hook.is_active ? "Deactivate hook" : "Activate hook"
|
||||
}
|
||||
/>
|
||||
</Disabled>
|
||||
<Disabled disabled={isBusy}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgSettings}
|
||||
onClick={onEdit}
|
||||
aria-label="Configure hook"
|
||||
/>
|
||||
</Disabled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function HooksContent() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [connectSpec, setConnectSpec] = useState<HookPointMeta | null>(null);
|
||||
const [editHook, setEditHook] = useState<HookResponse | null>(null);
|
||||
|
||||
const { specs, isLoading: specsLoading, error: specsError } = useHookSpecs();
|
||||
const {
|
||||
hooks,
|
||||
isLoading: hooksLoading,
|
||||
error: hooksError,
|
||||
mutate,
|
||||
} = useHooks();
|
||||
const { specs, isLoading, error } = useHookSpecs();
|
||||
|
||||
useEffect(() => {
|
||||
if (specsError) toast.error("Failed to load hook specifications.");
|
||||
}, [specsError]);
|
||||
if (error) {
|
||||
toast.error("Failed to load hook specifications.");
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hooksError) toast.error("Failed to load hooks.");
|
||||
}, [hooksError]);
|
||||
|
||||
if (specsLoading || hooksLoading) {
|
||||
if (isLoading) {
|
||||
return <SimpleLoader />;
|
||||
}
|
||||
|
||||
if (specsError) {
|
||||
if (error) {
|
||||
return (
|
||||
<Text text03 secondaryBody>
|
||||
Failed to load hook specifications. Please refresh the page.
|
||||
@@ -194,179 +50,68 @@ export default function HooksContent() {
|
||||
);
|
||||
}
|
||||
|
||||
const hooksByPoint: Record<string, HookResponse[]> = {};
|
||||
for (const hook of hooks ?? []) {
|
||||
(hooksByPoint[hook.hook_point] ??= []).push(hook);
|
||||
}
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
|
||||
// Connected hooks sorted alphabetically by hook name
|
||||
const connectedHooks = (hooks ?? [])
|
||||
.filter(
|
||||
(hook) =>
|
||||
!searchLower ||
|
||||
hook.name.toLowerCase().includes(searchLower) ||
|
||||
(hooksByPoint[hook.hook_point] &&
|
||||
specs
|
||||
?.find((s) => s.hook_point === hook.hook_point)
|
||||
?.display_name.toLowerCase()
|
||||
.includes(searchLower))
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Unconnected hook point specs sorted alphabetically
|
||||
const unconnectedSpecs = (specs ?? [])
|
||||
.filter(
|
||||
(spec) =>
|
||||
(hooksByPoint[spec.hook_point]?.length ?? 0) === 0 &&
|
||||
(!searchLower ||
|
||||
spec.display_name.toLowerCase().includes(searchLower) ||
|
||||
spec.description.toLowerCase().includes(searchLower))
|
||||
)
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
||||
|
||||
function handleHookSuccess(updated: HookResponse) {
|
||||
mutate((prev) => {
|
||||
if (!prev) return [updated];
|
||||
const idx = prev.findIndex((h) => h.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = updated;
|
||||
return next;
|
||||
}
|
||||
return [...prev, updated];
|
||||
});
|
||||
}
|
||||
|
||||
function handleHookDeleted(id: number) {
|
||||
mutate((prev) => prev?.filter((h) => h.id !== id));
|
||||
}
|
||||
|
||||
const connectSpec_ =
|
||||
connectSpec ??
|
||||
(editHook
|
||||
? specs?.find((s) => s.hook_point === editHook.hook_point)
|
||||
: undefined);
|
||||
const filtered = (specs ?? []).filter(
|
||||
(spec) =>
|
||||
spec.display_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
spec.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputSearch
|
||||
placeholder="Search hooks..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputSearch
|
||||
placeholder="Search hooks..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{connectedHooks.length === 0 && unconnectedSpecs.length === 0 ? (
|
||||
<Text text03 secondaryBody>
|
||||
{search
|
||||
? "No hooks match your search."
|
||||
: "No hook points are available."}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{connectedHooks.map((hook) => {
|
||||
const spec = specs?.find(
|
||||
(s) => s.hook_point === hook.hook_point
|
||||
);
|
||||
return (
|
||||
<ConnectedHookCard
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
spec={spec}
|
||||
onEdit={() => setEditHook(hook)}
|
||||
onDeleted={() => handleHookDeleted(hook.id)}
|
||||
onToggled={handleHookSuccess}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{unconnectedSpecs.map((spec) => {
|
||||
const UnconnectedIcon = getHookPointIcon(spec.hook_point);
|
||||
return (
|
||||
<Card
|
||||
key={spec.hook_point}
|
||||
variant="secondary"
|
||||
padding={0.5}
|
||||
gap={0}
|
||||
<div className="flex flex-col gap-2">
|
||||
{filtered.length === 0 ? (
|
||||
<Text text03 secondaryBody>
|
||||
{search
|
||||
? "No hooks match your search."
|
||||
: "No hook points are available."}
|
||||
</Text>
|
||||
) : (
|
||||
filtered.map((spec) => (
|
||||
<Card
|
||||
key={spec.hook_point}
|
||||
variant="secondary"
|
||||
padding={0.5}
|
||||
gap={0}
|
||||
>
|
||||
<ContentAction
|
||||
icon={getHookPointIcon(spec.hook_point)}
|
||||
title={spec.display_name}
|
||||
description={spec.description}
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
// TODO(Bo-Onyx): wire up Connect — open modal to create/edit hook
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{spec.docs_url && (
|
||||
<div className="pl-7 pt-1">
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 w-fit text-text-03"
|
||||
>
|
||||
<div className="flex flex-row items-start w-full">
|
||||
<div className="flex flex-row flex-1 min-w-0 items-start gap-1 p-2">
|
||||
<div className="shrink-0 p-0.5 text-text-04">
|
||||
<UnconnectedIcon
|
||||
style={{ width: "1rem", height: "1rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start min-w-0 flex-1">
|
||||
<span
|
||||
className="font-main-ui-action text-text-04"
|
||||
style={{ height: "1.25rem" }}
|
||||
>
|
||||
{spec.display_name}
|
||||
</span>
|
||||
<div className="font-secondary-body text-text-03">
|
||||
{spec.description}
|
||||
</div>
|
||||
{spec.docs_url && (
|
||||
<div className="font-secondary-body text-text-03">
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<span className="underline">Documentation</span>
|
||||
<SvgExternalLink
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 p-2 cursor-pointer"
|
||||
onClick={() => setConnectSpec(spec)}
|
||||
>
|
||||
<span className="font-main-ui-action text-text-03">
|
||||
Connect
|
||||
</span>
|
||||
<SvgArrowExchange
|
||||
size={16}
|
||||
className="text-text-03 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Text as="span" secondaryBody text03 className="underline">
|
||||
Documentation
|
||||
</Text>
|
||||
<SvgExternalLink size={16} className="text-text-02" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create modal */}
|
||||
<HookFormModal
|
||||
open={!!connectSpec}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConnectSpec(null);
|
||||
}}
|
||||
spec={connectSpec ?? undefined}
|
||||
onSuccess={handleHookSuccess}
|
||||
/>
|
||||
|
||||
{/* Edit modal */}
|
||||
<HookFormModal
|
||||
open={!!editHook}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditHook(null);
|
||||
}}
|
||||
hook={editHook ?? undefined}
|
||||
spec={connectSpec_ ?? undefined}
|
||||
onSuccess={handleHookSuccess}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ export interface HookResponse {
|
||||
name: string;
|
||||
hook_point: HookPoint;
|
||||
endpoint_url: string | null;
|
||||
/** Partially-masked API key (e.g. "abcd••••••••wxyz"), or null if no key is set. */
|
||||
api_key_masked: string | null;
|
||||
fail_strategy: HookFailStrategy;
|
||||
timeout_seconds: number;
|
||||
is_active: boolean;
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import {
|
||||
HookCreateRequest,
|
||||
HookResponse,
|
||||
HookUpdateRequest,
|
||||
} from "@/refresh-pages/admin/HooksPage/interfaces";
|
||||
|
||||
async function parseErrorDetail(
|
||||
res: Response,
|
||||
fallback: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.detail ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listHooks(): Promise<HookResponse[]> {
|
||||
const res = await fetch("/api/admin/hooks");
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to load hooks"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createHook(
|
||||
req: HookCreateRequest
|
||||
): Promise<HookResponse> {
|
||||
const res = await fetch("/api/admin/hooks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to create hook"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateHook(
|
||||
id: number,
|
||||
req: HookUpdateRequest
|
||||
): Promise<HookResponse> {
|
||||
const res = await fetch(`/api/admin/hooks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to update hook"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteHook(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/admin/hooks/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to delete hook"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateHook(id: number): Promise<HookResponse> {
|
||||
const res = await fetch(`/api/admin/hooks/${id}/activate`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to activate hook"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deactivateHook(id: number): Promise<HookResponse> {
|
||||
const res = await fetch(`/api/admin/hooks/${id}/deactivate`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to deactivate hook"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -256,6 +256,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
|
||||
title="Featured"
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
widthVariant="fit"
|
||||
/>
|
||||
)}
|
||||
<Content
|
||||
@@ -264,6 +265,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
widthVariant="fit"
|
||||
/>
|
||||
{agent.is_public && (
|
||||
<Content
|
||||
@@ -272,6 +274,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
widthVariant="fit"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SvgOrganization,
|
||||
SvgShare,
|
||||
SvgTag,
|
||||
SvgUser,
|
||||
SvgUsers,
|
||||
SvgX,
|
||||
} from "@opal/icons";
|
||||
@@ -18,7 +19,6 @@ import InputComboBox from "@/refresh-components/inputs/InputComboBox/InputComboB
|
||||
import * as InputLayouts from "@/layouts/input-layouts";
|
||||
import SwitchField from "@/refresh-components/form/SwitchField";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { SvgUser } from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import useShareableUsers from "@/hooks/useShareableUsers";
|
||||
|
||||
Reference in New Issue
Block a user