mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat: perm sync visibility (#10795)
This commit is contained in:
1 parent
a816c23908
commit
0ae2e1a940
7 files changed
+700
-170
No files matched your search
@@ -81,6 +81,7 @@ export default function TableHead({
|
||||
return (
|
||||
<th
|
||||
{...thProps}
|
||||
scope="col"
|
||||
style={width != null ? { width } : undefined}
|
||||
className={cn("table-head group", alignmentThClass[alignment])}
|
||||
data-size={resolvedSize}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
createTableColumns,
|
||||
MessageCard,
|
||||
EmptyMessageCard,
|
||||
Pagination,
|
||||
Table,
|
||||
Text,
|
||||
} from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { localizeAndPrettify } from "@/lib/time";
|
||||
import ExceptionTraceModal from "@/sections/modals/PreviewModal/ExceptionTraceModal";
|
||||
import { PermissionSyncStatusBadge } from "./PermissionSyncStatusBadge";
|
||||
import type { DocPermissionSyncAttemptSnapshot } from "./types";
|
||||
|
||||
const ERROR_MODAL_TITLE = "Document Permission Sync Error";
|
||||
|
||||
/**
|
||||
* Renders one page of `DocPermissionSyncAttempt` rows for the
|
||||
* connector-detail "Document Permissions" tab.
|
||||
@@ -30,56 +34,101 @@ import type { DocPermissionSyncAttemptSnapshot } from "./types";
|
||||
|
||||
const tc = createTableColumns<DocPermissionSyncAttemptSnapshot>();
|
||||
|
||||
const COLUMNS = [
|
||||
tc.column("time_started", {
|
||||
header: "Time Started",
|
||||
weight: 22,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{value ? localizeAndPrettify(value) : "-"}
|
||||
// Weights are TanStack-relative; they sum to 100 here purely for
|
||||
// readability. `Time Started` is bumped to 28 so `localizeAndPrettify`
|
||||
// (e.g. "5/3/2026, 12:00:00 PM") stays on a single line at standard
|
||||
// 800px-wide admin layouts; the difference is taken from `Status` and
|
||||
// `Docs Synced`, which both render short content.
|
||||
function buildColumns(onErrorClick: (errorMessage: string) => void) {
|
||||
return [
|
||||
tc.column("time_started", {
|
||||
header: "Time Started",
|
||||
weight: 28,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{value ? localizeAndPrettify(value) : "-"}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
enableSorting: false,
|
||||
cell: (value, row) => (
|
||||
<PermissionSyncStatusBadge
|
||||
status={value}
|
||||
errorMsg={row.error_message}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
tc.column("total_docs_synced", {
|
||||
header: "Docs Synced",
|
||||
weight: 12,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("docs_with_permission_errors", {
|
||||
header: "Permission Errors",
|
||||
weight: 18,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("error_message", {
|
||||
header: "Error Message",
|
||||
weight: 28,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<ErrorMessageCell errorMessage={value} onErrorClick={onErrorClick} />
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
interface ErrorMessageCellProps {
|
||||
errorMessage: string | null;
|
||||
onErrorClick: (errorMessage: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the truncated error message as a clickable button when a
|
||||
* message is present, opening the `ExceptionTraceModal` on click.
|
||||
* Mirrors the row-level click affordance in `IndexAttemptsTable`,
|
||||
* scoped here to just the cell so the status-badge tooltip on the
|
||||
* left stays its own independent affordance.
|
||||
*/
|
||||
function ErrorMessageCell({
|
||||
errorMessage,
|
||||
onErrorClick,
|
||||
}: ErrorMessageCellProps) {
|
||||
if (!errorMessage) {
|
||||
return (
|
||||
<Text as="span" font="secondary-body" color="text-03">
|
||||
-
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 18,
|
||||
enableSorting: false,
|
||||
cell: (value, row) => (
|
||||
<PermissionSyncStatusBadge status={value} errorMsg={row.error_message} />
|
||||
),
|
||||
}),
|
||||
tc.column("total_docs_synced", {
|
||||
header: "Docs Synced",
|
||||
weight: 14,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("docs_with_permission_errors", {
|
||||
header: "Permission Errors",
|
||||
weight: 18,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("error_message", {
|
||||
header: "Error Message",
|
||||
weight: 28,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onErrorClick(errorMessage)}
|
||||
aria-label="View full error message"
|
||||
className="text-left w-full cursor-pointer hover:underline"
|
||||
>
|
||||
<Text as="span" font="secondary-body" color="text-03" maxLines={2}>
|
||||
{value ?? "-"}
|
||||
{errorMessage}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
];
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DocPermissionSyncAttemptsTableProps {
|
||||
attempts: DocPermissionSyncAttemptSnapshot[];
|
||||
@@ -95,38 +144,58 @@ export function DocPermissionSyncAttemptsTable({
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: DocPermissionSyncAttemptsTableProps) {
|
||||
const [openErrorMessage, setOpenErrorMessage] = useState<string | null>(null);
|
||||
const handleErrorClick = useCallback(
|
||||
(errorMessage: string) => setOpenErrorMessage(errorMessage),
|
||||
[]
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => buildColumns(handleErrorClick),
|
||||
[handleErrorClick]
|
||||
);
|
||||
|
||||
if (!attempts.length) {
|
||||
return (
|
||||
<MessageCard
|
||||
variant="info"
|
||||
title="No document permission sync attempts scheduled yet"
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No document permission sync attempts yet"
|
||||
description="Document-permission sync runs are scheduled in the background. They may take some time to appear — try refreshing in ~30 seconds."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section gap={0.75} alignItems="stretch" height="auto">
|
||||
<Table
|
||||
data={attempts}
|
||||
columns={COLUMNS}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
height="auto"
|
||||
className="pt-1"
|
||||
>
|
||||
<Pagination
|
||||
variant="list"
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</Section>
|
||||
<>
|
||||
{openErrorMessage !== null && (
|
||||
<ExceptionTraceModal
|
||||
onOutsideClick={() => setOpenErrorMessage(null)}
|
||||
exceptionTrace={openErrorMessage}
|
||||
title={ERROR_MODAL_TITLE}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section gap={0.75} alignItems="stretch" height="auto">
|
||||
<Table
|
||||
data={attempts}
|
||||
columns={columns}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
height="auto"
|
||||
className="pt-1"
|
||||
>
|
||||
<Pagination
|
||||
variant="list"
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
createTableColumns,
|
||||
MessageCard,
|
||||
EmptyMessageCard,
|
||||
Pagination,
|
||||
Table,
|
||||
Text,
|
||||
} from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { localizeAndPrettify } from "@/lib/time";
|
||||
import ExceptionTraceModal from "@/sections/modals/PreviewModal/ExceptionTraceModal";
|
||||
import { PermissionSyncStatusBadge } from "./PermissionSyncStatusBadge";
|
||||
import type { ExternalGroupSyncAttemptSnapshot } from "./types";
|
||||
|
||||
const ERROR_MODAL_TITLE = "Group Membership Sync Error";
|
||||
|
||||
/**
|
||||
* Renders one page of `ExternalGroupPermissionSyncAttempt` rows for the
|
||||
* connector-detail "Group Membership" tab.
|
||||
@@ -35,66 +39,122 @@ import type { ExternalGroupSyncAttemptSnapshot } from "./types";
|
||||
|
||||
const tc = createTableColumns<ExternalGroupSyncAttemptSnapshot>();
|
||||
|
||||
const COLUMNS = [
|
||||
tc.column("time_started", {
|
||||
header: "Time Started",
|
||||
weight: 20,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{value ? localizeAndPrettify(value) : "-"}
|
||||
// Headers are intentionally short ("Users", not "Users Processed").
|
||||
// Opal's `TableHead` renders headers via `String(children)` (see
|
||||
// `web/lib/opal/src/components/table/TableHead.tsx:96`), which kills any
|
||||
// rich-content header (Tooltip + info icon, etc.) — so we lean on
|
||||
// concise, contextually-clear labels instead. The tab itself is named
|
||||
// "Group Membership", so "Users / Groups / Memberships" reads as
|
||||
// "users seen / groups processed / memberships written" without
|
||||
// further annotation.
|
||||
//
|
||||
// Weights are TanStack-relative; the per-column `minWidth =
|
||||
// header.length * 8 + 40` floor means short labels are also necessary
|
||||
// to actually achieve "skinnier" columns — e.g. "Users Processed"
|
||||
// pins minWidth to 160px no matter the weight.
|
||||
//
|
||||
// `Time Started` is bumped to 26 so `localizeAndPrettify` (e.g.
|
||||
// "5/3/2026, 12:00:00 PM") stays on a single line.
|
||||
function buildColumns(onErrorClick: (errorMessage: string) => void) {
|
||||
return [
|
||||
tc.column("time_started", {
|
||||
header: "Time Started",
|
||||
weight: 26,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{value ? localizeAndPrettify(value) : "-"}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
enableSorting: false,
|
||||
cell: (value, row) => (
|
||||
<PermissionSyncStatusBadge
|
||||
status={value}
|
||||
errorMsg={row.error_message}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
tc.column("total_users_processed", {
|
||||
header: "Users",
|
||||
weight: 10,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("total_groups_processed", {
|
||||
header: "Groups",
|
||||
weight: 10,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("total_group_memberships_synced", {
|
||||
header: "Memberships",
|
||||
weight: 12,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("error_message", {
|
||||
header: "Error Message",
|
||||
weight: 28,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<ErrorMessageCell errorMessage={value} onErrorClick={onErrorClick} />
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
interface ErrorMessageCellProps {
|
||||
errorMessage: string | null;
|
||||
onErrorClick: (errorMessage: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the truncated error message as a clickable button when a
|
||||
* message is present, opening the `ExceptionTraceModal` on click.
|
||||
* Mirrors the row-level click affordance in `IndexAttemptsTable`,
|
||||
* scoped here to just the cell so the status-badge tooltip on the
|
||||
* left stays its own independent affordance.
|
||||
*/
|
||||
function ErrorMessageCell({
|
||||
errorMessage,
|
||||
onErrorClick,
|
||||
}: ErrorMessageCellProps) {
|
||||
if (!errorMessage) {
|
||||
return (
|
||||
<Text as="span" font="secondary-body" color="text-03">
|
||||
-
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 16,
|
||||
enableSorting: false,
|
||||
cell: (value, row) => (
|
||||
<PermissionSyncStatusBadge status={value} errorMsg={row.error_message} />
|
||||
),
|
||||
}),
|
||||
tc.column("total_users_processed", {
|
||||
header: "Users Processed",
|
||||
weight: 14,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("total_groups_processed", {
|
||||
header: "Groups Processed",
|
||||
weight: 14,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("total_group_memberships_synced", {
|
||||
header: "Memberships Synced",
|
||||
weight: 14,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
<Text as="span" font="main-ui-body" color="text-04">
|
||||
{String(value)}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.column("error_message", {
|
||||
header: "Error Message",
|
||||
weight: 22,
|
||||
enableSorting: false,
|
||||
cell: (value) => (
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onErrorClick(errorMessage)}
|
||||
aria-label="View full error message"
|
||||
className="text-left w-full cursor-pointer hover:underline"
|
||||
>
|
||||
<Text as="span" font="secondary-body" color="text-03" maxLines={2}>
|
||||
{value ?? "-"}
|
||||
{errorMessage}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
];
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExternalGroupSyncAttemptsTableProps {
|
||||
attempts: ExternalGroupSyncAttemptSnapshot[];
|
||||
@@ -110,38 +170,58 @@ export function ExternalGroupSyncAttemptsTable({
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: ExternalGroupSyncAttemptsTableProps) {
|
||||
const [openErrorMessage, setOpenErrorMessage] = useState<string | null>(null);
|
||||
const handleErrorClick = useCallback(
|
||||
(errorMessage: string) => setOpenErrorMessage(errorMessage),
|
||||
[]
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => buildColumns(handleErrorClick),
|
||||
[handleErrorClick]
|
||||
);
|
||||
|
||||
if (!attempts.length) {
|
||||
return (
|
||||
<MessageCard
|
||||
variant="info"
|
||||
title="No group membership sync attempts scheduled yet"
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No group membership sync attempts yet"
|
||||
description="Group-membership sync runs are scheduled in the background. They may take some time to appear — try refreshing in ~30 seconds."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section gap={0.75} alignItems="stretch" height="auto">
|
||||
<Table
|
||||
data={attempts}
|
||||
columns={COLUMNS}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
height="auto"
|
||||
className="pt-1"
|
||||
>
|
||||
<Pagination
|
||||
variant="list"
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</Section>
|
||||
<>
|
||||
{openErrorMessage !== null && (
|
||||
<ExceptionTraceModal
|
||||
onOutsideClick={() => setOpenErrorMessage(null)}
|
||||
exceptionTrace={openErrorMessage}
|
||||
title={ERROR_MODAL_TITLE}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section gap={0.75} alignItems="stretch" height="auto">
|
||||
<Table
|
||||
data={attempts}
|
||||
columns={columns}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
height="auto"
|
||||
className="pt-1"
|
||||
>
|
||||
<Pagination
|
||||
variant="list"
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -93,10 +93,10 @@ export function SyncAttemptsTabs({
|
||||
<Tabs.List variant="contained">
|
||||
<Tabs.Trigger value={SyncAttemptsTab.INDEXING}>Indexing</Tabs.Trigger>
|
||||
<Tabs.Trigger value={SyncAttemptsTab.DOC_PERMISSIONS}>
|
||||
Document Permissions
|
||||
Document Permission Sync
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value={SyncAttemptsTab.GROUP_MEMBERSHIP}>
|
||||
Group Membership
|
||||
Group Membership Sync
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
@@ -228,6 +228,14 @@ function renderTabGate(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves roughly the height of a full populated `ITEMS_PER_PAGE`
|
||||
* results page (8 rows + header + pagination footer ≈ 32rem at the
|
||||
* default Opal `Table` `size="lg"`). Without this the loading state
|
||||
* collapses the tab body, the page shrinks, the user's scroll position
|
||||
* jumps up, and when data lands they have to scroll back down to see
|
||||
* the table — which is exactly what the previous spinner was causing.
|
||||
*/
|
||||
function SyncAttemptsTabSpinner() {
|
||||
return (
|
||||
<Section
|
||||
@@ -235,7 +243,7 @@ function SyncAttemptsTabSpinner() {
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="auto"
|
||||
className="py-6"
|
||||
className="min-h-[32rem]"
|
||||
>
|
||||
<SimpleLoader className="h-6 w-6" />
|
||||
</Section>
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import DeletionErrorStatus from "./DeletionErrorStatus";
|
||||
import { IndexAttemptsTable } from "./IndexAttemptsTable";
|
||||
import InlineFileManagement from "./InlineFileManagement";
|
||||
import { SyncAttemptsTabs } from "./SyncAttemptsTabs";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { buildCCPairInfoUrl, triggerIndexing } from "./lib";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
@@ -726,18 +728,31 @@ function Main({ ccPairId }: { ccPairId: number }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Title size="md" className="mt-6 mb-2">
|
||||
Indexing Attempts
|
||||
</Title>
|
||||
{indexAttempts && (
|
||||
<IndexAttemptsTable
|
||||
ccPair={ccPair}
|
||||
indexAttempts={indexAttempts}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
{indexAttempts &&
|
||||
(ccPair.access_type === "sync" ? (
|
||||
<Section height="auto" alignItems="stretch" className="mt-6">
|
||||
<SyncAttemptsTabs
|
||||
ccPair={ccPair}
|
||||
indexAttempts={indexAttempts}
|
||||
indexCurrentPage={currentPage}
|
||||
indexTotalPages={totalPages}
|
||||
onIndexPageChange={goToPage}
|
||||
/>
|
||||
</Section>
|
||||
) : (
|
||||
<>
|
||||
<Title size="md" className="mt-6 mb-2">
|
||||
Indexing Attempts
|
||||
</Title>
|
||||
<IndexAttemptsTable
|
||||
ccPair={ccPair}
|
||||
indexAttempts={indexAttempts}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,19 +8,27 @@ interface ExceptionTraceModalProps {
|
||||
onOutsideClick: () => void;
|
||||
exceptionTrace: string;
|
||||
language?: string;
|
||||
/**
|
||||
* Modal header. Defaults to "Full Exception Trace" — the original
|
||||
* caller (`IndexAttemptsTable`) renders multi-line Python tracebacks.
|
||||
* Pass a more specific label (e.g. "Sync Error") for shorter, less
|
||||
* trace-shaped error messages.
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function ExceptionTraceModal({
|
||||
onOutsideClick,
|
||||
exceptionTrace,
|
||||
language = "python",
|
||||
title = "Full Exception Trace",
|
||||
}: ExceptionTraceModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onOutsideClick}>
|
||||
<Modal.Content width="full" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Full Exception Trace"
|
||||
title={title}
|
||||
onClose={onOutsideClick}
|
||||
height="fit"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* E2E coverage for the connector-detail "Advanced" → permission-sync tabs UX.
|
||||
*
|
||||
* Three scenarios:
|
||||
*
|
||||
* 1. **Non-sync connector** (real file cc-pair): the legacy "Indexing
|
||||
* Attempts" title + table render exactly as before. No tab UI.
|
||||
* 2. **Sync connector** (route-mocked, since spinning up a real
|
||||
* permission-synced cc-pair would require source-specific OAuth /
|
||||
* credentials we don't have in CI): all three tabs render, the
|
||||
* Document Permissions tab loads its endpoint and shows the row, and
|
||||
* the Group Membership tab shows the explicit "not applicable"
|
||||
* message rather than a blank empty state.
|
||||
* 3. **Failed-attempt error modal**: in both sync tables, a `failed` row
|
||||
* surfaces the error message as a clickable button that opens
|
||||
* `ExceptionTraceModal` with a tab-specific title and the full error
|
||||
* text rendered in the body.
|
||||
*
|
||||
* The backend behavior (route correctness, `applicable` flag computation,
|
||||
* source-wide attribution for cc-pair-agnostic sources) is covered by
|
||||
* the external-dependency-unit suite at
|
||||
* `backend/tests/external_dependency_unit/permission_sync/test_cc_pair_sync_attempts_routes.py`,
|
||||
* landed in PR A. This spec deliberately scopes itself to the frontend
|
||||
* rendering decisions PR B–D introduce.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
|
||||
|
||||
const MOCK_SYNC_CC_PAIR_ID = 99999;
|
||||
const MOCK_SOURCE = "google_drive";
|
||||
const MOCK_DOC_ATTEMPT_ID = 5001;
|
||||
const MOCK_GROUP_ATTEMPT_ID = 6001;
|
||||
const MOCK_DOC_ERROR_MESSAGE =
|
||||
"Traceback: doc permission sync failed because the upstream API returned 503";
|
||||
const MOCK_GROUP_ERROR_MESSAGE =
|
||||
"Traceback: group membership sync failed because the upstream API returned 502";
|
||||
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal `CCPairFullInfo` shape needed for the page to render with
|
||||
* `access_type === "sync"`. Fields not exercised by the assertions are
|
||||
* filled with neutral defaults.
|
||||
*/
|
||||
function syncCCPairFixture() {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: MOCK_SYNC_CC_PAIR_ID,
|
||||
name: "Mock Sync Connector",
|
||||
status: "ACTIVE",
|
||||
in_repeated_error_state: false,
|
||||
num_docs_indexed: 0,
|
||||
connector: {
|
||||
id: 12345,
|
||||
name: "Mock Sync Connector",
|
||||
source: MOCK_SOURCE,
|
||||
input_type: "poll",
|
||||
connector_specific_config: {},
|
||||
refresh_freq: 600,
|
||||
prune_freq: null,
|
||||
indexing_start: null,
|
||||
access_type: "sync",
|
||||
credential_ids: [54321],
|
||||
time_created: now,
|
||||
time_updated: now,
|
||||
},
|
||||
credential: {
|
||||
id: 54321,
|
||||
name: "Mock Credential",
|
||||
credential_json: {},
|
||||
admin_public: true,
|
||||
time_created: now,
|
||||
time_updated: now,
|
||||
source: MOCK_SOURCE,
|
||||
user_id: null,
|
||||
curator_public: true,
|
||||
},
|
||||
number_of_index_attempts: 0,
|
||||
last_index_attempt_status: null,
|
||||
latest_deletion_attempt: null,
|
||||
access_type: "sync",
|
||||
is_editable_for_current_user: true,
|
||||
deletion_failure_message: null,
|
||||
indexing: false,
|
||||
creator: null,
|
||||
creator_email: null,
|
||||
last_indexed: null,
|
||||
last_pruned: null,
|
||||
last_full_permission_sync: null,
|
||||
overall_indexing_speed: null,
|
||||
latest_checkpoint_description: null,
|
||||
last_permission_sync_attempt_status: null,
|
||||
permission_syncing: false,
|
||||
last_permission_sync_attempt_finished: null,
|
||||
last_permission_sync_attempt_error_message: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface SyncMockOptions {
|
||||
/** Doc-permission attempts response. Must mirror `CCPairSyncAttemptsResponse`. */
|
||||
docPermissions: {
|
||||
applicable: boolean;
|
||||
items: Array<Record<string, unknown>>;
|
||||
total_items: number;
|
||||
};
|
||||
/** Group-membership attempts response. */
|
||||
externalGroup: {
|
||||
applicable: boolean;
|
||||
items: Array<Record<string, unknown>>;
|
||||
total_items: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires up route mocks for everything the connector-detail page fetches
|
||||
* for `MOCK_SYNC_CC_PAIR_ID`. Other endpoints (auth, license, llm
|
||||
* providers, etc.) are left untouched and hit the real backend.
|
||||
*
|
||||
* Routes are registered parent-first; Playwright runs them LIFO, so a
|
||||
* request to `/cc-pair/99999/index-attempts?...` is handled by the
|
||||
* `index-attempts` route, not the bare `/cc-pair/99999` route.
|
||||
*/
|
||||
async function mockSyncConnectorEndpoints(
|
||||
page: Page,
|
||||
{ docPermissions, externalGroup }: SyncMockOptions
|
||||
): Promise<void> {
|
||||
const base = `**/api/manage/admin/cc-pair/${MOCK_SYNC_CC_PAIR_ID}`;
|
||||
|
||||
await page.route(base, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill(jsonResponse(syncCCPairFixture()));
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.route(`${base}/index-attempts*`, async (route) => {
|
||||
await route.fulfill(jsonResponse({ items: [], total_items: 0 }));
|
||||
});
|
||||
|
||||
await page.route(`${base}/errors*`, async (route) => {
|
||||
await route.fulfill(jsonResponse({ items: [], total_items: 0 }));
|
||||
});
|
||||
|
||||
await page.route(
|
||||
`${base}/permission-sync-attempts*`,
|
||||
async (route: Route) => {
|
||||
await route.fulfill(jsonResponse(docPermissions));
|
||||
}
|
||||
);
|
||||
|
||||
await page.route(
|
||||
`${base}/external-group-sync-attempts*`,
|
||||
async (route: Route) => {
|
||||
await route.fulfill(jsonResponse(externalGroup));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Permission sync tabs", () => {
|
||||
test("non-sync connector: renders legacy Indexing Attempts table, no tabs", async ({
|
||||
page,
|
||||
}) => {
|
||||
const apiClient = new OnyxApiClient(page.request);
|
||||
const ccPairId = await apiClient.createFileConnector(
|
||||
`E2E PermSyncTabs NonSync ${Date.now()}`
|
||||
);
|
||||
|
||||
try {
|
||||
await page.goto(`/admin/connector/${ccPairId}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("button", { name: "Advanced" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Indexing Attempts" })
|
||||
).toBeVisible();
|
||||
|
||||
// No tab triggers should appear for a non-sync (file) connector.
|
||||
await expect(page.getByRole("tab", { name: "Indexing" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("tab", { name: "Document Permission Sync" })
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("tab", { name: "Group Membership Sync" })
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await apiClient.deleteCCPair(ccPairId);
|
||||
}
|
||||
});
|
||||
|
||||
test("sync connector: renders all three tabs, doc tab shows rows, group tab shows not-applicable message", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockSyncConnectorEndpoints(page, {
|
||||
docPermissions: {
|
||||
applicable: true,
|
||||
items: [
|
||||
{
|
||||
id: MOCK_DOC_ATTEMPT_ID,
|
||||
status: "success",
|
||||
error_message: null,
|
||||
total_docs_synced: 42,
|
||||
docs_with_permission_errors: 0,
|
||||
time_created: "2026-05-03T11:55:00Z",
|
||||
time_started: "2026-05-03T12:00:00Z",
|
||||
time_finished: "2026-05-03T12:01:30Z",
|
||||
},
|
||||
],
|
||||
total_items: 1,
|
||||
},
|
||||
externalGroup: {
|
||||
applicable: false,
|
||||
items: [],
|
||||
total_items: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/admin/connector/${MOCK_SYNC_CC_PAIR_ID}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("button", { name: "Advanced" }).click();
|
||||
|
||||
const indexingTab = page.getByRole("tab", { name: "Indexing" });
|
||||
const docPermissionsTab = page.getByRole("tab", {
|
||||
name: "Document Permission Sync",
|
||||
});
|
||||
const groupMembershipTab = page.getByRole("tab", {
|
||||
name: "Group Membership Sync",
|
||||
});
|
||||
|
||||
await expect(indexingTab).toBeVisible();
|
||||
await expect(docPermissionsTab).toBeVisible();
|
||||
await expect(groupMembershipTab).toBeVisible();
|
||||
// Indexing is the default — the legacy "Indexing Attempts" header
|
||||
// does NOT render inside the tabbed flow; the tab triggers are the
|
||||
// visual header now.
|
||||
await expect(indexingTab).toHaveAttribute("data-state", "active");
|
||||
|
||||
await docPermissionsTab.click();
|
||||
await expect(docPermissionsTab).toHaveAttribute("data-state", "active");
|
||||
// The DocPermissionSyncAttemptsTable renders column headers when
|
||||
// attempts.length > 0; the "Docs Synced" header is a stable signal
|
||||
// that the table (not the empty/not-applicable card) is showing.
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: "Docs Synced" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("No document permission sync attempts yet")
|
||||
).toHaveCount(0);
|
||||
|
||||
await groupMembershipTab.click();
|
||||
await expect(groupMembershipTab).toHaveAttribute("data-state", "active");
|
||||
// The explicit not-applicable message — distinct from the empty
|
||||
// "no attempts scheduled yet" state on a fresh applicable tab.
|
||||
await expect(
|
||||
page.getByText(
|
||||
"This connector does not use a separate group-membership syncing job."
|
||||
)
|
||||
).toBeVisible();
|
||||
// And the table headers from DocPermissions should NOT bleed through —
|
||||
// Radix's default Tabs.Content unmount confirms tabs are independent.
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: "Docs Synced" })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sync connector: clicking a failed row's Error Message opens the trace modal in both sync tables", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockSyncConnectorEndpoints(page, {
|
||||
docPermissions: {
|
||||
applicable: true,
|
||||
items: [
|
||||
{
|
||||
id: MOCK_DOC_ATTEMPT_ID,
|
||||
status: "failed",
|
||||
error_message: MOCK_DOC_ERROR_MESSAGE,
|
||||
total_docs_synced: 0,
|
||||
docs_with_permission_errors: 0,
|
||||
time_created: "2026-05-03T11:55:00Z",
|
||||
time_started: "2026-05-03T12:00:00Z",
|
||||
time_finished: "2026-05-03T12:01:30Z",
|
||||
},
|
||||
],
|
||||
total_items: 1,
|
||||
},
|
||||
externalGroup: {
|
||||
applicable: true,
|
||||
items: [
|
||||
{
|
||||
id: MOCK_GROUP_ATTEMPT_ID,
|
||||
status: "failed",
|
||||
error_message: MOCK_GROUP_ERROR_MESSAGE,
|
||||
total_users_processed: 0,
|
||||
total_groups_processed: 0,
|
||||
total_group_memberships_synced: 0,
|
||||
time_created: "2026-05-03T11:55:00Z",
|
||||
time_started: "2026-05-03T12:00:00Z",
|
||||
time_finished: "2026-05-03T12:01:30Z",
|
||||
},
|
||||
],
|
||||
total_items: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/admin/connector/${MOCK_SYNC_CC_PAIR_ID}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("button", { name: "Advanced" }).click();
|
||||
|
||||
await page.getByRole("tab", { name: "Document Permission Sync" }).click();
|
||||
// The error-message button is keyed on the cell's aria-label so the
|
||||
// assertion stays robust if the truncated text changes.
|
||||
await page.getByRole("button", { name: "View full error message" }).click();
|
||||
|
||||
const docModal = page.getByRole("dialog", {
|
||||
name: "Document Permission Sync Error",
|
||||
});
|
||||
await expect(docModal).toBeVisible();
|
||||
// `toContainText` concatenates text across descendants — needed
|
||||
// because `CodePreview` runs the body through a syntax highlighter
|
||||
// that splits each token into its own `<span>`.
|
||||
await expect(docModal).toContainText(MOCK_DOC_ERROR_MESSAGE);
|
||||
|
||||
// Escape closes Radix dialogs; the listener restores focus to the
|
||||
// trigger so re-entering the tab does not race with state cleanup.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(docModal).not.toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: "Group Membership Sync" }).click();
|
||||
await page.getByRole("button", { name: "View full error message" }).click();
|
||||
|
||||
const groupModal = page.getByRole("dialog", {
|
||||
name: "Group Membership Sync Error",
|
||||
});
|
||||
await expect(groupModal).toBeVisible();
|
||||
await expect(groupModal).toContainText(MOCK_GROUP_ERROR_MESSAGE);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user