mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-02 05:22:43 +00:00
Compare commits
14 Commits
main
...
refactor/h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195eb73c30 | ||
|
|
7cde4d3610 | ||
|
|
b7815a0c6b | ||
|
|
e174d339a1 | ||
|
|
814cc8c946 | ||
|
|
c6de6735f2 | ||
|
|
3dc62c37d4 | ||
|
|
68bb3eeb4b | ||
|
|
9fb5ef67e9 | ||
|
|
c5b9504994 | ||
|
|
55f7cbbace | ||
|
|
6a359e1dc4 | ||
|
|
c268c3ab91 | ||
|
|
b9720326a5 |
@@ -1,4 +1,3 @@
|
||||
import csv
|
||||
import gc
|
||||
import io
|
||||
import json
|
||||
@@ -20,7 +19,6 @@ from zipfile import BadZipFile
|
||||
|
||||
import chardet
|
||||
import openpyxl
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
from PIL import Image
|
||||
|
||||
from onyx.configs.constants import ONYX_METADATA_FILENAME
|
||||
@@ -355,94 +353,6 @@ def pptx_to_text(file: IO[Any], file_name: str = "") -> str:
|
||||
return presentation.markdown
|
||||
|
||||
|
||||
def _worksheet_to_matrix(
|
||||
worksheet: Worksheet,
|
||||
) -> list[list[str]]:
|
||||
"""
|
||||
Converts a singular worksheet to a matrix of values
|
||||
"""
|
||||
rows: list[list[str]] = []
|
||||
for worksheet_row in worksheet.iter_rows(min_row=1, values_only=True):
|
||||
row = ["" if cell is None else str(cell) for cell in worksheet_row]
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _clean_worksheet_matrix(matrix: list[list[str]]) -> list[list[str]]:
|
||||
"""
|
||||
Cleans a worksheet matrix by removing rows if there are N consecutive empty
|
||||
rows and removing cols if there are M consecutive empty columns
|
||||
"""
|
||||
MAX_EMPTY_ROWS = 2 # Runs longer than this are capped to max_empty; shorter runs are preserved as-is
|
||||
MAX_EMPTY_COLS = 2
|
||||
|
||||
# Row cleanup
|
||||
matrix = _remove_empty_runs(matrix, max_empty=MAX_EMPTY_ROWS)
|
||||
|
||||
if not matrix:
|
||||
return matrix
|
||||
|
||||
# Column cleanup — determine which columns to keep without transposing.
|
||||
num_cols = len(matrix[0])
|
||||
keep_cols = _columns_to_keep(matrix, num_cols, max_empty=MAX_EMPTY_COLS)
|
||||
if len(keep_cols) < num_cols:
|
||||
matrix = [[row[c] for c in keep_cols] for row in matrix]
|
||||
|
||||
return matrix
|
||||
|
||||
|
||||
def _columns_to_keep(
|
||||
matrix: list[list[str]], num_cols: int, max_empty: int
|
||||
) -> list[int]:
|
||||
"""Return the indices of columns to keep after removing empty-column runs.
|
||||
|
||||
Uses the same logic as ``_remove_empty_runs`` but operates on column
|
||||
indices so no transpose is needed.
|
||||
"""
|
||||
kept: list[int] = []
|
||||
empty_buffer: list[int] = []
|
||||
|
||||
for col_idx in range(num_cols):
|
||||
col_is_empty = all(not row[col_idx] for row in matrix)
|
||||
if col_is_empty:
|
||||
empty_buffer.append(col_idx)
|
||||
else:
|
||||
kept.extend(empty_buffer[:max_empty])
|
||||
kept.append(col_idx)
|
||||
empty_buffer = []
|
||||
|
||||
return kept
|
||||
|
||||
|
||||
def _remove_empty_runs(
|
||||
rows: list[list[str]],
|
||||
max_empty: int,
|
||||
) -> list[list[str]]:
|
||||
"""Removes entire runs of empty rows when the run length exceeds max_empty.
|
||||
|
||||
Leading empty runs are capped to max_empty, just like interior runs.
|
||||
Trailing empty rows are always dropped since there is no subsequent
|
||||
non-empty row to flush them.
|
||||
"""
|
||||
result: list[list[str]] = []
|
||||
empty_buffer: list[list[str]] = []
|
||||
|
||||
for row in rows:
|
||||
# Check if empty
|
||||
if not any(row):
|
||||
if len(empty_buffer) < max_empty:
|
||||
empty_buffer.append(row)
|
||||
else:
|
||||
# Add upto max empty rows onto the result - that's what we allow
|
||||
result.extend(empty_buffer[:max_empty])
|
||||
# Add the new non-empty row
|
||||
result.append(row)
|
||||
empty_buffer = []
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def xlsx_to_text(file: IO[Any], file_name: str = "") -> str:
|
||||
# TODO: switch back to this approach in a few months when markitdown
|
||||
# fixes their handling of excel files
|
||||
@@ -481,15 +391,30 @@ def xlsx_to_text(file: IO[Any], file_name: str = "") -> str:
|
||||
f"Failed to extract text from {file_name or 'xlsx file'}. This happens due to a bug in openpyxl. {e}"
|
||||
)
|
||||
return ""
|
||||
raise
|
||||
raise e
|
||||
|
||||
text_content = []
|
||||
for sheet in workbook.worksheets:
|
||||
sheet_matrix = _clean_worksheet_matrix(_worksheet_to_matrix(sheet))
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf, lineterminator="\n")
|
||||
writer.writerows(sheet_matrix)
|
||||
text_content.append(buf.getvalue().rstrip("\n"))
|
||||
rows = []
|
||||
num_empty_consecutive_rows = 0
|
||||
for row in sheet.iter_rows(min_row=1, values_only=True):
|
||||
row_str = ",".join(str(cell or "") for cell in row)
|
||||
|
||||
# Only add the row if there are any values in the cells
|
||||
if len(row_str) >= len(row):
|
||||
rows.append(row_str)
|
||||
num_empty_consecutive_rows = 0
|
||||
else:
|
||||
num_empty_consecutive_rows += 1
|
||||
|
||||
if num_empty_consecutive_rows > 100:
|
||||
# handle massive excel sheets with mostly empty cells
|
||||
logger.warning(
|
||||
f"Found {num_empty_consecutive_rows} empty rows in {file_name}, skipping rest of file"
|
||||
)
|
||||
break
|
||||
sheet_str = "\n".join(rows)
|
||||
text_content.append(sheet_str)
|
||||
return TEXT_SECTION_SEPARATOR.join(text_content)
|
||||
|
||||
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import io
|
||||
from typing import cast
|
||||
|
||||
import openpyxl
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
from onyx.file_processing.extract_file_text import xlsx_to_text
|
||||
|
||||
|
||||
def _make_xlsx(sheets: dict[str, list[list[str]]]) -> io.BytesIO:
|
||||
"""Create an in-memory xlsx file from a dict of sheet_name -> matrix of strings."""
|
||||
wb = openpyxl.Workbook()
|
||||
if wb.active is not None:
|
||||
wb.remove(cast(Worksheet, wb.active))
|
||||
for sheet_name, rows in sheets.items():
|
||||
ws = wb.create_sheet(title=sheet_name)
|
||||
for row in rows:
|
||||
ws.append(row)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
|
||||
class TestXlsxToText:
|
||||
def test_single_sheet_basic(self) -> None:
|
||||
xlsx = _make_xlsx(
|
||||
{
|
||||
"Sheet1": [
|
||||
["Name", "Age"],
|
||||
["Alice", "30"],
|
||||
["Bob", "25"],
|
||||
]
|
||||
}
|
||||
)
|
||||
result = xlsx_to_text(xlsx)
|
||||
lines = [line for line in result.strip().split("\n") if line.strip()]
|
||||
assert len(lines) == 3
|
||||
assert "Name" in lines[0]
|
||||
assert "Age" in lines[0]
|
||||
assert "Alice" in lines[1]
|
||||
assert "30" in lines[1]
|
||||
assert "Bob" in lines[2]
|
||||
|
||||
def test_multiple_sheets_separated(self) -> None:
|
||||
xlsx = _make_xlsx(
|
||||
{
|
||||
"Sheet1": [["a", "b"]],
|
||||
"Sheet2": [["c", "d"]],
|
||||
}
|
||||
)
|
||||
result = xlsx_to_text(xlsx)
|
||||
# TEXT_SECTION_SEPARATOR is "\n\n"
|
||||
assert "\n\n" in result
|
||||
parts = result.split("\n\n")
|
||||
assert any("a" in p for p in parts)
|
||||
assert any("c" in p for p in parts)
|
||||
|
||||
def test_empty_cells(self) -> None:
|
||||
xlsx = _make_xlsx(
|
||||
{
|
||||
"Sheet1": [
|
||||
["a", "", "b"],
|
||||
["", "c", ""],
|
||||
]
|
||||
}
|
||||
)
|
||||
result = xlsx_to_text(xlsx)
|
||||
lines = [line for line in result.strip().split("\n") if line.strip()]
|
||||
assert len(lines) == 2
|
||||
|
||||
def test_commas_in_cells_are_quoted(self) -> None:
|
||||
"""Cells containing commas should be quoted in CSV output."""
|
||||
xlsx = _make_xlsx(
|
||||
{
|
||||
"Sheet1": [
|
||||
["hello, world", "normal"],
|
||||
]
|
||||
}
|
||||
)
|
||||
result = xlsx_to_text(xlsx)
|
||||
assert '"hello, world"' in result
|
||||
|
||||
def test_empty_workbook(self) -> None:
|
||||
xlsx = _make_xlsx({"Sheet1": []})
|
||||
result = xlsx_to_text(xlsx)
|
||||
assert result.strip() == ""
|
||||
|
||||
def test_long_empty_row_run_capped(self) -> None:
|
||||
"""Runs of >2 empty rows should be capped to 2."""
|
||||
xlsx = _make_xlsx(
|
||||
{
|
||||
"Sheet1": [
|
||||
["header"],
|
||||
[""],
|
||||
[""],
|
||||
[""],
|
||||
[""],
|
||||
["data"],
|
||||
]
|
||||
}
|
||||
)
|
||||
result = xlsx_to_text(xlsx)
|
||||
lines = [line for line in result.strip().split("\n") if line.strip()]
|
||||
# 4 empty rows capped to 2, so: header + 2 empty + data = 4 lines
|
||||
assert len(lines) == 4
|
||||
assert "header" in lines[0]
|
||||
assert "data" in lines[-1]
|
||||
|
||||
def test_long_empty_col_run_capped(self) -> None:
|
||||
"""Runs of >2 empty columns should be capped to 2."""
|
||||
xlsx = _make_xlsx(
|
||||
{
|
||||
"Sheet1": [
|
||||
["a", "", "", "", "b"],
|
||||
["c", "", "", "", "d"],
|
||||
]
|
||||
}
|
||||
)
|
||||
result = xlsx_to_text(xlsx)
|
||||
lines = [line for line in result.strip().split("\n") if line.strip()]
|
||||
assert len(lines) == 2
|
||||
# Each row should have 4 fields (a + 2 empty + b), not 5
|
||||
# csv format: a,,,b (3 commas = 4 fields)
|
||||
first_line = lines[0].strip()
|
||||
# Count commas to verify column reduction
|
||||
assert first_line.count(",") == 3
|
||||
|
||||
def test_short_empty_runs_kept(self) -> None:
|
||||
"""Runs of <=2 empty rows/cols should be preserved."""
|
||||
xlsx = _make_xlsx(
|
||||
{
|
||||
"Sheet1": [
|
||||
["a", "b"],
|
||||
["", ""],
|
||||
["", ""],
|
||||
["c", "d"],
|
||||
]
|
||||
}
|
||||
)
|
||||
result = xlsx_to_text(xlsx)
|
||||
lines = [line for line in result.strip().split("\n") if line.strip()]
|
||||
# All 4 rows preserved (2 empty rows <= threshold)
|
||||
assert len(lines) == 4
|
||||
|
||||
def test_bad_zip_file_returns_empty(self) -> None:
|
||||
bad_file = io.BytesIO(b"not a zip file")
|
||||
result = xlsx_to_text(bad_file, file_name="test.xlsx")
|
||||
assert result == ""
|
||||
|
||||
def test_bad_zip_tilde_file_returns_empty(self) -> None:
|
||||
bad_file = io.BytesIO(b"not a zip file")
|
||||
result = xlsx_to_text(bad_file, file_name="~$temp.xlsx")
|
||||
assert result == ""
|
||||
|
||||
def test_large_sparse_sheet(self) -> None:
|
||||
"""A sheet with data, a big empty gap, and more data — gap is capped to 2."""
|
||||
rows: list[list[str]] = [["row1_data"]]
|
||||
rows.extend([[""] for _ in range(10)])
|
||||
rows.append(["row2_data"])
|
||||
xlsx = _make_xlsx({"Sheet1": rows})
|
||||
result = xlsx_to_text(xlsx)
|
||||
lines = [line for line in result.strip().split("\n") if line.strip()]
|
||||
# 10 empty rows capped to 2: row1_data + 2 empty + row2_data = 4
|
||||
assert len(lines) == 4
|
||||
assert "row1_data" in lines[0]
|
||||
assert "row2_data" in lines[-1]
|
||||
|
||||
def test_quotes_in_cells(self) -> None:
|
||||
"""Cells containing quotes should be properly escaped."""
|
||||
xlsx = _make_xlsx(
|
||||
{
|
||||
"Sheet1": [
|
||||
['say "hello"', "normal"],
|
||||
]
|
||||
}
|
||||
)
|
||||
result = xlsx_to_text(xlsx)
|
||||
# csv.writer escapes quotes by doubling them
|
||||
assert '""hello""' in result
|
||||
|
||||
def test_each_row_is_separate_line(self) -> None:
|
||||
"""Each row should produce its own line (regression for writerow vs writerows)."""
|
||||
xlsx = _make_xlsx(
|
||||
{
|
||||
"Sheet1": [
|
||||
["r1c1", "r1c2"],
|
||||
["r2c1", "r2c2"],
|
||||
["r3c1", "r3c2"],
|
||||
]
|
||||
}
|
||||
)
|
||||
result = xlsx_to_text(xlsx)
|
||||
lines = [line for line in result.strip().split("\n") if line.strip()]
|
||||
assert len(lines) == 3
|
||||
assert "r1c1" in lines[0] and "r1c2" in lines[0]
|
||||
assert "r2c1" in lines[1] and "r2c2" in lines[1]
|
||||
assert "r3c1" in lines[2] and "r3c2" in lines[2]
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cn } from "@opal/utils";
|
||||
import { useDisabled } from "@opal/core/disabled/components";
|
||||
import { guardPortalClick } from "@opal/core/interactive/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -91,7 +92,7 @@ function InteractiveSimple({
|
||||
? href
|
||||
? (e: React.MouseEvent) => e.preventDefault()
|
||||
: undefined
|
||||
: onClick
|
||||
: guardPortalClick(onClick)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cn } from "@opal/utils";
|
||||
import { useDisabled } from "@opal/core/disabled/components";
|
||||
import { guardPortalClick } from "@opal/core/interactive/utils";
|
||||
import type { ButtonType, WithoutStyles } from "@opal/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -153,7 +154,7 @@ function InteractiveStateful({
|
||||
? href
|
||||
? (e: React.MouseEvent) => e.preventDefault()
|
||||
: undefined
|
||||
: onClick
|
||||
: guardPortalClick(onClick)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cn } from "@opal/utils";
|
||||
import { useDisabled } from "@opal/core/disabled/components";
|
||||
import { guardPortalClick } from "@opal/core/interactive/utils";
|
||||
import type { ButtonType, WithoutStyles } from "@opal/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -137,7 +138,7 @@ function InteractiveStateless({
|
||||
? href
|
||||
? (e: React.MouseEvent) => e.preventDefault()
|
||||
: undefined
|
||||
: onClick
|
||||
: guardPortalClick(onClick)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
28
web/lib/opal/src/core/interactive/utils.ts
Normal file
28
web/lib/opal/src/core/interactive/utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type React from "react";
|
||||
|
||||
/**
|
||||
* Guards an onClick handler against React synthetic event bubbling from
|
||||
* portalled children (e.g. Radix Dialog overlays).
|
||||
*
|
||||
* React bubbles synthetic events through the **fiber tree** (component
|
||||
* hierarchy), not the DOM tree. This means a click on a portalled modal
|
||||
* overlay will bubble to a parent component's onClick even though the
|
||||
* overlay is not a DOM descendant. This guard checks that the click
|
||||
* target is actually inside the handler's DOM element before firing.
|
||||
*/
|
||||
function guardPortalClick<E extends React.MouseEvent>(
|
||||
onClick: ((e: E) => void) | undefined
|
||||
): ((e: E) => void) | undefined {
|
||||
if (!onClick) return undefined;
|
||||
return (e: E) => {
|
||||
if (
|
||||
e.currentTarget instanceof Node &&
|
||||
e.target instanceof Node &&
|
||||
e.currentTarget.contains(e.target)
|
||||
) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { guardPortalClick };
|
||||
@@ -92,7 +92,7 @@ export { default as SvgHashSmall } from "@opal/icons/hash-small";
|
||||
export { default as SvgHash } from "@opal/icons/hash";
|
||||
export { default as SvgHeadsetMic } from "@opal/icons/headset-mic";
|
||||
export { default as SvgHistory } from "@opal/icons/history";
|
||||
export { default as SvgHookNodes } from "@opal/icons/hook-nodes";
|
||||
export { default as SvgShareWebhook } from "@opal/icons/share-webhook";
|
||||
export { default as SvgHourglass } from "@opal/icons/hourglass";
|
||||
export { default as SvgImage } from "@opal/icons/image";
|
||||
export { default as SvgImageSmall } from "@opal/icons/image-small";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgHookNodes = ({ size, ...props }: IconProps) => (
|
||||
const SvgShareWebhook = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
@@ -18,4 +18,4 @@ const SvgHookNodes = ({ size, ...props }: IconProps) => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgHookNodes;
|
||||
export default SvgShareWebhook;
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SvgGlobe,
|
||||
SvgHardDrive,
|
||||
SvgHeadsetMic,
|
||||
SvgHookNodes,
|
||||
SvgShareWebhook,
|
||||
SvgKey,
|
||||
SvgLock,
|
||||
SvgPaintBrush,
|
||||
@@ -64,7 +64,7 @@ const BUSINESS_FEATURES: PlanFeature[] = [
|
||||
{ icon: SvgKey, text: "Service Account API Keys" },
|
||||
{ icon: SvgHardDrive, text: "Self-hosting (Optional)" },
|
||||
{ icon: SvgPaintBrush, text: "Custom Theming" },
|
||||
{ icon: SvgHookNodes, text: "Hook Extensions" },
|
||||
{ icon: SvgShareWebhook, text: "Hook Extensions" },
|
||||
];
|
||||
|
||||
const ENTERPRISE_FEATURES: PlanFeature[] = [
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { Button } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { Content } from "@opal/layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import {
|
||||
SvgExternalLink,
|
||||
SvgPlug,
|
||||
SvgRefreshCw,
|
||||
SvgSettings,
|
||||
SvgTrash,
|
||||
SvgUnplug,
|
||||
} from "@opal/icons";
|
||||
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
|
||||
import type {
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
|
||||
import {
|
||||
activateHook,
|
||||
deactivateHook,
|
||||
deleteHook,
|
||||
validateHook,
|
||||
} from "@/ee/refresh-pages/admin/HooksPage/svc";
|
||||
import { getHookPointIcon } from "@/ee/refresh-pages/admin/HooksPage/hookPointIcons";
|
||||
import HookStatusPopover from "@/ee/refresh-pages/admin/HooksPage/HookStatusPopover";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: disconnect confirmation modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DisconnectConfirmModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hook: HookResponse;
|
||||
onDisconnect: () => void;
|
||||
onDisconnectAndDelete: () => void;
|
||||
}
|
||||
|
||||
function DisconnectConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
hook,
|
||||
onDisconnect,
|
||||
onDisconnectAndDelete,
|
||||
}: DisconnectConfirmModalProps) {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Header
|
||||
icon={(props) => (
|
||||
<SvgUnplug {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title={`Disconnect ${hook.name}`}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
<Modal.Body>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text mainUiBody text03>
|
||||
Onyx will stop calling this endpoint for hook{" "}
|
||||
<strong>
|
||||
<em>{hook.name}</em>
|
||||
</strong>
|
||||
. In-flight requests will continue to run. The external endpoint
|
||||
may still retain data previously sent to it. You can reconnect
|
||||
this hook later if needed.
|
||||
</Text>
|
||||
<Text mainUiBody text03>
|
||||
You can also delete this hook. Deletion cannot be undone.
|
||||
</Text>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
cancel={
|
||||
<Button
|
||||
prominence="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
submit={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
prominence="secondary"
|
||||
onClick={onDisconnectAndDelete}
|
||||
>
|
||||
Disconnect & Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
prominence="primary"
|
||||
onClick={onDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: delete confirmation modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hook: HookResponse;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function DeleteConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
hook,
|
||||
onDelete,
|
||||
}: DeleteConfirmModalProps) {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Header
|
||||
icon={(props) => (
|
||||
<SvgTrash {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title={`Delete ${hook.name}`}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
<Modal.Body>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text mainUiBody text03>
|
||||
Hook{" "}
|
||||
<strong>
|
||||
<em>{hook.name}</em>
|
||||
</strong>{" "}
|
||||
will be permanently removed from this hook point. The external
|
||||
endpoint may still retain data previously sent to it.
|
||||
</Text>
|
||||
<Text mainUiBody text03>
|
||||
Deletion cannot be undone.
|
||||
</Text>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
cancel={
|
||||
<Button
|
||||
prominence="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
submit={
|
||||
<Button variant="danger" prominence="primary" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConnectedHookCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConnectedHookCardProps {
|
||||
hook: HookResponse;
|
||||
spec: HookPointMeta | undefined;
|
||||
onEdit: () => void;
|
||||
onDeleted: () => void;
|
||||
onToggled: (updated: HookResponse) => void;
|
||||
}
|
||||
|
||||
export default function ConnectedHookCard({
|
||||
hook,
|
||||
spec,
|
||||
onEdit,
|
||||
onDeleted,
|
||||
onToggled,
|
||||
}: ConnectedHookCardProps) {
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [disconnectConfirmOpen, setDisconnectConfirmOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleteConfirmOpen(false);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await deleteHook(hook.id);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to delete hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate() {
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const updated = await activateHook(hook.id);
|
||||
onToggled(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to reconnect hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to reconnect hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeactivate() {
|
||||
setDisconnectConfirmOpen(false);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const updated = await deactivateHook(hook.id);
|
||||
onToggled(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to deactivate hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to deactivate hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnectAndDelete() {
|
||||
setDisconnectConfirmOpen(false);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const deactivated = await deactivateHook(hook.id);
|
||||
onToggled(deactivated);
|
||||
await deleteHook(hook.id);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
console.error("Failed to disconnect hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to disconnect hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleValidate() {
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const result = await validateHook(hook.id);
|
||||
if (result.status === "passed") {
|
||||
toast.success("Hook validated successfully.");
|
||||
} else {
|
||||
toast.error(
|
||||
result.error_message ?? `Validation failed: ${result.status}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to validate hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to validate hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const HookIcon = getHookPointIcon(hook.hook_point);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DisconnectConfirmModal
|
||||
open={disconnectConfirmOpen}
|
||||
onOpenChange={setDisconnectConfirmOpen}
|
||||
hook={hook}
|
||||
onDisconnect={handleDeactivate}
|
||||
onDisconnectAndDelete={handleDisconnectAndDelete}
|
||||
/>
|
||||
<DeleteConfirmModal
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
hook={hook}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<Card
|
||||
variant="primary"
|
||||
padding={0.5}
|
||||
gap={0}
|
||||
className={cn(
|
||||
"hover:border-border-02",
|
||||
!hook.is_active && "!bg-background-neutral-02"
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex flex-row">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={HookIcon}
|
||||
title={!hook.is_active ? markdown(`~~${hook.name}~~`) : hook.name}
|
||||
description={`Hook Point: ${
|
||||
spec?.display_name ?? hook.hook_point
|
||||
}`}
|
||||
/>
|
||||
|
||||
{spec?.docs_url && (
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="pl-6 flex items-center gap-1 w-fit"
|
||||
>
|
||||
<span className="underline font-secondary-body text-text-03">
|
||||
Documentation
|
||||
</span>
|
||||
<SvgExternalLink size={12} className="shrink-0" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Section
|
||||
flexDirection="column"
|
||||
alignItems="end"
|
||||
width="fit"
|
||||
height="fit"
|
||||
gap={0}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{hook.is_active ? (
|
||||
<HookStatusPopover hook={hook} spec={spec} isBusy={isBusy} />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 p-2",
|
||||
isBusy ? "opacity-50 pointer-events-none" : "cursor-pointer"
|
||||
)}
|
||||
onClick={handleActivate}
|
||||
>
|
||||
<Text mainUiAction text03>
|
||||
Reconnect
|
||||
</Text>
|
||||
<SvgPlug size={16} className="text-text-03 shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Disabled disabled={isBusy}>
|
||||
<div className="flex items-center gap-0.5 pl-1 pr-1 pb-1">
|
||||
{hook.is_active ? (
|
||||
<>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgUnplug}
|
||||
onClick={() => setDisconnectConfirmOpen(true)}
|
||||
tooltip="Disconnect Hook"
|
||||
aria-label="Deactivate hook"
|
||||
/>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgRefreshCw}
|
||||
onClick={handleValidate}
|
||||
tooltip="Test Connection"
|
||||
aria-label="Re-validate hook"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgTrash}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
tooltip="Delete"
|
||||
aria-label="Delete hook"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
icon={SvgSettings}
|
||||
onClick={onEdit}
|
||||
tooltip="Manage"
|
||||
aria-label="Configure hook"
|
||||
/>
|
||||
</div>
|
||||
</Disabled>
|
||||
</Section>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Formik, Form, useFormikContext } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import {
|
||||
SvgCheckCircle,
|
||||
SvgHookNodes,
|
||||
SvgShareWebhook,
|
||||
SvgLoader,
|
||||
SvgRevert,
|
||||
} from "@opal/icons";
|
||||
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn";
|
||||
import { FormField } from "@/refresh-components/form/FormField";
|
||||
import PasswordInputTypeInField from "@/refresh-components/form/PasswordInputTypeInField";
|
||||
import * as InputLayouts from "@/layouts/input-layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Content, ContentAction } from "@opal/layouts";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import {
|
||||
createHook,
|
||||
@@ -37,7 +39,6 @@ import type {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HookFormModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** When provided, the modal is in edit mode for this hook. */
|
||||
hook?: HookResponse;
|
||||
@@ -50,7 +51,12 @@ interface HookFormModalProps {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildInitialState(
|
||||
const MAX_TIMEOUT_SECONDS = 600;
|
||||
|
||||
const SOFT_DESCRIPTION =
|
||||
"If the endpoint returns an error, Onyx logs it and continues the pipeline as normal, ignoring the hook result.";
|
||||
|
||||
function buildInitialValues(
|
||||
hook: HookResponse | undefined,
|
||||
spec: HookPointMeta | undefined
|
||||
): HookFormState {
|
||||
@@ -72,172 +78,95 @@ function buildInitialState(
|
||||
};
|
||||
}
|
||||
|
||||
const SOFT_DESCRIPTION =
|
||||
"If the endpoint returns an error, Onyx logs it and continues the pipeline as normal, ignoring the hook result.";
|
||||
function buildValidationSchema(isEdit: boolean) {
|
||||
return Yup.object().shape({
|
||||
name: Yup.string().trim().required("Display name cannot be empty."),
|
||||
endpoint_url: Yup.string().trim().required("Endpoint URL cannot be empty."),
|
||||
api_key: isEdit
|
||||
? Yup.string()
|
||||
: Yup.string().trim().required("API key cannot be empty."),
|
||||
timeout_seconds: Yup.string()
|
||||
.required("Timeout is required.")
|
||||
.test(
|
||||
"valid-timeout",
|
||||
`Must be greater than 0 and at most ${MAX_TIMEOUT_SECONDS} seconds.`,
|
||||
(val) => {
|
||||
const num = parseFloat(val ?? "");
|
||||
return !isNaN(num) && num > 0 && num <= MAX_TIMEOUT_SECONDS;
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const MAX_TIMEOUT_SECONDS = 600;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeout field (needs access to spec for revert button)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TimeoutFieldProps {
|
||||
spec: HookPointMeta | undefined;
|
||||
}
|
||||
|
||||
function TimeoutField({ spec }: TimeoutFieldProps) {
|
||||
const { values, setFieldValue, isSubmitting } =
|
||||
useFormikContext<HookFormState>();
|
||||
|
||||
return (
|
||||
<InputLayouts.Vertical
|
||||
name="timeout_seconds"
|
||||
title="Timeout"
|
||||
suffix="(seconds)"
|
||||
subDescription={`Maximum time Onyx will wait for the endpoint to respond before applying the fail strategy. Must be greater than 0 and at most ${MAX_TIMEOUT_SECONDS} seconds.`}
|
||||
>
|
||||
<div className="[&_input]:!font-main-ui-mono [&_input::placeholder]:!font-main-ui-mono [&_input]:![appearance:textfield] [&_input::-webkit-outer-spin-button]:!appearance-none [&_input::-webkit-inner-spin-button]:!appearance-none w-full">
|
||||
<InputTypeInField
|
||||
name="timeout_seconds"
|
||||
type="number"
|
||||
placeholder={spec ? String(spec.default_timeout_seconds) : undefined}
|
||||
variant={isSubmitting ? "disabled" : undefined}
|
||||
showClearButton={false}
|
||||
rightSection={
|
||||
spec?.default_timeout_seconds !== undefined &&
|
||||
values.timeout_seconds !== String(spec.default_timeout_seconds) ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="xs"
|
||||
icon={SvgRevert}
|
||||
tooltip="Revert to Default"
|
||||
onClick={() =>
|
||||
setFieldValue(
|
||||
"timeout_seconds",
|
||||
String(spec.default_timeout_seconds)
|
||||
)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</InputLayouts.Vertical>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function HookFormModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
hook,
|
||||
spec,
|
||||
onSuccess,
|
||||
}: HookFormModalProps) {
|
||||
const isEdit = !!hook;
|
||||
const [form, setForm] = useState<HookFormState>(() =>
|
||||
buildInitialState(hook, spec)
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
// Tracks whether the user explicitly cleared the API key field in edit mode.
|
||||
// - false + empty field → key unchanged (omitted from PATCH)
|
||||
// - true + empty field → key cleared (api_key: null sent to backend)
|
||||
// - false + non-empty → new key provided (new value sent to backend)
|
||||
const [apiKeyCleared, setApiKeyCleared] = useState(false);
|
||||
const [touched, setTouched] = useState({
|
||||
name: false,
|
||||
endpoint_url: false,
|
||||
api_key: false,
|
||||
});
|
||||
const [apiKeyServerError, setApiKeyServerError] = useState(false);
|
||||
const [endpointServerError, setEndpointServerError] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [timeoutServerError, setTimeoutServerError] = useState(false);
|
||||
|
||||
function touch(key: keyof typeof touched) {
|
||||
setTouched((prev) => ({ ...prev, [key]: true }));
|
||||
}
|
||||
const initialValues = buildInitialValues(hook, spec);
|
||||
const validationSchema = buildValidationSchema(isEdit);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
if (isSubmitting) return;
|
||||
setTimeout(() => {
|
||||
setForm(buildInitialState(hook, spec));
|
||||
setIsConnected(false);
|
||||
setApiKeyCleared(false);
|
||||
setTouched({ name: false, endpoint_url: false, api_key: false });
|
||||
setApiKeyServerError(false);
|
||||
setEndpointServerError(null);
|
||||
setTimeoutServerError(false);
|
||||
}, 200);
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function set<K extends keyof HookFormState>(key: K, value: HookFormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const timeoutNum = parseFloat(form.timeout_seconds);
|
||||
const isTimeoutValid =
|
||||
!isNaN(timeoutNum) && timeoutNum > 0 && timeoutNum <= MAX_TIMEOUT_SECONDS;
|
||||
const isValid =
|
||||
form.name.trim().length > 0 &&
|
||||
form.endpoint_url.trim().length > 0 &&
|
||||
isTimeoutValid &&
|
||||
(isEdit || form.api_key.trim().length > 0);
|
||||
|
||||
const nameError = touched.name && !form.name.trim();
|
||||
const endpointEmptyError = touched.endpoint_url && !form.endpoint_url.trim();
|
||||
const endpointFieldError = endpointEmptyError
|
||||
? "Endpoint URL cannot be empty."
|
||||
: endpointServerError ?? undefined;
|
||||
const apiKeyEmptyError = !isEdit && touched.api_key && !form.api_key.trim();
|
||||
const apiKeyFieldError = apiKeyEmptyError
|
||||
? "API key cannot be empty."
|
||||
: apiKeyServerError
|
||||
? "Invalid API key."
|
||||
: undefined;
|
||||
|
||||
function handleTimeoutBlur() {
|
||||
if (!isTimeoutValid) {
|
||||
const fallback = hook?.timeout_seconds ?? spec?.default_timeout_seconds;
|
||||
if (fallback !== undefined) {
|
||||
set("timeout_seconds", String(fallback));
|
||||
if (timeoutServerError) setTimeoutServerError(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges =
|
||||
isEdit && hook
|
||||
? form.name !== hook.name ||
|
||||
form.endpoint_url !== (hook.endpoint_url ?? "") ||
|
||||
form.fail_strategy !== hook.fail_strategy ||
|
||||
timeoutNum !== hook.timeout_seconds ||
|
||||
form.api_key.trim().length > 0 ||
|
||||
apiKeyCleared
|
||||
: true;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let result: HookResponse;
|
||||
if (isEdit && hook) {
|
||||
const req: HookUpdateRequest = {};
|
||||
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;
|
||||
if (form.api_key.trim().length > 0) {
|
||||
req.api_key = form.api_key;
|
||||
} else if (apiKeyCleared) {
|
||||
req.api_key = null;
|
||||
}
|
||||
if (Object.keys(req).length === 0) {
|
||||
setIsSubmitting(false);
|
||||
handleOpenChange(false);
|
||||
return;
|
||||
}
|
||||
result = await updateHook(hook.id, req);
|
||||
} else {
|
||||
if (!spec) {
|
||||
toast.error("No hook point specified.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
result = await createHook({
|
||||
name: form.name,
|
||||
hook_point: spec.hook_point,
|
||||
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);
|
||||
if (!isEdit) {
|
||||
setIsConnected(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
handleOpenChange(false);
|
||||
} catch (err) {
|
||||
if (err instanceof HookAuthError) {
|
||||
setApiKeyServerError(true);
|
||||
} else if (err instanceof HookTimeoutError) {
|
||||
setTimeoutServerError(true);
|
||||
} else if (err instanceof HookConnectError) {
|
||||
setEndpointServerError(err.message || "Could not connect to endpoint.");
|
||||
} else {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Something went wrong."
|
||||
);
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
function handleClose() {
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
const hookPointDisplayName =
|
||||
@@ -245,314 +174,291 @@ export default function HookFormModal({
|
||||
const hookPointDescription = spec?.description;
|
||||
const docsUrl = spec?.docs_url;
|
||||
|
||||
const failStrategyDescription =
|
||||
form.fail_strategy === "soft"
|
||||
? SOFT_DESCRIPTION
|
||||
: spec?.fail_hard_description;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<Modal open onOpenChange={(open) => !open && handleClose()}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgHookNodes}
|
||||
title={isEdit ? "Manage Hook Extension" : "Set Up Hook Extension"}
|
||||
description={
|
||||
isEdit
|
||||
? undefined
|
||||
: "Connect an external API endpoint to extend the hook point."
|
||||
}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
/>
|
||||
|
||||
<Modal.Body>
|
||||
{/* Hook point section header */}
|
||||
<ContentAction
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
title={hookPointDisplayName}
|
||||
description={hookPointDescription}
|
||||
rightChildren={
|
||||
<Section
|
||||
flexDirection="column"
|
||||
alignItems="end"
|
||||
width="fit"
|
||||
height="fit"
|
||||
gap={0.25}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<SvgHookNodes
|
||||
style={{ width: "1rem", height: "1rem" }}
|
||||
className="text-text-03 shrink-0"
|
||||
/>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
Hook Point
|
||||
</Text>
|
||||
</div>
|
||||
{docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
Documentation
|
||||
</Text>
|
||||
</a>
|
||||
)}
|
||||
</Section>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField className="w-full" state={nameError ? "error" : "idle"}>
|
||||
<FormField.Label>Display Name</FormField.Label>
|
||||
<FormField.Control>
|
||||
<div className="[&_input::placeholder]:!font-main-ui-muted w-full">
|
||||
<InputTypeIn
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
onBlur={() => touch("name")}
|
||||
placeholder="Name your extension at this hook point"
|
||||
variant={
|
||||
isSubmitting ? "disabled" : nameError ? "error" : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormField.Control>
|
||||
<FormField.Message
|
||||
messages={{ error: "Display name cannot be empty." }}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField className="w-full">
|
||||
<FormField.Label>Fail Strategy</FormField.Label>
|
||||
<FormField.Control>
|
||||
<InputSelect
|
||||
value={form.fail_strategy}
|
||||
onValueChange={(v) =>
|
||||
set("fail_strategy", v as HookFailStrategy)
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
validateOnMount
|
||||
onSubmit={async (values, helpers) => {
|
||||
try {
|
||||
let result: HookResponse;
|
||||
if (isEdit && hook) {
|
||||
const req: HookUpdateRequest = {};
|
||||
if (values.name !== hook.name) req.name = values.name;
|
||||
if (values.endpoint_url !== (hook.endpoint_url ?? ""))
|
||||
req.endpoint_url = values.endpoint_url;
|
||||
if (values.fail_strategy !== hook.fail_strategy)
|
||||
req.fail_strategy = values.fail_strategy;
|
||||
const timeoutNum = parseFloat(values.timeout_seconds);
|
||||
if (timeoutNum !== hook.timeout_seconds)
|
||||
req.timeout_seconds = timeoutNum;
|
||||
if (values.api_key.trim().length > 0) {
|
||||
req.api_key = values.api_key;
|
||||
} else if (apiKeyCleared) {
|
||||
req.api_key = null;
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select strategy" />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="soft">
|
||||
Log Error and Continue
|
||||
{spec?.default_fail_strategy === "soft" && (
|
||||
<>
|
||||
{" "}
|
||||
<Text color="text-03">(Default)</Text>
|
||||
</>
|
||||
)}
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="hard">
|
||||
Block Pipeline on Failure
|
||||
{spec?.default_fail_strategy === "hard" && (
|
||||
<>
|
||||
{" "}
|
||||
<Text color="text-03">(Default)</Text>
|
||||
</>
|
||||
)}
|
||||
</InputSelect.Item>
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</FormField.Control>
|
||||
<FormField.Description>
|
||||
{failStrategyDescription}
|
||||
</FormField.Description>
|
||||
</FormField>
|
||||
if (Object.keys(req).length === 0) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
result = await updateHook(hook.id, req);
|
||||
} else {
|
||||
if (!spec) {
|
||||
toast.error("No hook point specified.");
|
||||
return;
|
||||
}
|
||||
result = await createHook({
|
||||
name: values.name,
|
||||
hook_point: spec.hook_point,
|
||||
endpoint_url: values.endpoint_url,
|
||||
...(values.api_key ? { api_key: values.api_key } : {}),
|
||||
fail_strategy: values.fail_strategy,
|
||||
timeout_seconds: parseFloat(values.timeout_seconds),
|
||||
});
|
||||
}
|
||||
toast.success(isEdit ? "Hook updated." : "Hook created.");
|
||||
onSuccess(result);
|
||||
if (!isEdit) {
|
||||
setIsConnected(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
if (err instanceof HookAuthError) {
|
||||
helpers.setFieldError("api_key", "Invalid API key.");
|
||||
} else if (err instanceof HookTimeoutError) {
|
||||
helpers.setFieldError(
|
||||
"timeout_seconds",
|
||||
"Connection timed out. Try increasing the timeout."
|
||||
);
|
||||
} else if (err instanceof HookConnectError) {
|
||||
helpers.setFieldError(
|
||||
"endpoint_url",
|
||||
err.message || "Could not connect to endpoint."
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Something went wrong."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
helpers.setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ values, setFieldValue, isSubmitting, isValid, dirty }) => {
|
||||
const failStrategyDescription =
|
||||
values.fail_strategy === "soft"
|
||||
? SOFT_DESCRIPTION
|
||||
: spec?.fail_hard_description;
|
||||
|
||||
<FormField
|
||||
className="w-full"
|
||||
state={timeoutServerError ? "error" : "idle"}
|
||||
>
|
||||
<FormField.Label>
|
||||
Timeout{" "}
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
(seconds)
|
||||
</Text>
|
||||
</FormField.Label>
|
||||
<FormField.Control>
|
||||
<div className="[&_input]:!font-main-ui-mono [&_input::placeholder]:!font-main-ui-mono [&_input]:![appearance:textfield] [&_input::-webkit-outer-spin-button]:!appearance-none [&_input::-webkit-inner-spin-button]:!appearance-none w-full">
|
||||
<InputTypeIn
|
||||
type="number"
|
||||
value={form.timeout_seconds}
|
||||
onChange={(e) => {
|
||||
set("timeout_seconds", e.target.value);
|
||||
if (timeoutServerError) setTimeoutServerError(false);
|
||||
}}
|
||||
onBlur={handleTimeoutBlur}
|
||||
placeholder={
|
||||
spec ? String(spec.default_timeout_seconds) : undefined
|
||||
return (
|
||||
<Form className="w-full overflow-visible">
|
||||
<Modal.Header
|
||||
icon={SvgShareWebhook}
|
||||
title={
|
||||
isEdit ? "Manage Hook Extension" : "Set Up Hook Extension"
|
||||
}
|
||||
variant={
|
||||
isSubmitting
|
||||
? "disabled"
|
||||
: timeoutServerError
|
||||
? "error"
|
||||
: undefined
|
||||
description={
|
||||
isEdit
|
||||
? undefined
|
||||
: "Connect an external API endpoint to extend the hook point."
|
||||
}
|
||||
showClearButton={false}
|
||||
rightSection={
|
||||
spec?.default_timeout_seconds !== undefined &&
|
||||
form.timeout_seconds !==
|
||||
String(spec.default_timeout_seconds) ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="xs"
|
||||
icon={SvgRevert}
|
||||
tooltip="Revert to Default"
|
||||
onClick={() =>
|
||||
set(
|
||||
"timeout_seconds",
|
||||
String(spec.default_timeout_seconds)
|
||||
)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
|
||||
<Modal.Body>
|
||||
{/* Hook point section header */}
|
||||
<ContentAction
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
title={hookPointDisplayName}
|
||||
description={hookPointDescription}
|
||||
rightChildren={
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Content
|
||||
sizePreset="secondary"
|
||||
variant="body"
|
||||
icon={SvgShareWebhook}
|
||||
title="Hook Point"
|
||||
prominence="muted"
|
||||
widthVariant="fit"
|
||||
/>
|
||||
{docsUrl && (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline leading-none"
|
||||
>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
Documentation
|
||||
</Text>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputLayouts.Vertical name="name" title="Display Name">
|
||||
<div className="[&_input::placeholder]:!font-main-ui-muted w-full">
|
||||
<InputTypeInField
|
||||
name="name"
|
||||
placeholder="Name your extension at this hook point"
|
||||
variant={isSubmitting ? "disabled" : undefined}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormField.Control>
|
||||
{!timeoutServerError && (
|
||||
<FormField.Description>
|
||||
Maximum time Onyx will wait for the endpoint to respond before
|
||||
applying the fail strategy. Must be greater than 0 and at most{" "}
|
||||
{MAX_TIMEOUT_SECONDS} seconds.
|
||||
</FormField.Description>
|
||||
)}
|
||||
<FormField.Message
|
||||
messages={{
|
||||
error: "Connection timed out. Try increasing the timeout.",
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</InputLayouts.Vertical>
|
||||
|
||||
<FormField
|
||||
className="w-full"
|
||||
state={endpointFieldError ? "error" : "idle"}
|
||||
>
|
||||
<FormField.Label>External API Endpoint URL</FormField.Label>
|
||||
<FormField.Control>
|
||||
<div className="[&_input::placeholder]:!font-main-ui-muted w-full">
|
||||
<InputTypeIn
|
||||
value={form.endpoint_url}
|
||||
onChange={(e) => {
|
||||
set("endpoint_url", e.target.value);
|
||||
if (endpointServerError) setEndpointServerError(null);
|
||||
}}
|
||||
onBlur={() => touch("endpoint_url")}
|
||||
placeholder="https://your-api-endpoint.com"
|
||||
variant={
|
||||
isSubmitting
|
||||
? "disabled"
|
||||
: endpointFieldError
|
||||
? "error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormField.Control>
|
||||
{!endpointFieldError && (
|
||||
<FormField.Description>
|
||||
Only connect to servers you trust. You are responsible for
|
||||
actions taken and data shared with this connection.
|
||||
</FormField.Description>
|
||||
)}
|
||||
<FormField.Message messages={{ error: endpointFieldError }} />
|
||||
</FormField>
|
||||
<InputLayouts.Vertical
|
||||
name="fail_strategy"
|
||||
title="Fail Strategy"
|
||||
nonInteractive
|
||||
subDescription={failStrategyDescription}
|
||||
>
|
||||
<InputSelect
|
||||
value={values.fail_strategy}
|
||||
onValueChange={(v) =>
|
||||
setFieldValue("fail_strategy", v as HookFailStrategy)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<InputSelect.Trigger placeholder="Select strategy" />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item value="soft">
|
||||
Log Error and Continue
|
||||
{spec?.default_fail_strategy === "soft" && (
|
||||
<>
|
||||
{" "}
|
||||
<Text color="text-03">(Default)</Text>
|
||||
</>
|
||||
)}
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item value="hard">
|
||||
Block Pipeline on Failure
|
||||
{spec?.default_fail_strategy === "hard" && (
|
||||
<>
|
||||
{" "}
|
||||
<Text color="text-03">(Default)</Text>
|
||||
</>
|
||||
)}
|
||||
</InputSelect.Item>
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</InputLayouts.Vertical>
|
||||
|
||||
<FormField
|
||||
className="w-full"
|
||||
state={apiKeyFieldError ? "error" : "idle"}
|
||||
>
|
||||
<FormField.Label>API Key</FormField.Label>
|
||||
<FormField.Control>
|
||||
<PasswordInputTypeIn
|
||||
value={form.api_key}
|
||||
onChange={(e) => {
|
||||
set("api_key", e.target.value);
|
||||
if (apiKeyServerError) setApiKeyServerError(false);
|
||||
if (isEdit) {
|
||||
setApiKeyCleared(
|
||||
e.target.value === "" && !!hook?.api_key_masked
|
||||
);
|
||||
}
|
||||
}}
|
||||
onBlur={() => touch("api_key")}
|
||||
placeholder={
|
||||
isEdit
|
||||
? hook?.api_key_masked ?? "Leave blank to keep current key"
|
||||
: undefined
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
error={!!apiKeyFieldError}
|
||||
/>
|
||||
</FormField.Control>
|
||||
{!apiKeyFieldError && (
|
||||
<FormField.Description>
|
||||
Onyx will use this key to authenticate with your API endpoint.
|
||||
</FormField.Description>
|
||||
)}
|
||||
<FormField.Message messages={{ error: apiKeyFieldError }} />
|
||||
</FormField>
|
||||
<TimeoutField spec={spec} />
|
||||
|
||||
{!isEdit && (isSubmitting || isConnected) && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="start"
|
||||
height="fit"
|
||||
gap={1}
|
||||
className="px-0.5"
|
||||
>
|
||||
<div className="p-0.5 shrink-0">
|
||||
{isConnected ? (
|
||||
<SvgCheckCircle
|
||||
size={16}
|
||||
className="text-status-success-05"
|
||||
<InputLayouts.Vertical
|
||||
name="endpoint_url"
|
||||
title="External API Endpoint URL"
|
||||
subDescription="Only connect to servers you trust. You are responsible for actions taken and data shared with this connection."
|
||||
>
|
||||
<div className="[&_input::placeholder]:!font-main-ui-muted w-full">
|
||||
<InputTypeInField
|
||||
name="endpoint_url"
|
||||
placeholder="https://your-api-endpoint.com"
|
||||
variant={isSubmitting ? "disabled" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</InputLayouts.Vertical>
|
||||
|
||||
<InputLayouts.Vertical
|
||||
name="api_key"
|
||||
title="API Key"
|
||||
subDescription="Onyx will use this key to authenticate with your API endpoint."
|
||||
>
|
||||
<PasswordInputTypeInField
|
||||
name="api_key"
|
||||
placeholder={
|
||||
isEdit
|
||||
? hook?.api_key_masked ??
|
||||
"Leave blank to keep current key"
|
||||
: undefined
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
onChange={(e) => {
|
||||
if (isEdit && hook?.api_key_masked) {
|
||||
setApiKeyCleared(e.target.value === "");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</InputLayouts.Vertical>
|
||||
|
||||
{!isEdit && (isSubmitting || isConnected) && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
justifyContent="start"
|
||||
height="fit"
|
||||
gap={1}
|
||||
className="px-0.5"
|
||||
>
|
||||
<div className="p-0.5 shrink-0">
|
||||
{isConnected ? (
|
||||
<SvgCheckCircle
|
||||
size={16}
|
||||
className="text-status-success-05"
|
||||
/>
|
||||
) : (
|
||||
<SvgLoader
|
||||
size={16}
|
||||
className="animate-spin text-text-03"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{isConnected
|
||||
? "Connection valid."
|
||||
: "Verifying connection…"}
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
cancel={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button prominence="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
submit={
|
||||
<Disabled
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!isValid ||
|
||||
(!dirty && !apiKeyCleared && isEdit)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={
|
||||
isSubmitting && !isEdit
|
||||
? () => (
|
||||
<SvgLoader
|
||||
size={16}
|
||||
className="animate-spin"
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isEdit ? "Save Changes" : "Connect"}
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SvgLoader size={16} className="animate-spin text-text-03" />
|
||||
)}
|
||||
</div>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{isConnected ? "Connection valid." : "Verifying connection…"}
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
cancel={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting || !isValid || !hasChanges}>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
icon={
|
||||
isSubmitting && !isEdit
|
||||
? () => <SvgLoader size={16} className="animate-spin" />
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isEdit ? "Save Changes" : "Connect"}
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -14,15 +14,16 @@ import type {
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
|
||||
import { useModalClose } from "@/refresh-components/contexts/ModalContext";
|
||||
|
||||
interface HookLogsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hook: HookResponse;
|
||||
spec: HookPointMeta | undefined;
|
||||
}
|
||||
|
||||
// Section header: "Past Hour ————" or "Older ————"
|
||||
//
|
||||
// TODO(@raunakab): replace this with a proper, opalified `Separator` component (when it lands).
|
||||
function SectionHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<Section
|
||||
@@ -69,12 +70,9 @@ function LogRow({ log }: { log: HookExecutionRecord }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function HookLogsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
hook,
|
||||
spec,
|
||||
}: HookLogsModalProps) {
|
||||
export default function HookLogsModal({ hook, spec }: HookLogsModalProps) {
|
||||
const onClose = useModalClose();
|
||||
|
||||
const { recentErrors, olderErrors, isLoading, error } = useHookExecutionLogs(
|
||||
hook.id,
|
||||
10
|
||||
@@ -99,7 +97,7 @@ export default function HookLogsModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Header
|
||||
icon={(props) => <SvgTextLines {...props} />}
|
||||
@@ -107,7 +105,7 @@ export default function HookLogsModal({
|
||||
description={`Hook: ${hook.name} • Hook Point: ${
|
||||
spec?.display_name ?? hook.hook_point
|
||||
}`}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<Modal.Body>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
|
||||
import { noProp } from "@/lib/utils";
|
||||
import { formatTimeOnly } from "@/lib/dateUtils";
|
||||
import { Text } from "@opal/components";
|
||||
import { Button, Text } from "@opal/components";
|
||||
import { Content } from "@opal/layouts";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
@@ -22,6 +24,7 @@ import type {
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
interface HookStatusPopoverProps {
|
||||
hook: HookResponse;
|
||||
@@ -34,7 +37,7 @@ export default function HookStatusPopover({
|
||||
spec,
|
||||
isBusy,
|
||||
}: HookStatusPopoverProps) {
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const logsModal = useCreateModal();
|
||||
const [open, setOpen] = useState(false);
|
||||
// true = opened by click (stays until dismissed); false = opened by hover (closes after 1s)
|
||||
const [clickOpened, setClickOpened] = useState(false);
|
||||
@@ -113,39 +116,34 @@ export default function HookStatusPopover({
|
||||
|
||||
return (
|
||||
<>
|
||||
<HookLogsModal
|
||||
open={logsOpen}
|
||||
onOpenChange={setLogsOpen}
|
||||
hook={hook}
|
||||
spec={spec}
|
||||
/>
|
||||
<logsModal.Provider>
|
||||
<HookLogsModal hook={hook} spec={spec} />
|
||||
</logsModal.Provider>
|
||||
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Anchor asChild>
|
||||
<div
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
rightIcon={({ className, ...props }) =>
|
||||
hasRecentErrors ? (
|
||||
<SvgAlertTriangle
|
||||
{...props}
|
||||
className={cn("text-status-warning-05", className)}
|
||||
/>
|
||||
) : (
|
||||
<SvgCheckCircle
|
||||
{...props}
|
||||
className={cn("text-status-success-05", className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onMouseEnter={handleTriggerMouseEnter}
|
||||
onMouseLeave={handleTriggerMouseLeave}
|
||||
onClick={handleTriggerClick}
|
||||
className={cn(
|
||||
"flex items-center gap-1 cursor-pointer rounded-xl p-2 transition-colors hover:bg-background-neutral-02",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
onClick={noProp(handleTriggerClick)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
Connected
|
||||
</Text>
|
||||
{hasRecentErrors ? (
|
||||
<SvgAlertTriangle
|
||||
size={16}
|
||||
className="text-status-warning-05 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<SvgCheckCircle
|
||||
size={16}
|
||||
className="text-status-success-05 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
Connected
|
||||
</Button>
|
||||
</Popover.Anchor>
|
||||
|
||||
<Popover.Content
|
||||
@@ -160,62 +158,36 @@ export default function HookStatusPopover({
|
||||
alignItems="start"
|
||||
height="fit"
|
||||
width={hasRecentErrors ? 20 : 12.5}
|
||||
padding={0.125}
|
||||
gap={0.25}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Section justifyContent="center" height="fit" className="p-3">
|
||||
<Section justifyContent="center">
|
||||
<SimpleLoader />
|
||||
</Section>
|
||||
) : error ? (
|
||||
<Section justifyContent="center" height="fit" className="p-3">
|
||||
<Text font="secondary-body" color="text-03">
|
||||
Failed to load logs.
|
||||
</Text>
|
||||
</Section>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
Failed to load logs.
|
||||
</Text>
|
||||
) : hasRecentErrors ? (
|
||||
// Errors state
|
||||
<>
|
||||
{/* Header: "N Errors" (≤3) or "Most Recent Errors" (>3) */}
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
gap={0.25}
|
||||
padding={0.375}
|
||||
height="fit"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<Section
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
width={1.25}
|
||||
height={1.25}
|
||||
className="shrink-0"
|
||||
>
|
||||
<SvgXOctagon size={16} className="text-status-error-05" />
|
||||
</Section>
|
||||
<Section
|
||||
flexDirection="column"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
width="fit"
|
||||
height="fit"
|
||||
gap={0}
|
||||
className="px-0.5"
|
||||
>
|
||||
<Text font="main-ui-action" color="text-04">
|
||||
{recentErrors.length <= 3
|
||||
<div className="p-1">
|
||||
<Content
|
||||
sizePreset="secondary"
|
||||
variant="section"
|
||||
icon={SvgXOctagon}
|
||||
title={
|
||||
recentErrors.length <= 3
|
||||
? `${recentErrors.length} ${
|
||||
recentErrors.length === 1 ? "Error" : "Errors"
|
||||
}`
|
||||
: "Most Recent Errors"}
|
||||
</Text>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
in the past hour
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
: "Most Recent Errors"
|
||||
}
|
||||
description="in the past hour"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator noPadding className="py-1" />
|
||||
<Separator noPadding className="px-2" />
|
||||
|
||||
{/* Log rows — at most 3, timestamp first then error message */}
|
||||
<Section
|
||||
@@ -266,10 +238,10 @@ export default function HookStatusPopover({
|
||||
<LineItem
|
||||
muted
|
||||
icon={SvgMaximize2}
|
||||
onClick={() => {
|
||||
onClick={noProp(() => {
|
||||
handleOpenChange(false);
|
||||
setLogsOpen(true);
|
||||
}}
|
||||
logsModal.toggle(true);
|
||||
})}
|
||||
>
|
||||
View More Lines
|
||||
</LineItem>
|
||||
@@ -277,56 +249,26 @@ export default function HookStatusPopover({
|
||||
) : (
|
||||
// No errors state
|
||||
<>
|
||||
{/* No Error / in the past hour */}
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
gap={0.25}
|
||||
padding={0.375}
|
||||
height="fit"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<Section
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
width={1.25}
|
||||
height={1.25}
|
||||
className="shrink-0"
|
||||
>
|
||||
<SvgCheckCircle
|
||||
size={16}
|
||||
className="text-status-success-05"
|
||||
/>
|
||||
</Section>
|
||||
<Section
|
||||
flexDirection="column"
|
||||
justifyContent="start"
|
||||
alignItems="start"
|
||||
width="fit"
|
||||
height="fit"
|
||||
gap={0}
|
||||
className="px-0.5"
|
||||
>
|
||||
<Text font="main-ui-action" color="text-04">
|
||||
No Error
|
||||
</Text>
|
||||
<Text font="secondary-body" color="text-03">
|
||||
in the past hour
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
<div className="p-1">
|
||||
<Content
|
||||
sizePreset="secondary"
|
||||
variant="section"
|
||||
icon={SvgCheckCircle}
|
||||
title="No Error"
|
||||
description="in the past hour"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator noPadding className="py-1" />
|
||||
<Separator noPadding className="px-2" />
|
||||
|
||||
{/* View Older Errors */}
|
||||
<LineItem
|
||||
muted
|
||||
icon={SvgMaximize2}
|
||||
onClick={() => {
|
||||
onClick={noProp(() => {
|
||||
handleOpenChange(false);
|
||||
setLogsOpen(true);
|
||||
}}
|
||||
logsModal.toggle(true);
|
||||
})}
|
||||
>
|
||||
View Older Errors
|
||||
</LineItem>
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useHookSpecs } from "@/ee/hooks/useHookSpecs";
|
||||
import { useHooks } from "@/ee/hooks/useHooks";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { Button } from "@opal/components";
|
||||
import { Content } from "@opal/layouts";
|
||||
import InputSearch from "@/refresh-components/inputs/InputSearch";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgArrowExchange, SvgExternalLink } from "@opal/icons";
|
||||
import HookFormModal from "@/ee/refresh-pages/admin/HooksPage/HookFormModal";
|
||||
import ConnectedHookCard from "@/ee/refresh-pages/admin/HooksPage/ConnectedHookCard";
|
||||
import { getHookPointIcon } from "@/ee/refresh-pages/admin/HooksPage/hookPointIcons";
|
||||
import type {
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
|
||||
import { markdown } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
|
||||
if (specsLoading || hooksLoading) {
|
||||
return <SimpleLoader />;
|
||||
}
|
||||
|
||||
if (specsError || hooksError) {
|
||||
return (
|
||||
<Text text03 secondaryBody>
|
||||
Failed to load{specsError ? " hook specifications" : " hooks"}. Please
|
||||
refresh the page.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
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) ||
|
||||
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);
|
||||
|
||||
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-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}
|
||||
className="hover:border-border-02"
|
||||
>
|
||||
<div className="w-full flex flex-row">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={UnconnectedIcon}
|
||||
title={spec.display_name}
|
||||
description={spec.description}
|
||||
/>
|
||||
|
||||
{spec.docs_url && (
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="pl-6 flex items-center gap-1"
|
||||
>
|
||||
<span className="underline font-secondary-body text-text-03">
|
||||
Documentation
|
||||
</span>
|
||||
<SvgExternalLink size={12} className="shrink-0" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
rightIcon={SvgArrowExchange}
|
||||
onClick={() => setConnectSpec(spec)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create modal */}
|
||||
<HookFormModal
|
||||
key={connectSpec?.hook_point ?? "create"}
|
||||
open={!!connectSpec}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConnectSpec(null);
|
||||
}}
|
||||
spec={connectSpec ?? undefined}
|
||||
onSuccess={handleHookSuccess}
|
||||
/>
|
||||
|
||||
{/* Edit modal */}
|
||||
<HookFormModal
|
||||
key={editHook?.id ?? "edit"}
|
||||
open={!!editHook}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditHook(null);
|
||||
}}
|
||||
hook={editHook ?? undefined}
|
||||
spec={connectSpec_ ?? undefined}
|
||||
onSuccess={handleHookSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { SvgBubbleText, SvgFileBroadcast, SvgHookNodes } from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
|
||||
const HOOK_POINT_ICONS: Record<string, IconFunctionComponent> = {
|
||||
document_ingestion: SvgFileBroadcast,
|
||||
query_processing: SvgBubbleText,
|
||||
};
|
||||
|
||||
function getHookPointIcon(hookPoint: string): IconFunctionComponent {
|
||||
return HOOK_POINT_ICONS[hookPoint] ?? SvgHookNodes;
|
||||
}
|
||||
|
||||
export { HOOK_POINT_ICONS, getHookPointIcon };
|
||||
@@ -1,22 +1,509 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { useHookSpecs } from "@/ee/hooks/useHookSpecs";
|
||||
import { useHooks } from "@/ee/hooks/useHooks";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import {
|
||||
useCreateModal,
|
||||
useModalClose,
|
||||
} from "@/refresh-components/contexts/ModalContext";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import HooksContent from "./HooksContent";
|
||||
import { Button, SelectCard, Text } from "@opal/components";
|
||||
import { Disabled, Hoverable } from "@opal/core";
|
||||
import { markdown } from "@opal/utils";
|
||||
import { Content, IllustrationContent } from "@opal/layouts";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import {
|
||||
SvgArrowExchange,
|
||||
SvgBubbleText,
|
||||
SvgExternalLink,
|
||||
SvgFileBroadcast,
|
||||
SvgShareWebhook,
|
||||
SvgPlug,
|
||||
SvgRefreshCw,
|
||||
SvgSettings,
|
||||
SvgTrash,
|
||||
SvgUnplug,
|
||||
} from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import { SvgNoResult, SvgEmpty } from "@opal/illustrations";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import HookFormModal from "@/ee/refresh-pages/admin/HooksPage/HookFormModal";
|
||||
import HookStatusPopover from "@/ee/refresh-pages/admin/HooksPage/HookStatusPopover";
|
||||
import {
|
||||
activateHook,
|
||||
deactivateHook,
|
||||
deleteHook,
|
||||
validateHook,
|
||||
} from "@/ee/refresh-pages/admin/HooksPage/svc";
|
||||
import type {
|
||||
HookPointMeta,
|
||||
HookResponse,
|
||||
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
|
||||
import { noProp } from "@/lib/utils";
|
||||
|
||||
const route = ADMIN_ROUTES.HOOKS;
|
||||
|
||||
const HOOK_POINT_ICONS: Record<string, IconFunctionComponent> = {
|
||||
document_ingestion: SvgFileBroadcast,
|
||||
query_processing: SvgBubbleText,
|
||||
};
|
||||
|
||||
function getHookPointIcon(hookPoint: string): IconFunctionComponent {
|
||||
return HOOK_POINT_ICONS[hookPoint] ?? SvgShareWebhook;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disconnect confirmation modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DisconnectConfirmModalProps {
|
||||
hook: HookResponse;
|
||||
onDisconnect: () => void;
|
||||
onDisconnectAndDelete: () => void;
|
||||
}
|
||||
|
||||
function DisconnectConfirmModal({
|
||||
hook,
|
||||
onDisconnect,
|
||||
onDisconnectAndDelete,
|
||||
}: DisconnectConfirmModalProps) {
|
||||
const onClose = useModalClose();
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Header
|
||||
// TODO(@raunakab): replace the colour of this SVG with red.
|
||||
icon={SvgUnplug}
|
||||
title={markdown(`Disconnect *${hook.name}*`)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<Modal.Body>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
{markdown(
|
||||
`Onyx will stop calling this endpoint for hook ***${hook.name}***. In-flight requests will continue to run. The external endpoint may still retain data previously sent to it. You can reconnect this hook later if needed.`
|
||||
)}
|
||||
</Text>
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
You can also delete this hook. Deletion cannot be undone.
|
||||
</Text>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button prominence="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
prominence="secondary"
|
||||
onClick={onDisconnectAndDelete}
|
||||
>
|
||||
Disconnect & Delete
|
||||
</Button>
|
||||
<Button variant="danger" prominence="primary" onClick={onDisconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete confirmation modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
hook: HookResponse;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function DeleteConfirmModal({ hook, onDelete }: DeleteConfirmModalProps) {
|
||||
const onClose = useModalClose();
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content width="md" height="fit">
|
||||
<Modal.Header
|
||||
// TODO(@raunakab): replace the colour of this SVG with red.
|
||||
icon={SvgTrash}
|
||||
title={`Delete ${hook.name}`}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<Modal.Body>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
{markdown(
|
||||
`Hook ***${hook.name}*** will be permanently removed from this hook point. The external endpoint may still retain data previously sent to it.`
|
||||
)}
|
||||
</Text>
|
||||
<Text font="main-ui-body" color="text-03">
|
||||
Deletion cannot be undone.
|
||||
</Text>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button prominence="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" prominence="primary" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unconnected hook card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UnconnectedHookCardProps {
|
||||
spec: HookPointMeta;
|
||||
onConnect: () => void;
|
||||
}
|
||||
|
||||
function UnconnectedHookCard({ spec, onConnect }: UnconnectedHookCardProps) {
|
||||
const Icon = getHookPointIcon(spec.hook_point);
|
||||
|
||||
return (
|
||||
<SelectCard state="empty" padding="sm" rounding="lg" onClick={onConnect}>
|
||||
<div className="w-full flex flex-row">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={Icon}
|
||||
title={spec.display_name}
|
||||
description={spec.description}
|
||||
/>
|
||||
|
||||
{spec.docs_url && (
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-6 flex items-center gap-1 w-min"
|
||||
>
|
||||
<span className="underline font-secondary-body text-text-03">
|
||||
Documentation
|
||||
</span>
|
||||
<SvgExternalLink size={12} className="shrink-0" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
rightIcon={SvgArrowExchange}
|
||||
onClick={noProp(onConnect)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</SelectCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connected hook card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConnectedHookCardProps {
|
||||
hook: HookResponse;
|
||||
spec: HookPointMeta | undefined;
|
||||
onEdit: () => void;
|
||||
onDeleted: () => void;
|
||||
onToggled: (updated: HookResponse) => void;
|
||||
}
|
||||
|
||||
function ConnectedHookCard({
|
||||
hook,
|
||||
spec,
|
||||
onEdit,
|
||||
onDeleted,
|
||||
onToggled,
|
||||
}: ConnectedHookCardProps) {
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const disconnectModal = useCreateModal();
|
||||
const deleteModal = useCreateModal();
|
||||
|
||||
async function handleDelete() {
|
||||
deleteModal.toggle(false);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
await deleteHook(hook.id);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to delete hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate() {
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const updated = await activateHook(hook.id);
|
||||
onToggled(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to reconnect hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to reconnect hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeactivate() {
|
||||
disconnectModal.toggle(false);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const updated = await deactivateHook(hook.id);
|
||||
onToggled(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to deactivate hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to deactivate hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnectAndDelete() {
|
||||
disconnectModal.toggle(false);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const deactivated = await deactivateHook(hook.id);
|
||||
onToggled(deactivated);
|
||||
await deleteHook(hook.id);
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
console.error("Failed to disconnect hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to disconnect hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleValidate() {
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const result = await validateHook(hook.id);
|
||||
if (result.status === "passed") {
|
||||
toast.success("Hook validated successfully.");
|
||||
} else {
|
||||
toast.error(
|
||||
result.error_message ?? `Validation failed: ${result.status}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to validate hook:", err);
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to validate hook."
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const HookIcon = getHookPointIcon(hook.hook_point);
|
||||
|
||||
return (
|
||||
<>
|
||||
<disconnectModal.Provider>
|
||||
<DisconnectConfirmModal
|
||||
hook={hook}
|
||||
onDisconnect={handleDeactivate}
|
||||
onDisconnectAndDelete={handleDisconnectAndDelete}
|
||||
/>
|
||||
</disconnectModal.Provider>
|
||||
|
||||
<deleteModal.Provider>
|
||||
<DeleteConfirmModal hook={hook} onDelete={handleDelete} />
|
||||
</deleteModal.Provider>
|
||||
|
||||
<Hoverable.Root group="connected-hook-card">
|
||||
<SelectCard state="filled" padding="sm" rounding="lg" onClick={onEdit}>
|
||||
<div className="w-full flex flex-row">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={HookIcon}
|
||||
title={
|
||||
!hook.is_active ? markdown(`~~${hook.name}~~`) : hook.name
|
||||
}
|
||||
description={`Hook Point: ${
|
||||
spec?.display_name ?? hook.hook_point
|
||||
}`}
|
||||
/>
|
||||
|
||||
{spec?.docs_url && (
|
||||
<a
|
||||
href={spec.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-6 flex items-center gap-1 w-min"
|
||||
>
|
||||
<span className="underline font-secondary-body text-text-03">
|
||||
Documentation
|
||||
</span>
|
||||
<SvgExternalLink size={12} className="shrink-0" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{hook.is_active ? (
|
||||
<HookStatusPopover hook={hook} spec={spec} isBusy={isBusy} />
|
||||
) : (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
rightIcon={SvgPlug}
|
||||
onClick={noProp(handleActivate)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Disabled disabled={isBusy}>
|
||||
<div className="flex items-center pb-1 px-1 gap-1">
|
||||
{hook.is_active ? (
|
||||
<>
|
||||
<Hoverable.Item
|
||||
group="connected-hook-card"
|
||||
variant="opacity-on-hover"
|
||||
>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgUnplug}
|
||||
onClick={noProp(() => disconnectModal.toggle(true))}
|
||||
tooltip="Disconnect Hook"
|
||||
aria-label="Deactivate hook"
|
||||
/>
|
||||
</Hoverable.Item>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgRefreshCw}
|
||||
onClick={noProp(handleValidate)}
|
||||
tooltip="Test Connection"
|
||||
aria-label="Re-validate hook"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgTrash}
|
||||
onClick={noProp(() => deleteModal.toggle(true))}
|
||||
tooltip="Delete"
|
||||
aria-label="Delete hook"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgSettings}
|
||||
onClick={noProp(onEdit)}
|
||||
tooltip="Manage"
|
||||
aria-label="Configure hook"
|
||||
/>
|
||||
</div>
|
||||
</Disabled>
|
||||
</div>
|
||||
</div>
|
||||
</SelectCard>
|
||||
</Hoverable.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function HooksPage() {
|
||||
const router = useRouter();
|
||||
const { settings, settingsLoading } = useSettingsContext();
|
||||
const isEE = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
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 hookExtractor = useCallback(
|
||||
(hook: HookResponse) =>
|
||||
`${hook.name} ${
|
||||
specs?.find((s: HookPointMeta) => s.hook_point === hook.hook_point)
|
||||
?.display_name ?? ""
|
||||
}`,
|
||||
[specs]
|
||||
);
|
||||
|
||||
const sortedHooks = useMemo(
|
||||
() => [...(hooks ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[hooks]
|
||||
);
|
||||
|
||||
const {
|
||||
query: search,
|
||||
setQuery: setSearch,
|
||||
filtered: connectedHooks,
|
||||
} = useFilter(sortedHooks, hookExtractor);
|
||||
|
||||
const hooksByPoint = useMemo(() => {
|
||||
const map: Record<string, HookResponse[]> = {};
|
||||
for (const hook of hooks ?? []) {
|
||||
(map[hook.hook_point] ??= []).push(hook);
|
||||
}
|
||||
return map;
|
||||
}, [hooks]);
|
||||
|
||||
const unconnectedSpecs = useMemo(() => {
|
||||
const searchLower = search.toLowerCase();
|
||||
return (specs ?? [])
|
||||
.filter(
|
||||
(spec: HookPointMeta) =>
|
||||
(hooksByPoint[spec.hook_point]?.length ?? 0) === 0 &&
|
||||
(!searchLower ||
|
||||
spec.display_name.toLowerCase().includes(searchLower) ||
|
||||
spec.description.toLowerCase().includes(searchLower))
|
||||
)
|
||||
.sort((a: HookPointMeta, b: HookPointMeta) =>
|
||||
a.display_name.localeCompare(b.display_name)
|
||||
);
|
||||
}, [specs, hooksByPoint, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
if (!isEE) {
|
||||
@@ -32,17 +519,132 @@ export default function HooksPage() {
|
||||
return <SimpleLoader />;
|
||||
}
|
||||
|
||||
const isLoading = specsLoading || hooksLoading;
|
||||
|
||||
function handleHookSuccess(updated: HookResponse) {
|
||||
mutate((prev: HookResponse[] | undefined) => {
|
||||
if (!prev) return [updated];
|
||||
const idx = prev.findIndex((h: HookResponse) => h.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = updated;
|
||||
return next;
|
||||
}
|
||||
return [...prev, updated];
|
||||
});
|
||||
}
|
||||
|
||||
function handleHookDeleted(id: number) {
|
||||
mutate(
|
||||
(prev: HookResponse[] | undefined) =>
|
||||
prev?.filter((h: HookResponse) => h.id !== id)
|
||||
);
|
||||
}
|
||||
|
||||
const connectSpec_ =
|
||||
connectSpec ??
|
||||
(editHook
|
||||
? specs?.find((s: HookPointMeta) => s.hook_point === editHook.hook_point)
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Extend Onyx pipelines by registering external API endpoints as callbacks at predefined hook points."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<HooksContent />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
<>
|
||||
{/* Create modal */}
|
||||
{!!connectSpec && (
|
||||
<HookFormModal
|
||||
key={connectSpec?.hook_point ?? "create"}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) setConnectSpec(null);
|
||||
}}
|
||||
spec={connectSpec ?? undefined}
|
||||
onSuccess={handleHookSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{!!editHook && (
|
||||
<HookFormModal
|
||||
key={editHook?.id ?? "edit"}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) setEditHook(null);
|
||||
}}
|
||||
hook={editHook ?? undefined}
|
||||
spec={connectSpec_ ?? undefined}
|
||||
onSuccess={handleHookSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={route.icon}
|
||||
title={route.title}
|
||||
description="Extend Onyx pipelines by registering external API endpoints as callbacks at predefined hook points."
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
{isLoading ? (
|
||||
<SimpleLoader />
|
||||
) : specsError || hooksError ? (
|
||||
<Text font="secondary-body" color="text-03">
|
||||
{`Failed to load${
|
||||
specsError ? " hook specifications" : " hooks"
|
||||
}. Please refresh the page.`}
|
||||
</Text>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 h-full">
|
||||
<div className="pb-3">
|
||||
<InputTypeIn
|
||||
placeholder="Search hooks..."
|
||||
value={search}
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{connectedHooks.length === 0 && unconnectedSpecs.length === 0 ? (
|
||||
<div>
|
||||
<IllustrationContent
|
||||
title={
|
||||
search ? "No results found" : "No hook points available"
|
||||
}
|
||||
description={
|
||||
search ? "Try using a different search term." : undefined
|
||||
}
|
||||
illustration={search ? SvgNoResult : SvgEmpty}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{connectedHooks.map((hook) => {
|
||||
const spec = specs?.find(
|
||||
(s: HookPointMeta) => 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: HookPointMeta) => (
|
||||
<UnconnectedHookCard
|
||||
key={spec.hook_point}
|
||||
spec={spec}
|
||||
onConnect={() => setConnectSpec(spec)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum LLMProviderName {
|
||||
OPENROUTER = "openrouter",
|
||||
VERTEX_AI = "vertex_ai",
|
||||
BEDROCK = "bedrock",
|
||||
LITELLM = "litellm",
|
||||
LITELLM_PROXY = "litellm_proxy",
|
||||
BIFROST = "bifrost",
|
||||
CUSTOM = "custom",
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
SvgActivity,
|
||||
SvgArrowExchange,
|
||||
SvgAudio,
|
||||
SvgHookNodes,
|
||||
SvgShareWebhook,
|
||||
SvgBarChart,
|
||||
SvgBookOpen,
|
||||
SvgBubbleText,
|
||||
@@ -230,7 +230,7 @@ export const ADMIN_ROUTES = {
|
||||
},
|
||||
HOOKS: {
|
||||
path: "/admin/hooks",
|
||||
icon: SvgHookNodes,
|
||||
icon: SvgShareWebhook,
|
||||
title: "Hook Extensions",
|
||||
sidebarLabel: "Hook Extensions",
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
SvgOpenai,
|
||||
SvgClaude,
|
||||
SvgOllama,
|
||||
SvgCloud,
|
||||
SvgAws,
|
||||
SvgOpenrouter,
|
||||
SvgServer,
|
||||
@@ -22,7 +21,7 @@ const PROVIDER_ICONS: Record<string, IconFunctionComponent> = {
|
||||
[LLMProviderName.VERTEX_AI]: SvgGemini,
|
||||
[LLMProviderName.BEDROCK]: SvgAws,
|
||||
[LLMProviderName.AZURE]: SvgAzure,
|
||||
litellm: SvgLitellm,
|
||||
[LLMProviderName.LITELLM]: SvgLitellm,
|
||||
[LLMProviderName.LITELLM_PROXY]: SvgLitellm,
|
||||
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
|
||||
[LLMProviderName.OPENROUTER]: SvgOpenrouter,
|
||||
@@ -39,7 +38,7 @@ const PROVIDER_PRODUCT_NAMES: Record<string, string> = {
|
||||
[LLMProviderName.VERTEX_AI]: "Gemini",
|
||||
[LLMProviderName.BEDROCK]: "Amazon Bedrock",
|
||||
[LLMProviderName.AZURE]: "Azure OpenAI",
|
||||
litellm: "LiteLLM",
|
||||
[LLMProviderName.LITELLM]: "LiteLLM",
|
||||
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
|
||||
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
|
||||
[LLMProviderName.OPENROUTER]: "OpenRouter",
|
||||
@@ -56,7 +55,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
[LLMProviderName.VERTEX_AI]: "Google Cloud Vertex AI",
|
||||
[LLMProviderName.BEDROCK]: "AWS",
|
||||
[LLMProviderName.AZURE]: "Microsoft Azure",
|
||||
litellm: "LiteLLM",
|
||||
[LLMProviderName.LITELLM]: "LiteLLM",
|
||||
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
|
||||
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
|
||||
[LLMProviderName.OPENROUTER]: "OpenRouter",
|
||||
|
||||
Reference in New Issue
Block a user