mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge branch 'litellm_internal_staging' into litellm_shadcn_permissions_0814
This commit is contained in:
commit
e897e2b0aa
87 changed files with 4807 additions and 4500 deletions
|
|
@ -19,12 +19,11 @@ test.describe("Internal User", () => {
|
|||
|
||||
// Open the team dropdown — seeded internal user is a member of
|
||||
// e2e-team-crud and e2e-team-org, so we expect at least the CRUD alias.
|
||||
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
|
||||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
|
||||
await expect(page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const dropdown = page.locator('[data-slot="combobox-content"]:visible');
|
||||
await expect(dropdown.getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Team info page omits the Settings tab for non-admin members", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -27,18 +27,18 @@ test.describe("Internal User with no team memberships", () => {
|
|||
await page.getByRole("button", { name: /Create New Key/i }).click();
|
||||
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
|
||||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
|
||||
const dropdown = page.locator(".ant-select-dropdown:visible").first();
|
||||
const dropdown = page.locator('[data-slot="combobox-content"]:visible').first();
|
||||
await expect(dropdown).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Wait for the settled-empty state, not a transient one. The dropdown shows
|
||||
// a spinner while teams load and only swaps in "No teams found" once the
|
||||
// request resolves with nothing (team_dropdown.tsx renders the spinner when
|
||||
// isLoading and this copy otherwise). Asserting on it means a regression
|
||||
// where teams DO load for this user fails here instead of racing a one-shot
|
||||
// count() against an in-flight request.
|
||||
// "Loading teams…" while teams load and only swaps in "No teams found" once
|
||||
// the request resolves with nothing (team_dropdown.tsx passes both copies to
|
||||
// PaginatedSearchSelect). Asserting on it means a regression where teams DO
|
||||
// load for this user fails here instead of racing a one-shot count() against
|
||||
// an in-flight request.
|
||||
await expect(dropdown.getByText("No teams found")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(dropdown.getByRole("option")).toHaveCount(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ test.describe("Internal User with team memberships", () => {
|
|||
await page.getByRole("button", { name: /Create New Key/i }).click();
|
||||
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
|
||||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
|
||||
const dropdown = page.locator(".ant-select-dropdown:visible").first();
|
||||
const dropdown = page.locator('[data-slot="combobox-content"]:visible').first();
|
||||
await expect(dropdown).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Both seeded memberships render, and nothing else does — proving the
|
||||
|
|
|
|||
|
|
@ -328,11 +328,11 @@ test.describe("Add Model", () => {
|
|||
const teamByokRow = page.locator(".ant-form-item", { hasText: "Team-BYOK Model" });
|
||||
await teamByokRow.getByRole("switch").click();
|
||||
|
||||
// TeamDropdown's options carry custom markup and no role="option", so match by text.
|
||||
const teamDropdown = page.getByTestId("team-dropdown");
|
||||
// TeamDropdown options show the alias above the team id, so match on the id line by text.
|
||||
const teamDropdown = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await expect(teamDropdown).toBeVisible({ timeout: 5_000 });
|
||||
await teamDropdown.click();
|
||||
const teamOption = page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ID).first();
|
||||
const teamOption = page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ID).first();
|
||||
await expect(teamOption).toBeVisible({ timeout: 5_000 });
|
||||
await teamOption.click();
|
||||
|
||||
|
|
|
|||
|
|
@ -40,11 +40,11 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
const keyName = `e2e-admin-key-${Date.now()}`;
|
||||
await page.getByTestId("base-input").fill(keyName);
|
||||
|
||||
// Select team — the team dropdown has placeholder "Search or select a team"
|
||||
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
|
||||
// Select team
|
||||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
|
||||
await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click();
|
||||
await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click();
|
||||
|
||||
// Select models
|
||||
await page.locator(".ant-select-selection-overflow").click();
|
||||
|
|
@ -157,7 +157,7 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
await page.getByRole("button", { name: "More key actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete Key" }).click();
|
||||
|
||||
const modal = page.locator(".ant-modal:visible");
|
||||
const modal = page.getByRole("dialog", { name: "Delete Key" });
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
await modal.locator("input").fill(E2E_DELETE_KEY_ALIAS);
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
await teamRow.locator('[data-testid^="team-actions-"]').click();
|
||||
await page.getByTestId("team-action-delete").click();
|
||||
|
||||
const modal = page.locator(".ant-modal:visible");
|
||||
const modal = page.getByRole("dialog", { name: "Delete Team?" });
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
await modal.locator("input").fill(E2E_TEAM_DELETE_ALIAS);
|
||||
await modal.getByRole("button", { name: /Force Delete|Delete/i }).click();
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ test.describe("Team Admin", () => {
|
|||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await row.getByTestId("delete-member").click();
|
||||
|
||||
const modal = page.locator(".ant-modal:visible");
|
||||
const modal = page.getByRole("dialog", { name: "Delete Team Member" });
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const remove = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_delete" }, async () => {
|
||||
|
|
@ -139,10 +139,10 @@ test.describe("Team Admin", () => {
|
|||
await page.getByTestId("base-input").fill(keyName);
|
||||
|
||||
// Team selector — same locator pattern as the proxy-admin keys test.
|
||||
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
|
||||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
|
||||
await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click();
|
||||
await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click();
|
||||
|
||||
// Models — pick "All Team Models"
|
||||
await page.locator(".ant-select-selection-overflow").click();
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@
|
|||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": {
|
||||
|
|
@ -239,9 +239,6 @@
|
|||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx": {
|
||||
|
|
@ -252,9 +249,6 @@
|
|||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": {
|
||||
|
|
@ -275,17 +269,11 @@
|
|||
"src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts": {
|
||||
|
|
@ -1709,54 +1697,26 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/AIHub/SkillHubDashboard.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/AIHub/UsefulLinksManagement.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/AIHub/forms/MakeAgentPublicForm.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/AIHub/forms/MakeMCPPublicForm.test.tsx": {
|
||||
"react/display-name": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/AIHub/forms/MakeMCPPublicForm.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/AIHub/forms/MakeModelPublicForm.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -1784,11 +1744,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/DebugWarningBanner.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/DeprecationBanner.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -1840,11 +1795,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/LicenseExpiryBanner.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ModelSelect/ModelSelect.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -1855,39 +1805,11 @@
|
|||
"count": 12
|
||||
}
|
||||
},
|
||||
"src/components/Navbar/BlogDropdown/BlogDropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Navbar/NotificationsBell/NotificationsBell.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Navbar/UserDropdown/UserDropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Navbar/ViewSwitcher.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/SCIM.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
|
|
@ -2295,9 +2217,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2377,15 +2296,7 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/DefaultProxyAdminTag.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/DeleteResourceModal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2434,17 +2345,11 @@
|
|||
}
|
||||
},
|
||||
"src/components/common_components/ModelAliasManager.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/ModelSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2454,14 +2359,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/OrganizationDropdown.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/PassThroughGuardrailsSection.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
|
|
@ -2470,29 +2367,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/PassThroughRoutesSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/PassThroughSecuritySection.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/common_components/PremiumLoggingSettings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/ProjectDropdown.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/RateLimitTypeFormItem.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -2503,22 +2382,9 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/RouterSettingsAccordion.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/budget_duration_dropdown.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/chartUtils.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/chartUtils.tsx": {
|
||||
|
|
@ -2527,9 +2393,6 @@
|
|||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/check_openapi_schema.tsx": {
|
||||
|
|
@ -2554,17 +2417,11 @@
|
|||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/team_dropdown.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/common_components/team_multi_select.tsx": {
|
||||
|
|
@ -2696,9 +2553,6 @@
|
|||
"src/components/logging_settings_view.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/mcp_server_management/MCPServerSelector.tsx": {
|
||||
|
|
@ -2764,18 +2618,12 @@
|
|||
"src/components/model_filters.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/model_group_alias_settings.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2837,9 +2685,6 @@
|
|||
"src/components/navbar.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/networking.tsx": {
|
||||
|
|
@ -2865,9 +2710,6 @@
|
|||
"src/components/object_permissions_view.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/onboarding_link.tsx": {
|
||||
|
|
@ -3051,15 +2893,9 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 4
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 3
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 4
|
||||
}
|
||||
|
|
@ -3465,9 +3301,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
},
|
||||
|
|
@ -3485,29 +3318,15 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/CostBreakdownViewer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/EvalViewer/EvalViewer.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/GuardrailViewer/CompliancePanel.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -3520,31 +3339,17 @@
|
|||
"src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 4
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
|
|
@ -3554,36 +3359,11 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/ToolsSection/FormattedToolView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/ToolsSection/ToolExpandedContent.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/ToolsSection/ToolItem.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/VectorStoreViewer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/columns.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { renderWithProviders, screen, within } from "@/../tests/test-utils";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AccessGroupsPage } from "./AccessGroupsPage";
|
||||
|
|
@ -215,7 +216,9 @@ describe("AccessGroupsPage", () => {
|
|||
await user.click(await openRowMenu(user, "ag-1"));
|
||||
const dialog = screen.getByRole("dialog", { name: "Delete Access Group" });
|
||||
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,25 +8,29 @@ import CostTrackingSettings from "./cost_tracking_settings";
|
|||
// Mock sub-hooks so we can control their state without network calls
|
||||
const mockDiscountConfig = vi.fn(() => ({}));
|
||||
const mockMarginConfig = vi.fn(() => ({}));
|
||||
const mockRemoveDiscount = vi.fn();
|
||||
const mockRemoveMargin = vi.fn();
|
||||
|
||||
const stableDiscountCallbacks = {
|
||||
fetchDiscountConfig: vi.fn().mockResolvedValue(undefined),
|
||||
handleAddProvider: vi.fn().mockResolvedValue(true),
|
||||
handleRemoveProvider: mockRemoveDiscount,
|
||||
handleDiscountChange: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const stableMarginCallbacks = {
|
||||
fetchMarginConfig: vi.fn().mockResolvedValue(undefined),
|
||||
handleAddMargin: vi.fn().mockResolvedValue(true),
|
||||
handleRemoveMargin: mockRemoveMargin,
|
||||
handleMarginChange: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.mock("./use_discount_config", () => ({
|
||||
useDiscountConfig: () => ({
|
||||
discountConfig: mockDiscountConfig(),
|
||||
fetchDiscountConfig: vi.fn().mockResolvedValue(undefined),
|
||||
handleAddProvider: vi.fn().mockResolvedValue(true),
|
||||
handleRemoveProvider: vi.fn().mockResolvedValue(undefined),
|
||||
handleDiscountChange: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
useDiscountConfig: () => ({ discountConfig: mockDiscountConfig(), ...stableDiscountCallbacks }),
|
||||
}));
|
||||
|
||||
vi.mock("./use_margin_config", () => ({
|
||||
useMarginConfig: () => ({
|
||||
marginConfig: mockMarginConfig(),
|
||||
fetchMarginConfig: vi.fn().mockResolvedValue(undefined),
|
||||
handleAddMargin: vi.fn().mockResolvedValue(true),
|
||||
handleRemoveMargin: vi.fn().mockResolvedValue(undefined),
|
||||
handleMarginChange: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
useMarginConfig: () => ({ marginConfig: mockMarginConfig(), ...stableMarginCallbacks }),
|
||||
}));
|
||||
|
||||
vi.mock("./pricing_calculator/index", () => ({
|
||||
|
|
@ -153,6 +157,57 @@ describe("CostTrackingSettings", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("removing a configured provider", () => {
|
||||
const expandAndRemove = async (section: string, actionName: string) => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
|
||||
|
||||
await user.click(screen.getByText(section).closest("button")!);
|
||||
await user.click(await screen.findByRole("button", { name: actionName }));
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
it("should ask to confirm before removing a discount", async () => {
|
||||
mockDiscountConfig.mockReturnValue({ openai: 0.05 });
|
||||
|
||||
await expandAndRemove("Provider Discounts", "Remove discount for openai");
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/are you sure you want to remove the discount for openai\?/i)).toBeInTheDocument();
|
||||
expect(mockRemoveDiscount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should remove the discount once removal is confirmed", async () => {
|
||||
mockDiscountConfig.mockReturnValue({ openai: 0.05 });
|
||||
|
||||
const user = await expandAndRemove("Provider Discounts", "Remove discount for openai");
|
||||
await user.click(await screen.findByRole("button", { name: "Remove" }));
|
||||
|
||||
expect(mockRemoveDiscount).toHaveBeenCalledWith("openai");
|
||||
});
|
||||
|
||||
it("should leave the discount in place when the confirmation is cancelled", async () => {
|
||||
mockDiscountConfig.mockReturnValue({ openai: 0.05 });
|
||||
|
||||
const user = await expandAndRemove("Provider Discounts", "Remove discount for openai");
|
||||
await user.click(await screen.findByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mockRemoveDiscount).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should remove the margin once removal is confirmed", async () => {
|
||||
mockMarginConfig.mockReturnValue({ openai: 0.1 });
|
||||
|
||||
const user = await expandAndRemove("Fee/Price Margin", "Remove margin for openai");
|
||||
expect(screen.getByText(/are you sure you want to remove the margin for openai\?/i)).toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("button", { name: "Remove" }));
|
||||
|
||||
expect(mockRemoveMargin).toHaveBeenCalledWith("openai");
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty state messages", () => {
|
||||
it("should show the empty state message when no discount config is loaded", async () => {
|
||||
mockDiscountConfig.mockReturnValue({});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionBody,
|
||||
TabGroup,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
} from "@tremor/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Modal, Form } from "antd";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CostTrackingSettingsProps } from "./types";
|
||||
import ProviderDiscountTable from "./provider_discount_table";
|
||||
import AddProviderForm from "./add_provider_form";
|
||||
import ProviderMarginTable from "./provider_margin_table";
|
||||
import AddMarginForm from "./add_margin_form";
|
||||
import PricingCalculator from "./pricing_calculator/index";
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import { DocsMenu } from "@/components/HelpLink";
|
||||
import HowItWorks from "./how_it_works";
|
||||
import { useDiscountConfig } from "./use_discount_config";
|
||||
|
|
@ -31,6 +31,29 @@ const DOCS_LINKS = [
|
|||
{ label: "Spend tracking", href: "https://docs.litellm.ai/docs/proxy/cost_tracking" },
|
||||
];
|
||||
|
||||
const REMOVAL_COPY = {
|
||||
discount: { title: "Remove Provider Discount", noun: "discount" },
|
||||
margin: { title: "Remove Provider Margin", noun: "margin" },
|
||||
} as const;
|
||||
|
||||
interface PendingRemoval {
|
||||
kind: keyof typeof REMOVAL_COPY;
|
||||
provider: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const SECTION_HEADER_CLASS = "group/section flex w-full items-center justify-between px-6 py-4 text-left";
|
||||
|
||||
const SectionHeader: React.FC<{ title: string; description: string }> = ({ title, description }) => (
|
||||
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<span className="block text-lg font-semibold text-gray-900">{title}</span>
|
||||
<span className="block text-sm text-gray-500 mt-1">{description}</span>
|
||||
</div>
|
||||
<ChevronDown className="size-5 shrink-0 text-gray-500 transition-transform group-data-[panel-open]/section:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, userRole, accessToken }) => {
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | undefined>(undefined);
|
||||
const [newDiscount, setNewDiscount] = useState<string>("");
|
||||
|
|
@ -42,9 +65,9 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
const [percentageValue, setPercentageValue] = useState<string>("");
|
||||
const [fixedAmountValue, setFixedAmountValue] = useState<string>("");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [pendingRemoval, setPendingRemoval] = useState<PendingRemoval | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [marginForm] = Form.useForm();
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
|
||||
const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin";
|
||||
|
||||
|
|
@ -104,16 +127,18 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
handleAddProvider();
|
||||
};
|
||||
|
||||
const handleRemoveProvider = async (provider: string, providerDisplayName: string) => {
|
||||
modal.confirm({
|
||||
title: "Remove Provider Discount",
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `Are you sure you want to remove the discount for ${providerDisplayName}?`,
|
||||
okText: "Remove",
|
||||
okType: "danger",
|
||||
cancelText: "Cancel",
|
||||
onOk: () => removeProvider(provider),
|
||||
});
|
||||
const handleRemoveProvider = (provider: string, providerDisplayName: string) => {
|
||||
setPendingRemoval({ kind: "discount", provider, displayName: providerDisplayName });
|
||||
};
|
||||
|
||||
const handleConfirmRemoval = () => {
|
||||
if (!pendingRemoval) return;
|
||||
if (pendingRemoval.kind === "discount") {
|
||||
removeProvider(pendingRemoval.provider);
|
||||
} else {
|
||||
removeMargin(pendingRemoval.provider);
|
||||
}
|
||||
setPendingRemoval(null);
|
||||
};
|
||||
|
||||
const handleAddMargin = async () => {
|
||||
|
|
@ -141,16 +166,8 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
setMarginType("percentage");
|
||||
};
|
||||
|
||||
const handleRemoveMargin = async (provider: string, providerDisplayName: string) => {
|
||||
modal.confirm({
|
||||
title: "Remove Provider Margin",
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `Are you sure you want to remove the margin for ${providerDisplayName}?`,
|
||||
okText: "Remove",
|
||||
okType: "danger",
|
||||
cancelText: "Cancel",
|
||||
onOk: () => removeMargin(provider),
|
||||
});
|
||||
const handleRemoveMargin = (provider: string, providerDisplayName: string) => {
|
||||
setPendingRemoval({ kind: "margin", provider, displayName: providerDisplayName });
|
||||
};
|
||||
|
||||
if (!accessToken) {
|
||||
|
|
@ -159,18 +176,16 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
{contextHolder}
|
||||
|
||||
{/* Header Section - Outside the card */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Title>Cost Tracking Settings</Title>
|
||||
<p className="text-xl font-medium text-gray-900">Cost Tracking Settings</p>
|
||||
<DocsMenu items={DOCS_LINKS} />
|
||||
</div>
|
||||
<Text className="text-gray-500 mt-1">
|
||||
<p className="text-gray-500 mt-1">
|
||||
Configure cost discounts and margins for different LLM providers. Changes are saved automatically.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -178,90 +193,78 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full space-y-4">
|
||||
{/* Accordion 1: Provider Discounts - Only for proxy admins */}
|
||||
{isProxyAdmin && (
|
||||
<Accordion>
|
||||
<AccordionHeader className="px-6 py-4">
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<Text className="text-lg font-semibold text-gray-900">Provider Discounts</Text>
|
||||
<Text className="text-sm text-gray-500 mt-1">
|
||||
Apply percentage-based discounts to reduce costs for specific providers
|
||||
</Text>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionBody className="px-0">
|
||||
<TabGroup>
|
||||
<TabList className="px-6 pt-4">
|
||||
<Tab>Discounts</Tab>
|
||||
<Tab>Test It</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
|
||||
<Collapsible className="rounded-lg border">
|
||||
<SectionHeader
|
||||
title="Provider Discounts"
|
||||
description="Apply percentage-based discounts to reduce costs for specific providers"
|
||||
/>
|
||||
<CollapsibleContent className="px-0">
|
||||
<Tabs defaultValue="discounts">
|
||||
<TabsList className="mx-6 mt-4">
|
||||
<TabsTrigger value="discounts">Discounts</TabsTrigger>
|
||||
<TabsTrigger value="test-it">Test It</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="discounts">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => setIsModalVisible(true)}>+ Add Provider Discount</Button>
|
||||
</div>
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500">Loading configuration...</p>
|
||||
</div>
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<Text className="text-gray-500">Loading configuration...</Text>
|
||||
</div>
|
||||
) : Object.keys(discountConfig).length > 0 ? (
|
||||
<ProviderDiscountTable
|
||||
discountConfig={discountConfig}
|
||||
onDiscountChange={handleDiscountChange}
|
||||
onRemoveProvider={handleRemoveProvider}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-16 px-6 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Text className="text-gray-700 font-medium mb-2">No provider discounts configured</Text>
|
||||
<Text className="text-gray-500 text-sm">
|
||||
Click "Add Provider Discount" to get started
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className="px-6 pb-4">
|
||||
<HowItWorks />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
) : Object.keys(discountConfig).length > 0 ? (
|
||||
<ProviderDiscountTable
|
||||
discountConfig={discountConfig}
|
||||
onDiscountChange={handleDiscountChange}
|
||||
onRemoveProvider={handleRemoveProvider}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-16 px-6 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-gray-700 font-medium mb-2">No provider discounts configured</p>
|
||||
<p className="text-gray-500 text-sm">Click "Add Provider Discount" to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="test-it">
|
||||
<div className="px-6 pb-4">
|
||||
<HowItWorks />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Accordion 2: Fee/Price Margin - Only for proxy admins */}
|
||||
{isProxyAdmin && (
|
||||
<Accordion>
|
||||
<AccordionHeader className="px-6 py-4">
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<Text className="text-lg font-semibold text-gray-900">Fee/Price Margin</Text>
|
||||
<Text className="text-sm text-gray-500 mt-1">
|
||||
Add fees or margins to LLM costs for internal billing and cost recovery
|
||||
</Text>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionBody className="px-0">
|
||||
<Collapsible className="rounded-lg border">
|
||||
<SectionHeader
|
||||
title="Fee/Price Margin"
|
||||
description="Add fees or margins to LLM costs for internal billing and cost recovery"
|
||||
/>
|
||||
<CollapsibleContent className="px-0">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => setIsMarginModalVisible(true)}>+ Add Provider Margin</Button>
|
||||
</div>
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<Text className="text-gray-500">Loading configuration...</Text>
|
||||
<p className="text-gray-500">Loading configuration...</p>
|
||||
</div>
|
||||
) : Object.keys(marginConfig).length > 0 ? (
|
||||
<ProviderMarginTable
|
||||
|
|
@ -284,33 +287,49 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Text className="text-gray-700 font-medium mb-2">No provider margins configured</Text>
|
||||
<Text className="text-gray-500 text-sm">Click "Add Provider Margin" to get started</Text>
|
||||
<p className="text-gray-700 font-medium mb-2">No provider margins configured</p>
|
||||
<p className="text-gray-500 text-sm">Click "Add Provider Margin" to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Accordion 3: Pricing Calculator - Available to all roles */}
|
||||
<Accordion defaultOpen={true}>
|
||||
<AccordionHeader className="px-6 py-4">
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<Text className="text-lg font-semibold text-gray-900">Pricing Calculator</Text>
|
||||
<Text className="text-sm text-gray-500 mt-1">
|
||||
Estimate LLM costs based on expected token usage and request volume
|
||||
</Text>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionBody className="px-0">
|
||||
<Collapsible defaultOpen={true} className="rounded-lg border">
|
||||
<SectionHeader
|
||||
title="Pricing Calculator"
|
||||
description="Estimate LLM costs based on expected token usage and request volume"
|
||||
/>
|
||||
<CollapsibleContent className="px-0">
|
||||
<div className="p-6">
|
||||
<PricingCalculator accessToken={accessToken} models={models} />
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{pendingRemoval && (
|
||||
<AlertDialog open onOpenChange={(open) => !open && setPendingRemoval(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{REMOVAL_COPY[pendingRemoval.kind].title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove the {REMOVAL_COPY[pendingRemoval.kind].noun} for{" "}
|
||||
{pendingRemoval.displayName}?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleConfirmRemoval}>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
|
||||
|
|
@ -328,10 +347,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Text className="text-sm text-gray-600 mb-6">
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5%
|
||||
discount).
|
||||
</Text>
|
||||
</p>
|
||||
<Form form={form} onFinish={handleFormSubmit} layout="vertical" className="space-y-6">
|
||||
<AddProviderForm
|
||||
discountConfig={discountConfig}
|
||||
|
|
@ -362,10 +381,10 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
|
|||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Text className="text-sm text-gray-600 mb-6">
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Select a provider (or "Global" for all providers) and configure the margin. You can use
|
||||
percentage-based or fixed amount.
|
||||
</Text>
|
||||
</p>
|
||||
<Form form={marginForm} layout="vertical" className="space-y-6">
|
||||
<AddMarginForm
|
||||
marginConfig={marginConfig}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ const DEFAULT_PROPS = {
|
|||
models: ["gpt-4", "gpt-3.5-turbo", "claude-3-sonnet"],
|
||||
};
|
||||
|
||||
const dataRows = (): HTMLElement[] =>
|
||||
within(screen.getByRole("table"))
|
||||
.getAllByRole("row")
|
||||
.filter((row) => within(row).queryAllByRole("combobox").length > 0);
|
||||
|
||||
const deleteButtonIn = (row: HTMLElement): HTMLElement => {
|
||||
const cells = within(row).getAllByRole("cell");
|
||||
return within(cells[cells.length - 1]).getByRole("button");
|
||||
};
|
||||
|
||||
describe("PricingCalculator", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -124,8 +134,31 @@ describe("PricingCalculator", () => {
|
|||
|
||||
it("should render column headers for Model, Input Tokens, and Output Tokens", () => {
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Input Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Input Tokens" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Output Tokens" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a numeric field for input tokens, output tokens and requests", () => {
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getAllByRole("spinbutton")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should offer a model picker per row", () => {
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should remove a row when its delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PricingCalculator {...DEFAULT_PROPS} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add another model/i }));
|
||||
const withTwoRows = dataRows();
|
||||
expect(withTwoRows).toHaveLength(2);
|
||||
|
||||
await user.click(deleteButtonIn(withTwoRows[1]));
|
||||
|
||||
expect(dataRows()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import React, { useState, useCallback } from "react";
|
||||
import { Table, Select, InputNumber, Button, Radio } from "antd";
|
||||
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { PricingCalculatorProps, ModelEntry } from "./types";
|
||||
import MultiCostResults from "./multi_cost_results";
|
||||
import { useMultiCostEstimate } from "./use_multi_cost_estimate";
|
||||
|
|
@ -63,132 +67,115 @@ const PricingCalculator: React.FC<PricingCalculatorProps> = ({ accessToken, mode
|
|||
|
||||
const multiModelResult = getMultiModelResult(entries);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Model",
|
||||
dataIndex: "model",
|
||||
key: "model",
|
||||
width: "35%",
|
||||
render: (_: string, record: ModelEntry) => (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Select a model"
|
||||
value={record.model || undefined}
|
||||
onChange={(value) => handleEntryChange(record.id, "model", value)}
|
||||
optionFilterProp="label"
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
options={models.map((model) => ({
|
||||
value: model,
|
||||
label: model,
|
||||
}))}
|
||||
style={{ width: "100%" }}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Input Tokens",
|
||||
dataIndex: "input_tokens",
|
||||
key: "input_tokens",
|
||||
width: "18%",
|
||||
render: (_: number, record: ModelEntry) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={record.input_tokens}
|
||||
onChange={(value) => handleEntryChange(record.id, "input_tokens", value ?? 0)}
|
||||
style={{ width: "100%" }}
|
||||
size="small"
|
||||
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Output Tokens",
|
||||
dataIndex: "output_tokens",
|
||||
key: "output_tokens",
|
||||
width: "18%",
|
||||
render: (_: number, record: ModelEntry) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={record.output_tokens}
|
||||
onChange={(value) => handleEntryChange(record.id, "output_tokens", value ?? 0)}
|
||||
style={{ width: "100%" }}
|
||||
size="small"
|
||||
formatter={(value) => `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: `Requests/${timePeriod === "day" ? "Day" : "Month"}`,
|
||||
dataIndex: timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month",
|
||||
key: "num_requests",
|
||||
width: "20%",
|
||||
render: (_: number | undefined, record: ModelEntry) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={timePeriod === "day" ? record.num_requests_per_day : record.num_requests_per_month}
|
||||
onChange={(value) =>
|
||||
handleEntryChange(
|
||||
record.id,
|
||||
timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month",
|
||||
value ?? undefined,
|
||||
)
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
size="small"
|
||||
placeholder="-"
|
||||
formatter={(value) => (value ? `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 50,
|
||||
render: (_: unknown, record: ModelEntry) => (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveEntry(record.id)}
|
||||
disabled={entries.length === 1}
|
||||
danger
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
const modelOptions = models.map((model) => ({ label: model, value: model }));
|
||||
const requestsField = timePeriod === "day" ? "num_requests_per_day" : "num_requests_per_month";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<Radio.Group
|
||||
<RadioGroup
|
||||
value={timePeriod}
|
||||
onChange={(e) => handleTimePeriodChange(e.target.value)}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
onValueChange={(value) => handleTimePeriodChange(value as TimePeriod)}
|
||||
className="flex w-auto items-center gap-4"
|
||||
>
|
||||
<Radio.Button value="day">Per Day</Radio.Button>
|
||||
<Radio.Button value="month">Per Month</Radio.Button>
|
||||
</Radio.Group>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="day" />
|
||||
Per Day
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<RadioGroupItem value="month" />
|
||||
Per Month
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
footer={() => (
|
||||
<Button type="dashed" onClick={handleAddEntry} icon={<PlusOutlined />} className="w-full">
|
||||
Add Another Model
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[35%]">Model</TableHead>
|
||||
<TableHead className="w-[18%]">Input Tokens</TableHead>
|
||||
<TableHead className="w-[18%]">Output Tokens</TableHead>
|
||||
<TableHead className="w-[20%]">Requests/{timePeriod === "day" ? "Day" : "Month"}</TableHead>
|
||||
<TableHead className="w-[50px]">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((record, index) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell className="whitespace-normal">
|
||||
<SearchSelect
|
||||
options={modelOptions}
|
||||
value={record.model || undefined}
|
||||
onValueChange={(value) => handleEntryChange(record.id, "model", value)}
|
||||
placeholder="Select a model"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8"
|
||||
value={record.input_tokens}
|
||||
onChange={(e) =>
|
||||
handleEntryChange(record.id, "input_tokens", e.target.value === "" ? 0 : Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8"
|
||||
value={record.output_tokens}
|
||||
onChange={(e) =>
|
||||
handleEntryChange(record.id, "output_tokens", e.target.value === "" ? 0 : Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8"
|
||||
placeholder="-"
|
||||
value={record[requestsField] ?? ""}
|
||||
onChange={(e) =>
|
||||
handleEntryChange(
|
||||
record.id,
|
||||
requestsField,
|
||||
e.target.value === "" ? undefined : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove model row ${index + 1}`}
|
||||
onClick={() => handleRemoveEntry(record.id)}
|
||||
disabled={entries.length === 1}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<Button variant="outline" onClick={handleAddEntry} className="w-full border-dashed">
|
||||
<Plus className="size-3.5" />
|
||||
Add Another Model
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
|
||||
<MultiCostResults multiResult={multiModelResult} timePeriod={timePeriod} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -85,6 +85,14 @@ function emptyMultiResult(): MultiModelResult {
|
|||
};
|
||||
}
|
||||
|
||||
const expandToggle = (): HTMLElement => screen.getByRole("button", { name: /cost breakdown for / });
|
||||
|
||||
const shownBreakdown = (): HTMLElement | null => {
|
||||
const label = screen.queryByText("Total/Request");
|
||||
if (label === null) return null;
|
||||
return label.closest("[style*='display: none']") === null ? label : null;
|
||||
};
|
||||
|
||||
describe("MultiCostResults", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -200,40 +208,78 @@ describe("MultiCostResults", () => {
|
|||
expect(screen.getByRole("button", { name: /export/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a column header for each summary column", () => {
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Per Request" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Margin Fee" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Daily" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show the model breakdown before the row is expanded", () => {
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
expect(shownBreakdown()).toBeNull();
|
||||
});
|
||||
|
||||
it("should expand the model breakdown row when the expand button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
|
||||
// The expand column renders a button (RightOutlined icon) for rows without errors
|
||||
const expandButtons = screen.getAllByRole("button");
|
||||
// Find the small expand button (not the Export button)
|
||||
const expandButton = expandButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export"));
|
||||
expect(expandButton).toBeDefined();
|
||||
await user.click(expandToggle());
|
||||
|
||||
await user.click(expandButton!);
|
||||
|
||||
// After expanding, the SingleModelBreakdown should be visible
|
||||
expect(screen.getByText("Total/Request")).toBeInTheDocument();
|
||||
expect(shownBreakdown()).toBeVisible();
|
||||
expect(screen.getByText("Daily Total (100 req)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the collapse icon after expanding a row", async () => {
|
||||
it("should collapse the model breakdown again on a second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
|
||||
const getExpandButton = () => {
|
||||
const allButtons = screen.getAllByRole("button");
|
||||
return allButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export"));
|
||||
};
|
||||
await user.click(expandToggle());
|
||||
expect(shownBreakdown()).toBeVisible();
|
||||
|
||||
// Before expand: button has the "down" aria-label (RightOutlined renders as down in ant icons)
|
||||
// Just verify clicking works and the breakdown content appears
|
||||
await user.click(getExpandButton()!);
|
||||
expect(screen.getByText("Total/Request")).toBeInTheDocument();
|
||||
await user.click(expandToggle());
|
||||
expect(shownBreakdown()).toBeNull();
|
||||
});
|
||||
|
||||
// After a second click, the row collapses — content may be hidden or removed
|
||||
await user.click(getExpandButton()!);
|
||||
// The expanded content should no longer be visible
|
||||
expect(screen.queryByText("Total/Request")).not.toBeVisible();
|
||||
it("should name the breakdown toggle and report its expanded state", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiCostResults multiResult={makeMultiResult()} timePeriod="day" />);
|
||||
|
||||
const toggle = screen.getByRole("button", { name: "Show cost breakdown for gpt-4" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
const collapseToggle = screen.getByRole("button", { name: "Hide cost breakdown for gpt-4" });
|
||||
expect(collapseToggle).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("should not offer an expand toggle for a row that failed", () => {
|
||||
renderWithProviders(
|
||||
<MultiCostResults
|
||||
multiResult={makeMultiResult({
|
||||
entries: [
|
||||
{
|
||||
entry: { id: "e1", model: "gpt-4", input_tokens: 1000, output_tokens: 500 },
|
||||
result: makeCostResponse(),
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
entry: { id: "e2", model: "bad-model", input_tokens: 0, output_tokens: 0 },
|
||||
result: null,
|
||||
loading: false,
|
||||
error: "Pricing not found",
|
||||
},
|
||||
],
|
||||
})}
|
||||
timePeriod="day"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole("button", { name: /cost breakdown for / })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React, { useState } from "react";
|
||||
import { Text, Button } from "@tremor/react";
|
||||
import { Card, Statistic, Row, Col, Divider, Spin, Table, Tag } from "antd";
|
||||
import { LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { CostEstimateResponse } from "../types";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { MultiModelResult } from "./types";
|
||||
|
|
@ -41,55 +45,57 @@ const SingleModelBreakdown: React.FC<{
|
|||
<div className="space-y-3 bg-gray-50 p-4 rounded-lg">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="small" />
|
||||
<UiLoadingSpinner className="size-3.5" />
|
||||
<span>Updating...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Total/Request</Text>
|
||||
<Text className="text-base font-semibold text-blue-600">{formatCost(result.cost_per_request)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">Total/Request</p>
|
||||
<p className="text-base font-semibold text-blue-600 break-words">{formatCost(result.cost_per_request)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Input Cost</Text>
|
||||
<Text className="text-sm">{formatCost(result.input_cost_per_request)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">Input Cost</p>
|
||||
<p className="text-sm break-words">{formatCost(result.input_cost_per_request)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Output Cost</Text>
|
||||
<Text className="text-sm">{formatCost(result.output_cost_per_request)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">Output Cost</p>
|
||||
<p className="text-sm break-words">{formatCost(result.output_cost_per_request)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">Margin Fee</Text>
|
||||
<Text className={`text-sm ${result.margin_cost_per_request > 0 ? "text-amber-600" : ""}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">Margin Fee</p>
|
||||
<p className={`text-sm break-words ${result.margin_cost_per_request > 0 ? "text-amber-600" : ""}`}>
|
||||
{formatCost(result.margin_cost_per_request)}
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{periodCost !== null && (
|
||||
<div className="grid grid-cols-4 gap-4 pt-2 border-t border-gray-200">
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">
|
||||
{periodLabel} Total ({formatRequests(periodRequests)} req)
|
||||
</Text>
|
||||
<Text className={`text-base font-semibold ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}>
|
||||
</p>
|
||||
<p
|
||||
className={`text-base font-semibold break-words ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}
|
||||
>
|
||||
{formatCost(periodCost)}
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Input</Text>
|
||||
<Text className="text-sm">{formatCost(periodInputCost)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">{periodLabel} Input</p>
|
||||
<p className="text-sm break-words">{formatCost(periodInputCost)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Output</Text>
|
||||
<Text className="text-sm">{formatCost(periodOutputCost)}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">{periodLabel} Output</p>
|
||||
<p className="text-sm break-words">{formatCost(periodOutputCost)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs text-gray-500 block">{periodLabel} Margin Fee</Text>
|
||||
<Text className={`text-sm ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-500 block">{periodLabel} Margin Fee</p>
|
||||
<p className={`text-sm break-words ${(periodMarginCost ?? 0) > 0 ? "text-amber-600" : ""}`}>
|
||||
{formatCost(periodMarginCost)}
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -124,7 +130,7 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
|
|||
if (!hasAnyResult && !isAnyLoading && !hasAnyError) {
|
||||
return (
|
||||
<div className="py-6 text-center border border-dashed border-gray-300 rounded-lg bg-gray-50">
|
||||
<Text className="text-gray-500">Select models above to see cost estimates</Text>
|
||||
<p className="text-gray-500">Select models above to see cost estimates</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -133,8 +139,8 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
|
|||
if (!hasAnyResult && isAnyLoading && !hasAnyError) {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
<Text className="text-gray-500 block mt-2">Calculating costs...</Text>
|
||||
<UiLoadingSpinner className="inline-block size-5" />
|
||||
<p className="text-gray-500 block mt-2">Calculating costs...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -143,10 +149,10 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
|
|||
if (!hasAnyResult && hasAnyError) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Divider className="my-4" />
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
|
||||
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
<p className="text-base font-semibold text-gray-900">Cost Estimates</p>
|
||||
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
|
||||
</div>
|
||||
{/* Error Messages */}
|
||||
{errorEntries.map((e) => (
|
||||
|
|
@ -174,102 +180,10 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
|
|||
const hasMargin = multiResult.totals.margin_per_request > 0;
|
||||
|
||||
const periodLabel = timePeriod === "day" ? "Daily" : "Monthly";
|
||||
const periodCostKey = timePeriod === "day" ? "daily_cost" : "monthly_cost";
|
||||
|
||||
const summaryColumns = [
|
||||
{
|
||||
title: "Model",
|
||||
dataIndex: "model",
|
||||
key: "model",
|
||||
render: (
|
||||
text: string,
|
||||
record: {
|
||||
id: string;
|
||||
provider?: string | null;
|
||||
error?: string | null;
|
||||
loading?: boolean;
|
||||
hasZeroCost?: boolean | null;
|
||||
},
|
||||
) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{text}</span>
|
||||
{record.provider && (
|
||||
<Tag color="blue" className="text-xs">
|
||||
{record.provider}
|
||||
</Tag>
|
||||
)}
|
||||
{record.loading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
</div>
|
||||
{record.error && <div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded-sm">⚠️ {record.error}</div>}
|
||||
{record.hasZeroCost && !record.error && (
|
||||
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-sm">
|
||||
⚠️ No pricing data found for this model. Set base_model in config.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Per Request",
|
||||
dataIndex: "cost_per_request",
|
||||
key: "cost_per_request",
|
||||
align: "right" as const,
|
||||
render: (value: number | null, record: { error?: string | null }) =>
|
||||
record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Margin Fee",
|
||||
dataIndex: "margin_cost_per_request",
|
||||
key: "margin_cost_per_request",
|
||||
align: "right" as const,
|
||||
render: (value: number | null, record: { error?: string | null }) =>
|
||||
record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className={`font-mono text-sm ${(value ?? 0) > 0 ? "text-amber-600" : "text-gray-400"}`}>
|
||||
{formatCost(value)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: periodLabel,
|
||||
dataIndex: periodCostKey,
|
||||
key: "period_cost",
|
||||
align: "right" as const,
|
||||
render: (value: number | null, record: { error?: string | null }) =>
|
||||
record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "expand",
|
||||
width: 40,
|
||||
render: (_: unknown, record: { id: string; error?: string | null }) =>
|
||||
record.error ? null : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => toggleExpanded(record.id)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{expandedModels.has(record.id) ? <DownOutlined /> : <RightOutlined />}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Include both valid results and errors in the table data
|
||||
const allEntriesWithModels = multiResult.entries.filter((e) => e.entry.model);
|
||||
const summaryData = allEntriesWithModels.map((e) => ({
|
||||
key: e.entry.id,
|
||||
id: e.entry.id,
|
||||
model: e.result?.model || e.entry.model,
|
||||
provider: e.result?.provider,
|
||||
|
|
@ -284,78 +198,153 @@ const MultiCostResults: React.FC<MultiCostResultsProps> = ({ multiResult, timePe
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Divider className="my-4" />
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-base font-semibold text-gray-900">Cost Estimates</Text>
|
||||
<p className="text-base font-semibold text-gray-900">Cost Estimates</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAnyLoading && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
{isAnyLoading && <UiLoadingSpinner className="size-3.5" />}
|
||||
<MultiExportDropdown multiResult={multiResult} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Combined Totals - Always show when there are results */}
|
||||
<Card size="small" className="bg-linear-to-r from-slate-50 to-blue-50 border-slate-200">
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Statistic
|
||||
title={<span className="text-xs">Total Per Request</span>}
|
||||
value={formatCost(multiResult.totals.cost_per_request)}
|
||||
valueStyle={{ color: "#1890ff", fontSize: "18px", fontFamily: "monospace" }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Statistic
|
||||
title={<span className="text-xs">Total {periodLabel}</span>}
|
||||
value={formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)}
|
||||
valueStyle={{
|
||||
color: timePeriod === "day" ? "#52c41a" : "#722ed1",
|
||||
fontSize: "18px",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Card size="sm" className="px-4 bg-linear-to-r from-slate-50 to-blue-50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-gray-500">Total Per Request</span>
|
||||
<div className="text-lg font-mono text-blue-600 break-words">
|
||||
{formatCost(multiResult.totals.cost_per_request)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-gray-500">Total {periodLabel}</span>
|
||||
<div
|
||||
className={`text-lg font-mono break-words ${timePeriod === "day" ? "text-green-600" : "text-purple-600"}`}
|
||||
>
|
||||
{formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasMargin && (
|
||||
<Row gutter={[16, 8]} className="mt-3 pt-3 border-t border-slate-200">
|
||||
<Col xs={24} sm={12}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 mt-3 pt-3 border-t border-slate-200">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-500">Margin Fee/Request</div>
|
||||
<div className="text-sm font-mono text-amber-600">
|
||||
<div className="text-sm font-mono text-amber-600 break-words">
|
||||
{formatCost(multiResult.totals.margin_per_request)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-500">{periodLabel} Margin Fee</div>
|
||||
<div className="text-sm font-mono text-amber-600">
|
||||
<div className="text-sm font-mono text-amber-600 break-words">
|
||||
{formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Per-Model Table */}
|
||||
{summaryData.length > 0 && (
|
||||
<Table
|
||||
columns={summaryColumns}
|
||||
dataSource={summaryData}
|
||||
pagination={false}
|
||||
size="small"
|
||||
className="border border-gray-200 rounded-lg"
|
||||
expandable={{
|
||||
expandedRowKeys: Array.from(expandedModels),
|
||||
expandedRowRender: (record) => {
|
||||
const entry = validEntries.find((e) => e.entry.id === record.id);
|
||||
if (!entry?.result) return null;
|
||||
<Table className="border border-gray-200 rounded-lg">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead className="text-right">Per Request</TableHead>
|
||||
<TableHead className="text-right">Margin Fee</TableHead>
|
||||
<TableHead className="text-right">{periodLabel}</TableHead>
|
||||
<TableHead className="w-10">
|
||||
<span className="sr-only">Cost breakdown</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{summaryData.map((record) => {
|
||||
const isExpanded = expandedModels.has(record.id);
|
||||
const periodCost = timePeriod === "day" ? record.daily_cost : record.monthly_cost;
|
||||
const breakdownEntry = validEntries.find((e) => e.entry.id === record.id);
|
||||
return (
|
||||
<div className="py-2">
|
||||
<SingleModelBreakdown result={entry.result} loading={entry.loading} timePeriod={timePeriod} />
|
||||
</div>
|
||||
<React.Fragment key={record.id}>
|
||||
<TableRow>
|
||||
<TableCell className="whitespace-normal">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm break-words">{record.model}</span>
|
||||
{record.provider && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{record.provider}
|
||||
</Badge>
|
||||
)}
|
||||
{record.loading && <UiLoadingSpinner className="size-3.5" />}
|
||||
</div>
|
||||
{record.error && (
|
||||
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded-sm">⚠️ {record.error}</div>
|
||||
)}
|
||||
{record.hasZeroCost && !record.error && (
|
||||
<div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded-sm">
|
||||
⚠️ No pricing data found for this model. Set base_model in config.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(record.cost_per_request)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span
|
||||
className={`font-mono text-sm ${(record.margin_cost_per_request ?? 0) > 0 ? "text-amber-600" : "text-gray-400"}`}
|
||||
>
|
||||
{formatCost(record.margin_cost_per_request)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.error ? (
|
||||
<span className="text-gray-400">-</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{formatCost(periodCost)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!record.error && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? "Hide" : "Show"} cost breakdown for ${record.model}`}
|
||||
onClick={() => toggleExpanded(record.id)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && breakdownEntry?.result && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="whitespace-normal">
|
||||
<div className="py-2">
|
||||
<SingleModelBreakdown
|
||||
result={breakdownEntry.result}
|
||||
loading={breakdownEntry.loading}
|
||||
timePeriod={timePeriod}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
},
|
||||
showExpandColumn: false,
|
||||
}}
|
||||
/>
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,49 +5,21 @@ import userEvent from "@testing-library/user-event";
|
|||
import { renderWithProviders } from "../../../../../tests/test-utils";
|
||||
import ProviderDiscountTable from "./provider_discount_table";
|
||||
|
||||
vi.mock("@heroicons/react/outline", () => ({
|
||||
TrashIcon: function TrashIcon() {
|
||||
return null;
|
||||
},
|
||||
PencilAltIcon: function PencilAltIcon() {
|
||||
return null;
|
||||
},
|
||||
CheckIcon: function CheckIcon() {
|
||||
return null;
|
||||
},
|
||||
XIcon: function XIcon() {
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
Table: ({ children }: any) => <table>{children}</table>,
|
||||
TableHead: ({ children }: any) => <thead>{children}</thead>,
|
||||
TableRow: ({ children }: any) => <tr>{children}</tr>,
|
||||
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
|
||||
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
|
||||
TableCell: ({ children }: any) => <td>{children}</td>,
|
||||
Text: ({ children }: any) => <span>{children}</span>,
|
||||
TextInput: ({ value, onValueChange, onKeyDown, placeholder, ...rest }: any) => (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onValueChange?.(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
),
|
||||
Icon: ({ icon: IconComponent, onClick }: any) => {
|
||||
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
|
||||
return <button onClick={onClick} aria-label={name} />;
|
||||
},
|
||||
}));
|
||||
|
||||
const DEFAULT_DISCOUNT_CONFIG = {
|
||||
openai: 0.05,
|
||||
anthropic: 0.1,
|
||||
};
|
||||
|
||||
const ROW_ACTION_NAME = {
|
||||
edit: /^Edit discount for /,
|
||||
save: /^Save discount for /,
|
||||
cancel: /^Cancel editing discount for /,
|
||||
remove: /^Remove discount for /,
|
||||
} as const;
|
||||
|
||||
const rowAction = (action: keyof typeof ROW_ACTION_NAME): HTMLElement =>
|
||||
screen.getByRole("button", { name: ROW_ACTION_NAME[action] });
|
||||
|
||||
describe("ProviderDiscountTable", () => {
|
||||
const onDiscountChange = vi.fn();
|
||||
const onRemoveProvider = vi.fn();
|
||||
|
|
@ -75,9 +47,9 @@ describe("ProviderDiscountTable", () => {
|
|||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("Discount Percentage")).toBeInTheDocument();
|
||||
expect(screen.getByText("Actions")).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Discount Percentage" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display provider display names in the table", () => {
|
||||
|
|
@ -91,6 +63,21 @@ describe("ProviderDiscountTable", () => {
|
|||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should sort rows by provider display name", () => {
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={DEFAULT_DISCOUNT_CONFIG}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
expect.stringContaining("Anthropic"),
|
||||
expect.stringContaining("OpenAI"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should display the formatted discount percentage", () => {
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
|
|
@ -102,6 +89,17 @@ describe("ProviderDiscountTable", () => {
|
|||
expect(screen.getByText("5.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the provider logo alongside the display name", () => {
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "OpenAI logo" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a text input when the edit icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -112,8 +110,7 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const pencilButton = screen.getByRole("button", { name: /PencilAltIcon/i });
|
||||
await user.click(pencilButton);
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.getByPlaceholderText("5")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -128,11 +125,26 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.queryByText("5.0%")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should seed the edit input with the current discount as a percentage", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.getByPlaceholderText("5")).toHaveValue("5");
|
||||
});
|
||||
|
||||
it("should call onDiscountChange with the new value when the save icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -143,17 +155,57 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const input = screen.getByPlaceholderText("5");
|
||||
await user.clear(input);
|
||||
await user.type(input, "10");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
|
||||
});
|
||||
|
||||
it("should save the edited discount when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const input = screen.getByPlaceholderText("5");
|
||||
await user.clear(input);
|
||||
await user.type(input, "10{Enter}");
|
||||
|
||||
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
|
||||
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should abandon the edit when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const input = screen.getByPlaceholderText("5");
|
||||
await user.clear(input);
|
||||
await user.type(input, "10{Escape}");
|
||||
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("5.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should restore the display view after saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -164,8 +216,8 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -180,30 +232,14 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: /XIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
await user.click(rowAction("cancel"));
|
||||
|
||||
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("5.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call onDiscountChange when canceling edit", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: /XIcon/i }));
|
||||
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onRemoveProvider with the provider key and display name when the trash icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -214,7 +250,7 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(rowAction("remove"));
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
|
||||
});
|
||||
|
|
@ -229,12 +265,42 @@ describe("ProviderDiscountTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
const input = screen.getByPlaceholderText("5");
|
||||
await user.clear(input);
|
||||
await user.type(input, "150");
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onDiscountChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should expose each row action as a button named for its provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{ openai: 0.05 }}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Edit discount for OpenAI" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove discount for OpenAI" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit discount for OpenAI" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Save discount for OpenAI" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel editing discount for OpenAI" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the empty message when no discounts are configured", () => {
|
||||
renderWithProviders(
|
||||
<ProviderDiscountTable
|
||||
discountConfig={{}}
|
||||
onDiscountChange={onDiscountChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No provider discounts configured")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from "react";
|
||||
import { TextInput, Icon, Text } from "@tremor/react";
|
||||
import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { Check, SquarePen, Trash2, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SimpleTable } from "@/components/common_components/simple_table";
|
||||
import { DiscountConfig } from "./types";
|
||||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
|
|
@ -79,45 +80,57 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Discount Percentage",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<TextInput
|
||||
value={editValue}
|
||||
onValueChange={setEditValue}
|
||||
onKeyDown={(e) => handleKeyDown(e, row.provider)}
|
||||
placeholder="5"
|
||||
className="w-20"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<Icon
|
||||
icon={CheckIcon}
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(row.provider)}
|
||||
className="cursor-pointer text-green-600 hover:text-green-700"
|
||||
/>
|
||||
<Icon
|
||||
icon={XIcon}
|
||||
size="sm"
|
||||
onClick={handleCancelEdit}
|
||||
className="cursor-pointer text-gray-600 hover:text-gray-700"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className="font-medium">{(row.discount * 100).toFixed(1)}%</Text>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => handleStartEdit(row.provider, row.discount)}
|
||||
className="cursor-pointer text-blue-600 hover:text-blue-700"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: (row) => {
|
||||
const { displayName } = getProviderLogoAndName(row.provider);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, row.provider)}
|
||||
placeholder="5"
|
||||
className="w-20"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Save discount for ${displayName}`}
|
||||
onClick={() => handleSaveEdit(row.provider)}
|
||||
className="cursor-pointer text-green-600 hover:text-green-700"
|
||||
>
|
||||
<Check className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Cancel editing discount for ${displayName}`}
|
||||
onClick={handleCancelEdit}
|
||||
className="cursor-pointer text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">{(row.discount * 100).toFixed(1)}%</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit discount for ${displayName}`}
|
||||
onClick={() => handleStartEdit(row.provider, row.discount)}
|
||||
className="cursor-pointer text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: "250px",
|
||||
},
|
||||
{
|
||||
|
|
@ -125,12 +138,15 @@ const ProviderDiscountTable: React.FC<ProviderDiscountTableProps> = ({
|
|||
cell: (row) => {
|
||||
const { displayName } = getProviderLogoAndName(row.provider);
|
||||
return (
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove discount for ${displayName}`}
|
||||
onClick={() => onRemoveProvider(row.provider, displayName)}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
width: "80px",
|
||||
|
|
|
|||
|
|
@ -6,43 +6,15 @@ import { renderWithProviders } from "../../../../../tests/test-utils";
|
|||
import ProviderMarginTable from "./provider_margin_table";
|
||||
import { Providers, providerLogoMap } from "@/components/provider_info_helpers";
|
||||
|
||||
vi.mock("@heroicons/react/outline", () => ({
|
||||
TrashIcon: function TrashIcon() {
|
||||
return null;
|
||||
},
|
||||
PencilAltIcon: function PencilAltIcon() {
|
||||
return null;
|
||||
},
|
||||
CheckIcon: function CheckIcon() {
|
||||
return null;
|
||||
},
|
||||
XIcon: function XIcon() {
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
const ROW_ACTION_NAME = {
|
||||
edit: /^Edit margin for /,
|
||||
save: /^Save margin for /,
|
||||
cancel: /^Cancel editing margin for /,
|
||||
remove: /^Remove margin for /,
|
||||
} as const;
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
Table: ({ children }: any) => <table>{children}</table>,
|
||||
TableHead: ({ children }: any) => <thead>{children}</thead>,
|
||||
TableRow: ({ children }: any) => <tr>{children}</tr>,
|
||||
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
|
||||
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
|
||||
TableCell: ({ children }: any) => <td>{children}</td>,
|
||||
Text: ({ children }: any) => <span>{children}</span>,
|
||||
TextInput: ({ value, onValueChange, placeholder, autoFocus, className }: any) => (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onValueChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
className={className}
|
||||
/>
|
||||
),
|
||||
Icon: ({ icon: IconComponent, onClick }: any) => {
|
||||
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
|
||||
return <button onClick={onClick} aria-label={name} />;
|
||||
},
|
||||
}));
|
||||
const rowAction = (action: keyof typeof ROW_ACTION_NAME): HTMLElement =>
|
||||
screen.getByRole("button", { name: ROW_ACTION_NAME[action] });
|
||||
|
||||
describe("ProviderMarginTable", () => {
|
||||
const onMarginChange = vi.fn();
|
||||
|
|
@ -71,9 +43,9 @@ describe("ProviderMarginTable", () => {
|
|||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("Margin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Actions")).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Margin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Actions" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the provider display name", () => {
|
||||
|
|
@ -122,6 +94,21 @@ describe("ProviderMarginTable", () => {
|
|||
expect(screen.getByText("Global (All Providers)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should sort the global row above provider rows", () => {
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1, global: 0.05 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
expect.stringContaining("Global (All Providers)"),
|
||||
expect.stringContaining("OpenAI"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should display a numeric margin as a percentage", () => {
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
|
|
@ -165,12 +152,28 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.getByPlaceholderText("10")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("0.001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should seed the percentage input from a numeric margin and leave the fixed amount blank", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
expect(screen.getByPlaceholderText("10")).toHaveValue("10");
|
||||
expect(screen.getByPlaceholderText("0.001")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should call onMarginChange with a percentage value when save is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -181,17 +184,37 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const percentInput = screen.getByPlaceholderText("10");
|
||||
await user.clear(percentInput);
|
||||
await user.type(percentInput, "20");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onMarginChange).toHaveBeenCalledWith("openai", 0.2);
|
||||
});
|
||||
|
||||
it("should call onMarginChange with a fixed-amount-only object when the percentage is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("10"));
|
||||
await user.type(screen.getByPlaceholderText("0.001"), "0.002");
|
||||
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onMarginChange).toHaveBeenCalledWith("openai", { fixed_amount: 0.002 });
|
||||
});
|
||||
|
||||
it("should cancel edit mode without calling onMarginChange when X is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
|
|
@ -202,8 +225,8 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(screen.getByRole("button", { name: /XIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
await user.click(rowAction("cancel"));
|
||||
|
||||
expect(onMarginChange).not.toHaveBeenCalled();
|
||||
expect(screen.queryByPlaceholderText("10")).not.toBeInTheDocument();
|
||||
|
|
@ -219,7 +242,7 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(rowAction("remove"));
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
|
||||
});
|
||||
|
|
@ -234,11 +257,50 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(rowAction("remove"));
|
||||
|
||||
expect(onRemoveProvider).toHaveBeenCalledWith("global", "Global");
|
||||
});
|
||||
|
||||
it("should expose each row action as a button named for its provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ openai: 0.1 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Edit margin for OpenAI" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove margin for OpenAI" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit margin for OpenAI" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Save margin for OpenAI" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel editing margin for OpenAI" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should name the global row's actions after the global provider", () => {
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable
|
||||
marginConfig={{ global: 0.05 }}
|
||||
onMarginChange={onMarginChange}
|
||||
onRemoveProvider={onRemoveProvider}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Edit margin for Global" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove margin for Global" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the empty message when no margins are configured", () => {
|
||||
renderWithProviders(
|
||||
<ProviderMarginTable marginConfig={{}} onMarginChange={onMarginChange} onRemoveProvider={onRemoveProvider} />,
|
||||
);
|
||||
expect(screen.getByText("No provider margins configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("when both percentage and fixed amount are entered", () => {
|
||||
it("should call onMarginChange with an object containing both values", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
|
@ -250,7 +312,7 @@ describe("ProviderMarginTable", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
|
||||
await user.click(rowAction("edit"));
|
||||
|
||||
const percentInput = screen.getByPlaceholderText("10");
|
||||
await user.clear(percentInput);
|
||||
|
|
@ -259,7 +321,7 @@ describe("ProviderMarginTable", () => {
|
|||
const fixedInput = screen.getByPlaceholderText("0.001");
|
||||
await user.type(fixedInput, "0.002");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
|
||||
await user.click(rowAction("save"));
|
||||
|
||||
expect(onMarginChange).toHaveBeenCalledWith("openai", {
|
||||
percentage: 0.05,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from "react";
|
||||
import { TextInput, Icon, Text } from "@tremor/react";
|
||||
import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { Check, SquarePen, Trash2, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SimpleTable } from "@/components/common_components/simple_table";
|
||||
import { MarginConfig } from "./types";
|
||||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
|
|
@ -17,6 +18,9 @@ interface ProviderMarginRow {
|
|||
margin: number | { percentage?: number; fixed_amount?: number };
|
||||
}
|
||||
|
||||
const marginRowDisplayName = (provider: string): string =>
|
||||
provider === "global" ? "Global" : getProviderLogoAndName(provider).displayName;
|
||||
|
||||
const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
|
||||
marginConfig,
|
||||
onMarginChange,
|
||||
|
|
@ -119,67 +123,82 @@ const ProviderMarginTable: React.FC<ProviderMarginTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Margin",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<TextInput
|
||||
value={editPercentage}
|
||||
onValueChange={setEditPercentage}
|
||||
placeholder="10"
|
||||
className="w-20"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<span className="text-gray-400">+</span>
|
||||
<span className="text-gray-600">$</span>
|
||||
<TextInput
|
||||
value={editFixedAmount}
|
||||
onValueChange={setEditFixedAmount}
|
||||
placeholder="0.001"
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<Icon
|
||||
icon={CheckIcon}
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(row.provider)}
|
||||
className="cursor-pointer text-green-600 hover:text-green-700"
|
||||
/>
|
||||
<Icon
|
||||
icon={XIcon}
|
||||
size="sm"
|
||||
onClick={handleCancelEdit}
|
||||
className="cursor-pointer text-gray-600 hover:text-gray-700"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className="font-medium">{formatMargin(row.margin)}</Text>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => handleStartEdit(row.provider, row.margin)}
|
||||
className="cursor-pointer text-blue-600 hover:text-blue-700"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: (row) => {
|
||||
const displayName = marginRowDisplayName(row.provider);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={editPercentage}
|
||||
onChange={(e) => setEditPercentage(e.target.value)}
|
||||
placeholder="10"
|
||||
className="w-20"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<span className="text-gray-400">+</span>
|
||||
<span className="text-gray-600">$</span>
|
||||
<Input
|
||||
value={editFixedAmount}
|
||||
onChange={(e) => setEditFixedAmount(e.target.value)}
|
||||
placeholder="0.001"
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Save margin for ${displayName}`}
|
||||
onClick={() => handleSaveEdit(row.provider)}
|
||||
className="cursor-pointer text-green-600 hover:text-green-700"
|
||||
>
|
||||
<Check className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Cancel editing margin for ${displayName}`}
|
||||
onClick={handleCancelEdit}
|
||||
className="cursor-pointer text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">{formatMargin(row.margin)}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit margin for ${displayName}`}
|
||||
onClick={() => handleStartEdit(row.provider, row.margin)}
|
||||
className="cursor-pointer text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: "350px",
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
cell: (row) => {
|
||||
const displayName = row.provider === "global" ? "Global" : getProviderLogoAndName(row.provider).displayName;
|
||||
const displayName = marginRowDisplayName(row.provider);
|
||||
return (
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove margin for ${displayName}`}
|
||||
onClick={() => onRemoveProvider(row.provider, displayName)}
|
||||
className="cursor-pointer hover:text-red-600"
|
||||
/>
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
width: "80px",
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ describe("UserInfoView", () => {
|
|||
});
|
||||
|
||||
// The DeleteResourceModal's OK button has text "Delete" - find it within the modal
|
||||
const modal = screen.getByText("Remove from Team").closest(".ant-modal") as HTMLElement;
|
||||
const modal = screen.getByRole("dialog", { name: "Remove from Team" });
|
||||
const deleteConfirmButton = within(modal).getByRole("button", { name: /delete/i });
|
||||
await user.click(deleteConfirmButton);
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,14 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Input, Select } from "antd";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { Inbox, Search, X } from "lucide-react";
|
||||
import { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { getSkillHubTableColumns } from "@/components/AIHub/SkillHubTableColumns";
|
||||
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const ALL_DOMAINS = "__all_domains__";
|
||||
|
||||
interface SkillHubDashboardProps {
|
||||
skills: Plugin[];
|
||||
|
|
@ -48,7 +50,10 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
|
||||
// Derived stats
|
||||
const totalSkills = skills.length;
|
||||
const domains = useMemo(() => [...new Set(skills.map((s) => s.domain).filter(Boolean))], [skills]);
|
||||
const domains = useMemo(
|
||||
() => [...new Set(skills.map((s) => s.domain).filter((domain): domain is string => Boolean(domain)))],
|
||||
[skills],
|
||||
);
|
||||
const namespaces = useMemo(() => [...new Set(skills.map((s) => s.namespace).filter(Boolean))], [skills]);
|
||||
|
||||
// Filtered table data
|
||||
|
|
@ -73,6 +78,11 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
|
||||
const columns = useMemo(() => getSkillHubTableColumns({ onSkillClick: setSelectedSkill }), []);
|
||||
|
||||
const domainItems = useMemo(
|
||||
() => [{ value: ALL_DOMAINS, label: "All Domains" }, ...domains.map((d) => ({ value: d, label: d }))],
|
||||
[domains],
|
||||
);
|
||||
|
||||
const hasActiveFilter = search.trim().length > 0 || domainFilter != null;
|
||||
|
||||
if (selectedSkill) {
|
||||
|
|
@ -111,21 +121,43 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
<h3 className="text-sm font-semibold text-gray-700">All {publicPage ? "Public " : ""}Skills</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
placeholder="All Domains"
|
||||
allowClear
|
||||
value={domainFilter}
|
||||
onChange={(val) => setDomainFilter(val)}
|
||||
style={{ width: 160 }}
|
||||
options={domains.map((d) => ({ label: d, value: d }))}
|
||||
/>
|
||||
<Input
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
placeholder="Search by name, namespace, or tag…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 280 }}
|
||||
allowClear
|
||||
/>
|
||||
items={domainItems}
|
||||
value={domainFilter ?? ALL_DOMAINS}
|
||||
onValueChange={(val) => setDomainFilter(val === null || val === ALL_DOMAINS ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domainItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputGroup className="w-[280px]">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search by name, namespace, or tag…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search !== "" && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import TableIconActionButton from "@/components/common_components/IconActionButt
|
|||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { ChevronDownIcon, ChevronRightIcon, ExternalLinkIcon, PlusCircleIcon } from "@heroicons/react/outline";
|
||||
import { Card, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text, Title } from "@tremor/react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getProxyBaseUrl, getPublicModelHubInfo, updateUsefulLinksCall } from "../networking";
|
||||
|
|
@ -223,10 +224,10 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
|
|||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<Card className="mb-6 px-6">
|
||||
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<div className="flex flex-col">
|
||||
<Title className="mb-0">Link Management</Title>
|
||||
<h3 className="mb-0 text-lg font-semibold">Link Management</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Manage the links that are displayed under 'Useful Links' on the public model hub.
|
||||
</p>
|
||||
|
|
@ -243,7 +244,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
|
|||
{isExpanded && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-6">
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Link</Text>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Add New Link</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Display Name</label>
|
||||
|
|
@ -288,7 +289,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Text className="text-sm font-medium text-gray-700">Manage Existing Links</Text>
|
||||
<p className="text-sm font-medium text-gray-700">Manage Existing Links</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link
|
||||
href={`${getProxyBaseUrl()}/ui/model_hub_table`}
|
||||
|
|
@ -328,13 +329,13 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
|
|||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
<TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="py-1 h-8">Display Name</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">URL</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">Actions</TableHeaderCell>
|
||||
<TableHead className="py-1 h-8">Display Name</TableHead>
|
||||
<TableHead className="py-1 h-8">URL</TableHead>
|
||||
<TableHead className="py-1 h-8">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{links.map((link, index) => (
|
||||
<TableRow key={link.id} className="h-8">
|
||||
|
|
|
|||
|
|
@ -12,67 +12,8 @@ vi.mock("../../networking", () => ({
|
|||
import { makeAgentsPublicCall } from "../../networking";
|
||||
const mockMakeAgentsPublicCall = vi.mocked(makeAgentsPublicCall);
|
||||
|
||||
// Mock antd components
|
||||
vi.mock("antd", () => ({
|
||||
Modal: ({ open, title, children, onCancel, footer }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div>{title}</div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
Form: Object.assign(({ children, form }: any) => <form data-testid="form">{children}</form>, {
|
||||
useForm: () => [
|
||||
{
|
||||
resetFields: vi.fn(),
|
||||
validateFields: vi.fn(),
|
||||
getFieldsValue: vi.fn(),
|
||||
setFieldsValue: vi.fn(),
|
||||
},
|
||||
vi.fn(),
|
||||
],
|
||||
Item: ({ children }: any) => <div>{children}</div>,
|
||||
}),
|
||||
Steps: Object.assign(
|
||||
({ children, current, className }: any) => (
|
||||
<div data-testid="steps" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
{
|
||||
Step: ({ title }: any) => <div>{title}</div>,
|
||||
},
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, ...props }: any) => (
|
||||
<button onClick={onClick} disabled={disabled || loading} data-loading={loading} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
|
||||
disabled={disabled}
|
||||
data-indeterminate={indeterminate}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock @tremor/react components
|
||||
vi.mock("@tremor/react", () => ({
|
||||
Text: ({ children, className }: any) => <span className={className}>{children}</span>,
|
||||
Title: ({ children }: any) => <h3>{children}</h3>,
|
||||
Badge: ({ children, color, size }: any) => (
|
||||
<span data-color={color} data-size={size}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
const expectDisabledControl = (element: HTMLElement) =>
|
||||
expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true);
|
||||
|
||||
describe("MakeAgentPublicForm", () => {
|
||||
const mockProps = {
|
||||
|
|
@ -143,7 +84,7 @@ describe("MakeAgentPublicForm", () => {
|
|||
expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument();
|
||||
|
||||
// Select all agents using the select all checkbox
|
||||
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
|
||||
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
|
||||
await act(async () => {
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
});
|
||||
|
|
@ -169,12 +110,11 @@ describe("MakeAgentPublicForm", () => {
|
|||
render(<MakeAgentPublicForm {...mockProps} />);
|
||||
|
||||
// Select all agents
|
||||
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
|
||||
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
|
||||
await act(async () => {
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
});
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
@ -185,7 +125,6 @@ describe("MakeAgentPublicForm", () => {
|
|||
expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: "Make Public" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
|
@ -232,6 +171,8 @@ describe("MakeAgentPublicForm", () => {
|
|||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
await act(async () => {
|
||||
fireEvent.click(checkboxes[0]); // Click select all to select all
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(checkboxes[0]); // Click select all again to deselect all
|
||||
});
|
||||
|
||||
|
|
@ -256,8 +197,8 @@ describe("MakeAgentPublicForm", () => {
|
|||
expect(screen.getByText("No agents available.")).toBeInTheDocument();
|
||||
|
||||
// Select All checkbox should be disabled
|
||||
const selectAllCheckbox = screen.getByLabelText("Select All");
|
||||
expect(selectAllCheckbox).toBeDisabled();
|
||||
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" });
|
||||
expectDisabledControl(selectAllCheckbox);
|
||||
|
||||
// Next button should be disabled
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
|
|
@ -332,7 +273,7 @@ describe("MakeAgentPublicForm", () => {
|
|||
|
||||
// Select all should be indeterminate now
|
||||
const selectAllCheckbox = checkboxes[0];
|
||||
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
|
||||
expect(selectAllCheckbox).toBePartiallyChecked();
|
||||
});
|
||||
|
||||
it("should display skills overflow text when agent has more than 3 skills", () => {
|
||||
|
|
@ -369,7 +310,6 @@ describe("MakeAgentPublicForm", () => {
|
|||
|
||||
render(<MakeAgentPublicForm {...mockProps} />);
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
@ -379,7 +319,6 @@ describe("MakeAgentPublicForm", () => {
|
|||
expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: "Make Public" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
|
@ -395,7 +334,7 @@ describe("MakeAgentPublicForm", () => {
|
|||
expect(mockProps.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show loading state during submit", async () => {
|
||||
it("should not complete the flow until the submit request resolves", async () => {
|
||||
let resolvePromise: (value: any) => void = () => {};
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
|
|
@ -404,7 +343,6 @@ describe("MakeAgentPublicForm", () => {
|
|||
|
||||
render(<MakeAgentPublicForm {...mockProps} />);
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
@ -414,17 +352,20 @@ describe("MakeAgentPublicForm", () => {
|
|||
expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: "Make Public" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
// Check loading state
|
||||
expect(submitButton).toHaveAttribute("data-loading", "true");
|
||||
expect(submitButton).toBeDisabled();
|
||||
expectDisabledControl(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
expect(mockMakeAgentsPublicCall).toHaveBeenCalledTimes(1);
|
||||
expect(mockProps.onSuccess).not.toHaveBeenCalled();
|
||||
expect(mockProps.onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise({});
|
||||
await waitFor(() => {
|
||||
expect(mockProps.onSuccess).toHaveBeenCalled();
|
||||
|
|
@ -441,7 +382,7 @@ describe("MakeAgentPublicForm", () => {
|
|||
render(<MakeAgentPublicForm {...invisibleProps} />);
|
||||
|
||||
// Modal should not be rendered
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Make Agents Public")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -500,6 +441,6 @@ describe("MakeAgentPublicForm", () => {
|
|||
|
||||
// Select all should be indeterminate
|
||||
const selectAllCheckbox = checkboxes[0];
|
||||
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
|
||||
expect(selectAllCheckbox).toBePartiallyChecked();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Form, Steps, Button, Checkbox } from "antd";
|
||||
import { Text, Title, Badge } from "@tremor/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { makeAgentsPublicCall } from "../../networking";
|
||||
import NotificationsManager from "../../molecules/notifications_manager";
|
||||
import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns";
|
||||
|
||||
const { Step } = Steps;
|
||||
const STEP_TITLES = ["Select Agents", "Confirm"];
|
||||
|
||||
interface MakeAgentPublicFormProps {
|
||||
visible: boolean;
|
||||
|
|
@ -25,12 +29,10 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
|
|||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0);
|
||||
setSelectedAgents(new Set());
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -113,29 +115,30 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Title>Select Agents to Make Public</Title>
|
||||
<h3 className="text-lg font-semibold">Select Agents to Make Public</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={allAgentsSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
disabled={agentHubData.length === 0}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={allAgentsSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onCheckedChange={(checked) => handleSelectAll(checked === true)}
|
||||
disabled={agentHubData.length === 0}
|
||||
/>
|
||||
Select All {agentHubData.length > 0 && `(${agentHubData.length})`}
|
||||
</Checkbox>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600">
|
||||
Select the agents you want to be visible on the public model hub. Users will still require a valid Virtual Key
|
||||
to use these agents.
|
||||
</Text>
|
||||
</p>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
{agentHubData.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Text>No agents available.</Text>
|
||||
<p>No agents available.</p>
|
||||
</div>
|
||||
) : (
|
||||
agentHubData.map((agent) => {
|
||||
|
|
@ -144,25 +147,23 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
|
|||
<div key={agentId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50">
|
||||
<Checkbox
|
||||
checked={selectedAgents.has(agentId)}
|
||||
onChange={(e) => handleAgentSelection(agentId, e.target.checked)}
|
||||
onCheckedChange={(checked) => handleAgentSelection(agentId, checked === true)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Text className="font-medium">{agent.name}</Text>
|
||||
<Badge color="blue" size="sm">
|
||||
v{agent.version}
|
||||
</Badge>
|
||||
<p className="font-medium break-words">{agent.name}</p>
|
||||
<Badge variant="secondary">v{agent.version}</Badge>
|
||||
</div>
|
||||
<Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>
|
||||
<p className="text-xs text-gray-600 mt-1 break-words">{agent.description}</p>
|
||||
{agent.skills && agent.skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{agent.skills.slice(0, 3).map((skill) => (
|
||||
<Badge key={skill.id} color="purple" size="xs">
|
||||
<Badge key={skill.id} variant="outline">
|
||||
{skill.name}
|
||||
</Badge>
|
||||
))}
|
||||
{agent.skills.length > 3 && (
|
||||
<Text className="text-xs text-gray-500">+{agent.skills.length - 3} more</Text>
|
||||
<p className="text-xs text-gray-500">+{agent.skills.length - 3} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -176,9 +177,9 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
|
|||
|
||||
{selectedAgents.size > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-sm text-blue-800">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} selected
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -188,33 +189,31 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
|
|||
const renderStep2Content = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Title>Confirm Making Agents Public</Title>
|
||||
<h3 className="text-lg font-semibold">Confirm Making Agents Public</h3>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<Text className="text-sm text-yellow-800">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Warning:</strong> Once you make these agents public, anyone who can go to the{" "}
|
||||
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Text className="font-medium">Agents to be made public:</Text>
|
||||
<p className="font-medium">Agents to be made public:</p>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
|
||||
<div className="space-y-2">
|
||||
{Array.from(selectedAgents).map((agentId) => {
|
||||
const agent = agentHubData.find((a) => (a.agent_id || a.name) === agentId);
|
||||
return (
|
||||
<div key={agentId} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Text className="font-medium">{agent?.name || agentId}</Text>
|
||||
{agent && (
|
||||
<Badge color="blue" size="xs">
|
||||
v{agent.version}
|
||||
</Badge>
|
||||
)}
|
||||
<p className="font-medium break-words">{agent?.name || agentId}</p>
|
||||
{agent && <Badge variant="secondary">v{agent.version}</Badge>}
|
||||
</div>
|
||||
{agent?.description && <Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>}
|
||||
{agent?.description && (
|
||||
<p className="text-xs text-gray-600 mt-1 break-words">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -224,10 +223,10 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-sm text-blue-800">
|
||||
<p className="text-sm text-blue-800">
|
||||
Total: <strong>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} will be made
|
||||
public
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -247,7 +246,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
|
|||
const renderStepButtons = () => {
|
||||
return (
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
|
||||
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
|
||||
{currentStep === 0 ? "Cancel" : "Previous"}
|
||||
</Button>
|
||||
|
||||
|
|
@ -259,7 +258,8 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
|
|||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Make Public
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -269,24 +269,42 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Make Agents Public"
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={1200}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Steps current={currentStep} className="mb-6">
|
||||
<Step title="Select Agents" />
|
||||
<Step title="Confirm" />
|
||||
</Steps>
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Make Agents Public</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{renderStepContent()}
|
||||
{renderStepButtons()}
|
||||
</Form>
|
||||
</Modal>
|
||||
<div>
|
||||
<ol className="mb-6 flex items-center gap-6">
|
||||
{STEP_TITLES.map((title, index) => (
|
||||
<li
|
||||
key={title}
|
||||
className="flex items-center gap-2"
|
||||
aria-current={currentStep === index ? "step" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded-full border text-xs",
|
||||
currentStep === index
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
|
||||
{title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{renderStepContent()}
|
||||
{renderStepButtons()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,83 +12,8 @@ vi.mock("../../networking", () => ({
|
|||
import { makeMCPPublicCall } from "../../networking";
|
||||
const mockMakeMCPPublicCall = vi.mocked(makeMCPPublicCall);
|
||||
|
||||
// Mock antd components
|
||||
vi.mock("antd", () => ({
|
||||
Modal: ({ open, title, children, onCancel, footer }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div>{title}</div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
Form: Object.assign(({ children, form }: any) => <form data-testid="form">{children}</form>, {
|
||||
useForm: () => [
|
||||
{
|
||||
resetFields: vi.fn(),
|
||||
validateFields: vi.fn(),
|
||||
getFieldsValue: vi.fn(),
|
||||
setFieldsValue: vi.fn(),
|
||||
},
|
||||
vi.fn(),
|
||||
],
|
||||
Item: ({ children }: any) => <div>{children}</div>,
|
||||
}),
|
||||
Steps: Object.assign(
|
||||
({ children, current, className }: any) => (
|
||||
<div data-testid="steps" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
{
|
||||
Step: ({ title }: any) => <div>{title}</div>,
|
||||
},
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, ...props }: any) => (
|
||||
<button onClick={onClick} disabled={disabled || loading} data-loading={loading} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
|
||||
disabled={disabled}
|
||||
data-indeterminate={indeterminate}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
// Additional @tremor/react mocks.
|
||||
// NOTE: the comment used to say "Button is already mocked globally" — that was
|
||||
// incorrect. A file-level vi.mock fully replaces the setup-level mock from
|
||||
// tests/setupTests.ts, so we must re-apply the Button/Tooltip overrides here.
|
||||
// Without them, the real Tremor Button leaks through and its useTooltip(300)
|
||||
// schedules a native setTimeout that can fire post-teardown -> "window is not defined".
|
||||
vi.mock("@tremor/react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tremor/react")>();
|
||||
const React = await import("react");
|
||||
return {
|
||||
...actual,
|
||||
Text: ({ children, className }: any) => <span className={className}>{children}</span>,
|
||||
Title: ({ children }: any) => <h3>{children}</h3>,
|
||||
Badge: ({ children, color, size }: any) => (
|
||||
<span data-color={color} data-size={size}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) => (
|
||||
<button {...props} ref={ref}>
|
||||
{children}
|
||||
</button>
|
||||
)),
|
||||
Tooltip: ({ children }: any) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
const expectDisabledControl = (element: HTMLElement) =>
|
||||
expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true);
|
||||
|
||||
describe("MakeMCPPublicForm", () => {
|
||||
const mockProps = {
|
||||
|
|
@ -182,7 +107,7 @@ describe("MakeMCPPublicForm", () => {
|
|||
expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
|
||||
|
||||
// Select all servers using the select all checkbox
|
||||
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
|
||||
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
|
||||
await act(async () => {
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
});
|
||||
|
|
@ -208,12 +133,11 @@ describe("MakeMCPPublicForm", () => {
|
|||
render(<MakeMCPPublicForm {...mockProps} />);
|
||||
|
||||
// Select all servers
|
||||
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
|
||||
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
|
||||
await act(async () => {
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
});
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
@ -224,7 +148,6 @@ describe("MakeMCPPublicForm", () => {
|
|||
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: "Make Public" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
|
@ -271,6 +194,8 @@ describe("MakeMCPPublicForm", () => {
|
|||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
await act(async () => {
|
||||
fireEvent.click(checkboxes[0]); // Click select all to select all
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(checkboxes[0]); // Click select all again to deselect all
|
||||
});
|
||||
|
||||
|
|
@ -295,8 +220,8 @@ describe("MakeMCPPublicForm", () => {
|
|||
expect(screen.getByText("No MCP servers available.")).toBeInTheDocument();
|
||||
|
||||
// Select All checkbox should be disabled
|
||||
const selectAllCheckbox = screen.getByLabelText("Select All");
|
||||
expect(selectAllCheckbox).toBeDisabled();
|
||||
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" });
|
||||
expectDisabledControl(selectAllCheckbox);
|
||||
|
||||
// Next button should be disabled
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
|
|
@ -371,7 +296,7 @@ describe("MakeMCPPublicForm", () => {
|
|||
|
||||
// Select all should be indeterminate now
|
||||
const selectAllCheckbox = checkboxes[0];
|
||||
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
|
||||
expect(selectAllCheckbox).toBePartiallyChecked();
|
||||
});
|
||||
|
||||
it("should display tools overflow text when server has more than 3 tools", () => {
|
||||
|
|
@ -402,7 +327,6 @@ describe("MakeMCPPublicForm", () => {
|
|||
|
||||
render(<MakeMCPPublicForm {...mockProps} />);
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
@ -412,7 +336,6 @@ describe("MakeMCPPublicForm", () => {
|
|||
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: "Make Public" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
|
@ -428,7 +351,7 @@ describe("MakeMCPPublicForm", () => {
|
|||
expect(mockProps.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show loading state during submit", async () => {
|
||||
it("should not complete the flow until the submit request resolves", async () => {
|
||||
let resolvePromise: (value: any) => void = () => {};
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
|
|
@ -437,7 +360,6 @@ describe("MakeMCPPublicForm", () => {
|
|||
|
||||
render(<MakeMCPPublicForm {...mockProps} />);
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
@ -447,17 +369,20 @@ describe("MakeMCPPublicForm", () => {
|
|||
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: "Make Public" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
// Check loading state
|
||||
expect(submitButton).toHaveAttribute("data-loading", "true");
|
||||
expect(submitButton).toBeDisabled();
|
||||
expectDisabledControl(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
expect(mockMakeMCPPublicCall).toHaveBeenCalledTimes(1);
|
||||
expect(mockProps.onSuccess).not.toHaveBeenCalled();
|
||||
expect(mockProps.onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise({});
|
||||
await waitFor(() => {
|
||||
expect(mockProps.onSuccess).toHaveBeenCalled();
|
||||
|
|
@ -474,7 +399,7 @@ describe("MakeMCPPublicForm", () => {
|
|||
render(<MakeMCPPublicForm {...invisibleProps} />);
|
||||
|
||||
// Modal should not be rendered
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Make MCP Servers Public")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -569,6 +494,6 @@ describe("MakeMCPPublicForm", () => {
|
|||
|
||||
// Select all should be indeterminate
|
||||
const selectAllCheckbox = checkboxes[0];
|
||||
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
|
||||
expect(selectAllCheckbox).toBePartiallyChecked();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Form, Steps, Button, Checkbox } from "antd";
|
||||
import { Text, Title, Badge } from "@tremor/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { makeMCPPublicCall } from "../../networking";
|
||||
import NotificationsManager from "../../molecules/notifications_manager";
|
||||
import { MCPServerData } from "@/components/AIHub/MCPHubTableColumns";
|
||||
|
||||
const { Step } = Steps;
|
||||
const STEP_TITLES = ["Select Servers", "Confirm"];
|
||||
|
||||
const statusVariant = (status?: string) => {
|
||||
if (status === "active" || status === "healthy") {
|
||||
return "default" as const;
|
||||
}
|
||||
if (status === "inactive" || status === "unhealthy") {
|
||||
return "destructive" as const;
|
||||
}
|
||||
return "outline" as const;
|
||||
};
|
||||
|
||||
interface MakeMCPPublicFormProps {
|
||||
visible: boolean;
|
||||
|
|
@ -25,12 +39,10 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
|
|||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0);
|
||||
setSelectedServers(new Set());
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -114,29 +126,30 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Title>Select MCP Servers to Make Public</Title>
|
||||
<h3 className="text-lg font-semibold">Select MCP Servers to Make Public</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={allServersSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
disabled={mcpHubData.length === 0}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={allServersSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onCheckedChange={(checked) => handleSelectAll(checked === true)}
|
||||
disabled={mcpHubData.length === 0}
|
||||
/>
|
||||
Select All {mcpHubData.length > 0 && `(${mcpHubData.length})`}
|
||||
</Checkbox>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600">
|
||||
Select the MCP servers you want to be visible on the public model hub. Users will still require a valid
|
||||
Virtual Key to use these servers.
|
||||
</Text>
|
||||
</p>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
{mcpHubData.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Text>No MCP servers available.</Text>
|
||||
<p>No MCP servers available.</p>
|
||||
</div>
|
||||
) : (
|
||||
mcpHubData.map((server) => {
|
||||
|
|
@ -148,42 +161,25 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
|
|||
>
|
||||
<Checkbox
|
||||
checked={selectedServers.has(server.server_id)}
|
||||
onChange={(e) => handleServerSelection(server.server_id, e.target.checked)}
|
||||
onCheckedChange={(checked) => handleServerSelection(server.server_id, checked === true)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Text className="font-medium">{server.server_name}</Text>
|
||||
{isPublic && (
|
||||
<Badge color="emerald" size="sm">
|
||||
Public
|
||||
</Badge>
|
||||
)}
|
||||
<Badge color="blue" size="sm">
|
||||
{server.transport}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={
|
||||
server.status === "active" || server.status === "healthy"
|
||||
? "green"
|
||||
: server.status === "inactive" || server.status === "unhealthy"
|
||||
? "red"
|
||||
: "gray"
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{server.status || "unknown"}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium break-words">{server.server_name}</p>
|
||||
{isPublic && <Badge>Public</Badge>}
|
||||
<Badge variant="secondary">{server.transport}</Badge>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
|
||||
</div>
|
||||
<Text className="text-xs text-gray-600 mt-1">{server.description || server.url}</Text>
|
||||
<p className="text-xs text-gray-600 mt-1 break-words">{server.description || server.url}</p>
|
||||
{server.allowed_tools && server.allowed_tools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{server.allowed_tools.slice(0, 3).map((tool, idx) => (
|
||||
<Badge key={idx} color="purple" size="xs">
|
||||
<Badge key={idx} variant="outline">
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
{server.allowed_tools.length > 3 && (
|
||||
<Text className="text-xs text-gray-500">+{server.allowed_tools.length - 3} more</Text>
|
||||
<p className="text-xs text-gray-500">+{server.allowed_tools.length - 3} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -197,9 +193,9 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
|
|||
|
||||
{selectedServers.size > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-sm text-blue-800">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} selected
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -209,48 +205,37 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
|
|||
const renderStep2Content = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Title>Confirm Making MCP Servers Public</Title>
|
||||
<h3 className="text-lg font-semibold">Confirm Making MCP Servers Public</h3>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<Text className="text-sm text-yellow-800">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Warning:</strong> Once you make these MCP servers public, anyone who can go to the{" "}
|
||||
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Text className="font-medium">MCP Servers to be made public:</Text>
|
||||
<p className="font-medium">MCP Servers to be made public:</p>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
|
||||
<div className="space-y-2">
|
||||
{Array.from(selectedServers).map((serverId) => {
|
||||
const server = mcpHubData.find((s) => s.server_id === serverId);
|
||||
return (
|
||||
<div key={serverId} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Text className="font-medium">{server?.server_name || serverId}</Text>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium break-words">{server?.server_name || serverId}</p>
|
||||
{server && (
|
||||
<>
|
||||
<Badge color="blue" size="xs">
|
||||
{server.transport}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={
|
||||
server.status === "active" || server.status === "healthy"
|
||||
? "green"
|
||||
: server.status === "inactive" || server.status === "unhealthy"
|
||||
? "red"
|
||||
: "gray"
|
||||
}
|
||||
size="xs"
|
||||
>
|
||||
{server.status || "unknown"}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{server.transport}</Badge>
|
||||
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{server?.description && <Text className="text-xs text-gray-600 mt-1">{server.description}</Text>}
|
||||
{server?.url && <Text className="text-xs text-gray-500 mt-1">{server.url}</Text>}
|
||||
{server?.description && (
|
||||
<p className="text-xs text-gray-600 mt-1 break-words">{server.description}</p>
|
||||
)}
|
||||
{server?.url && <p className="text-xs text-gray-500 mt-1 break-words">{server.url}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -260,10 +245,10 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-sm text-blue-800">
|
||||
<p className="text-sm text-blue-800">
|
||||
Total: <strong>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} will be
|
||||
made public
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -283,7 +268,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
|
|||
const renderStepButtons = () => {
|
||||
return (
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
|
||||
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
|
||||
{currentStep === 0 ? "Cancel" : "Previous"}
|
||||
</Button>
|
||||
|
||||
|
|
@ -295,7 +280,8 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
|
|||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Make Public
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -305,24 +291,42 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Make MCP Servers Public"
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={1200}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Steps current={currentStep} className="mb-6">
|
||||
<Step title="Select Servers" />
|
||||
<Step title="Confirm" />
|
||||
</Steps>
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Make MCP Servers Public</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{renderStepContent()}
|
||||
{renderStepButtons()}
|
||||
</Form>
|
||||
</Modal>
|
||||
<div>
|
||||
<ol className="mb-6 flex items-center gap-6">
|
||||
{STEP_TITLES.map((title, index) => (
|
||||
<li
|
||||
key={title}
|
||||
className="flex items-center gap-2"
|
||||
aria-current={currentStep === index ? "step" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded-full border text-xs",
|
||||
currentStep === index
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
|
||||
{title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{renderStepContent()}
|
||||
{renderStepButtons()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -29,67 +29,8 @@ vi.mock("../../networking", () => ({
|
|||
import { makeModelGroupPublic } from "../../networking";
|
||||
const mockMakeModelGroupPublic = vi.mocked(makeModelGroupPublic);
|
||||
|
||||
// Mock antd components
|
||||
vi.mock("antd", () => ({
|
||||
Modal: ({ open, title, children, onCancel, footer }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div>{title}</div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
Form: Object.assign(({ children, form }: any) => <form data-testid="form">{children}</form>, {
|
||||
useForm: () => [
|
||||
{
|
||||
resetFields: vi.fn(),
|
||||
validateFields: vi.fn(),
|
||||
getFieldsValue: vi.fn(),
|
||||
setFieldsValue: vi.fn(),
|
||||
},
|
||||
vi.fn(),
|
||||
],
|
||||
Item: ({ children }: any) => <div>{children}</div>,
|
||||
}),
|
||||
Steps: Object.assign(
|
||||
({ children, current, className }: any) => (
|
||||
<div data-testid="steps" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
{
|
||||
Step: ({ title }: any) => <div>{title}</div>,
|
||||
},
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, ...props }: any) => (
|
||||
<button onClick={onClick} disabled={disabled || loading} data-loading={loading} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
|
||||
disabled={disabled}
|
||||
data-indeterminate={indeterminate}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock @tremor/react components
|
||||
vi.mock("@tremor/react", () => ({
|
||||
Text: ({ children, className }: any) => <span className={className}>{children}</span>,
|
||||
Title: ({ children }: any) => <h3>{children}</h3>,
|
||||
Badge: ({ children, color, size }: any) => (
|
||||
<span data-color={color} data-size={size}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
const expectDisabledControl = (element: HTMLElement) =>
|
||||
expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true);
|
||||
|
||||
// Mock ModelFilters component
|
||||
vi.mock("../../model_filters", () => ({
|
||||
|
|
@ -190,7 +131,7 @@ describe("MakeModelPublicForm", () => {
|
|||
expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument();
|
||||
|
||||
// Select all models using the select all checkbox
|
||||
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
|
||||
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
|
||||
await act(async () => {
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
});
|
||||
|
|
@ -216,12 +157,11 @@ describe("MakeModelPublicForm", () => {
|
|||
render(<MakeModelPublicForm {...mockProps} />);
|
||||
|
||||
// Select all models
|
||||
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
|
||||
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
|
||||
await act(async () => {
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
});
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
@ -232,7 +172,6 @@ describe("MakeModelPublicForm", () => {
|
|||
expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: "Make Public" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
|
@ -279,6 +218,8 @@ describe("MakeModelPublicForm", () => {
|
|||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
await act(async () => {
|
||||
fireEvent.click(checkboxes[0]); // Click select all to select all
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(checkboxes[0]); // Click select all again to deselect all
|
||||
});
|
||||
|
||||
|
|
@ -303,8 +244,8 @@ describe("MakeModelPublicForm", () => {
|
|||
expect(screen.getByText("No models match the current filters.")).toBeInTheDocument();
|
||||
|
||||
// Select All checkbox should be disabled
|
||||
const selectAllCheckbox = screen.getByLabelText("Select All");
|
||||
expect(selectAllCheckbox).toBeDisabled();
|
||||
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" });
|
||||
expectDisabledControl(selectAllCheckbox);
|
||||
|
||||
// Next button should be disabled
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
|
|
@ -379,7 +320,7 @@ describe("MakeModelPublicForm", () => {
|
|||
|
||||
// Select all should be indeterminate now
|
||||
const selectAllCheckbox = checkboxes[0];
|
||||
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
|
||||
expect(selectAllCheckbox).toBePartiallyChecked();
|
||||
});
|
||||
|
||||
it("should display model badges and information", () => {
|
||||
|
|
@ -402,7 +343,6 @@ describe("MakeModelPublicForm", () => {
|
|||
|
||||
render(<MakeModelPublicForm {...mockProps} />);
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
@ -412,7 +352,6 @@ describe("MakeModelPublicForm", () => {
|
|||
expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: "Make Public" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
|
|
@ -428,7 +367,7 @@ describe("MakeModelPublicForm", () => {
|
|||
expect(mockProps.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show loading state during submit", async () => {
|
||||
it("should not complete the flow until the submit request resolves", async () => {
|
||||
let resolvePromise: (value: any) => void = () => {};
|
||||
const pendingPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
|
|
@ -437,7 +376,6 @@ describe("MakeModelPublicForm", () => {
|
|||
|
||||
render(<MakeModelPublicForm {...mockProps} />);
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
@ -447,17 +385,20 @@ describe("MakeModelPublicForm", () => {
|
|||
expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole("button", { name: "Make Public" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
// Check loading state
|
||||
expect(submitButton).toHaveAttribute("data-loading", "true");
|
||||
expect(submitButton).toBeDisabled();
|
||||
expectDisabledControl(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
expect(mockMakeModelGroupPublic).toHaveBeenCalledTimes(1);
|
||||
expect(mockProps.onSuccess).not.toHaveBeenCalled();
|
||||
expect(mockProps.onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise({});
|
||||
await waitFor(() => {
|
||||
expect(mockProps.onSuccess).toHaveBeenCalled();
|
||||
|
|
@ -474,7 +415,7 @@ describe("MakeModelPublicForm", () => {
|
|||
render(<MakeModelPublicForm {...invisibleProps} />);
|
||||
|
||||
// Modal should not be rendered
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Make Models Public")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -521,21 +462,19 @@ describe("MakeModelPublicForm", () => {
|
|||
|
||||
// Select all should be indeterminate
|
||||
const selectAllCheckbox = checkboxes[0];
|
||||
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
|
||||
expect(selectAllCheckbox).toBePartiallyChecked();
|
||||
});
|
||||
|
||||
it("should show selected count", () => {
|
||||
render(<MakeModelPublicForm {...mockProps} />);
|
||||
|
||||
// Should show that 1 model is selected (gpt-3.5-turbo is preselected)
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("model selected")).toBeInTheDocument();
|
||||
expect(screen.getByText("model selected")).toHaveTextContent("1 model selected");
|
||||
});
|
||||
|
||||
it("should show confirmation step with selected models", async () => {
|
||||
render(<MakeModelPublicForm {...mockProps} />);
|
||||
|
||||
// Navigate to confirm step
|
||||
const nextButton = screen.getByRole("button", { name: "Next" });
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Modal, Form, Steps, Button, Checkbox } from "antd";
|
||||
import { Text, Title, Badge } from "@tremor/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { makeModelGroupPublic } from "../../networking";
|
||||
import ModelFilters from "../../model_filters";
|
||||
import NotificationsManager from "../../molecules/notifications_manager";
|
||||
|
||||
const { Step } = Steps;
|
||||
const STEP_TITLES = ["Select Models", "Confirm"];
|
||||
|
||||
interface ModelGroupInfo {
|
||||
model_group: string;
|
||||
|
|
@ -44,13 +48,11 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||
const [filteredData, setFilteredData] = useState<ModelGroupInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0);
|
||||
setSelectedModels(new Set());
|
||||
setFilteredData([]);
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -138,23 +140,24 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Title>Select Models to Make Public</Title>
|
||||
<h3 className="text-lg font-semibold">Select Models to Make Public</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={allModelsSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
disabled={filteredData.length === 0}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={allModelsSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onCheckedChange={(checked) => handleSelectAll(checked === true)}
|
||||
disabled={filteredData.length === 0}
|
||||
/>
|
||||
Select All {filteredData.length > 0 && `(${filteredData.length})`}
|
||||
</Checkbox>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600">
|
||||
Select the models you want to be visible on the public model hub. Users will still require a valid Virtual Key
|
||||
to use these models.
|
||||
</Text>
|
||||
</p>
|
||||
|
||||
{/* Filters */}
|
||||
<ModelFilters
|
||||
|
|
@ -168,7 +171,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
<div className="space-y-3">
|
||||
{filteredData.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Text>No models match the current filters.</Text>
|
||||
<p>No models match the current filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredData.map((model) => (
|
||||
|
|
@ -178,20 +181,16 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
>
|
||||
<Checkbox
|
||||
checked={selectedModels.has(model.model_group)}
|
||||
onChange={(e) => handleModelSelection(model.model_group, e.target.checked)}
|
||||
onCheckedChange={(checked) => handleModelSelection(model.model_group, checked === true)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Text className="font-medium">{model.model_group}</Text>
|
||||
{model.mode && (
|
||||
<Badge color="green" size="sm">
|
||||
{model.mode}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium break-words">{model.model_group}</p>
|
||||
{model.mode && <Badge>{model.mode}</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{model.providers.map((provider) => (
|
||||
<Badge key={provider} color="blue" size="xs">
|
||||
<Badge key={provider} variant="secondary">
|
||||
{provider}
|
||||
</Badge>
|
||||
))}
|
||||
|
|
@ -205,9 +204,9 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
|
||||
{selectedModels.size > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-sm text-blue-800">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>{selectedModels.size}</strong> model{selectedModels.size !== 1 ? "s" : ""} selected
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -217,29 +216,29 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
const renderStep2Content = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Title>Confirm Making Models Public</Title>
|
||||
<h3 className="text-lg font-semibold">Confirm Making Models Public</h3>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<Text className="text-sm text-yellow-800">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Warning:</strong> Once you make these models public, anyone who can go to the{" "}
|
||||
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Text className="font-medium">Models to be made public:</Text>
|
||||
<p className="font-medium">Models to be made public:</p>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
|
||||
<div className="space-y-2">
|
||||
{Array.from(selectedModels).map((modelGroup) => {
|
||||
const model = modelHubData.find((m) => m.model_group === modelGroup);
|
||||
return (
|
||||
<div key={modelGroup} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
|
||||
<div>
|
||||
<Text className="font-medium">{modelGroup}</Text>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium break-words">{modelGroup}</p>
|
||||
{model && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{model.providers.map((provider) => (
|
||||
<Badge key={provider} color="blue" size="xs">
|
||||
<Badge key={provider} variant="secondary">
|
||||
{provider}
|
||||
</Badge>
|
||||
))}
|
||||
|
|
@ -254,10 +253,10 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-sm text-blue-800">
|
||||
<p className="text-sm text-blue-800">
|
||||
Total: <strong>{selectedModels.size}</strong> model{selectedModels.size !== 1 ? "s" : ""} will be made
|
||||
public
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -277,7 +276,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
const renderStepButtons = () => {
|
||||
return (
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
|
||||
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
|
||||
{currentStep === 0 ? "Cancel" : "Previous"}
|
||||
</Button>
|
||||
|
||||
|
|
@ -289,7 +288,8 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Make Public
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -299,24 +299,42 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Make Models Public"
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={1200}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Steps current={currentStep} className="mb-6">
|
||||
<Step title="Select Models" />
|
||||
<Step title="Confirm" />
|
||||
</Steps>
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1200px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Make Models Public</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{renderStepContent()}
|
||||
{renderStepButtons()}
|
||||
</Form>
|
||||
</Modal>
|
||||
<div>
|
||||
<ol className="mb-6 flex items-center gap-6">
|
||||
{STEP_TITLES.map((title, index) => (
|
||||
<li
|
||||
key={title}
|
||||
className="flex items-center gap-2"
|
||||
aria-current={currentStep === index ? "step" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded-full border text-xs",
|
||||
currentStep === index
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
|
||||
{title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{renderStepContent()}
|
||||
{renderStepButtons()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Alert } from "antd";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
|
||||
|
||||
interface DebugWarningBannerProps {
|
||||
|
|
@ -17,19 +18,14 @@ export const DebugWarningBanner: React.FC<DebugWarningBannerProps> = ({ accessTo
|
|||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message="Performance Warning: Detailed Debug Mode Active"
|
||||
description={
|
||||
<>
|
||||
Detailed debug logging (<code>LITELLM_LOG=DEBUG</code>) is currently enabled. This mode logs extensive
|
||||
diagnostic information and will significantly degrade performance. It should only be used for troubleshooting
|
||||
and disabled in production environments.
|
||||
</>
|
||||
}
|
||||
type="warning"
|
||||
showIcon
|
||||
banner
|
||||
style={{ marginBottom: 0, borderRadius: 0 }}
|
||||
/>
|
||||
<Alert variant="warning" className="rounded-none border-x-0 border-t-0">
|
||||
<TriangleAlert className="size-4" aria-hidden />
|
||||
<AlertTitle>Performance Warning: Detailed Debug Mode Active</AlertTitle>
|
||||
<AlertDescription>
|
||||
Detailed debug logging (<code>LITELLM_LOG=DEBUG</code>) is currently enabled. This mode logs extensive
|
||||
diagnostic information and will significantly degrade performance. It should only be used for troubleshooting
|
||||
and disabled in production environments.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,18 +42,22 @@ describe("LicenseExpiryBannerView", () => {
|
|||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows a dismissible amber warning within 30 days", () => {
|
||||
it("shows a dismissible warning within 30 days", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(20))} />);
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(container.querySelector(".lucide-triangle-alert")).toBeInTheDocument();
|
||||
expect(screen.getByText(/expires in 20 days/)).toBeInTheDocument();
|
||||
expect(container.querySelector(".ant-alert-warning")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Renew before it lapses to keep enterprise features/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "sales@berri.ai" })).toHaveAttribute("href", "mailto:sales@berri.ai");
|
||||
});
|
||||
|
||||
it("shows a non-dismissible red critical alert within 7 days", () => {
|
||||
it("shows a non-dismissible critical alert within 7 days", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(5))} />);
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(container.querySelector(".lucide-circle-alert")).toBeInTheDocument();
|
||||
expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument();
|
||||
expect(container.querySelector(".ant-alert-error")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Renew now to avoid losing enterprise features/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -62,18 +66,19 @@ describe("LicenseExpiryBannerView", () => {
|
|||
expect(screen.getByText(/expires today/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a non-dismissible red expired alert stating features are disabled", () => {
|
||||
it("shows a non-dismissible expired alert stating features are disabled", () => {
|
||||
const { container } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(daysFromNow(-3))} />);
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(container.querySelector(".lucide-circle-alert")).toBeInTheDocument();
|
||||
expect(screen.getByText(/expired on/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/features are now disabled/i)).toBeInTheDocument();
|
||||
expect(container.querySelector(".ant-alert-error")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the warning after dismissal and stays hidden within the session", () => {
|
||||
const expiration = daysFromNow(20);
|
||||
const { unmount } = render(<LicenseExpiryBannerView licenseInfo={licenseWith(expiration)} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /close/i }));
|
||||
expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Alert } from "antd";
|
||||
import { CircleAlert, TriangleAlert, X } from "lucide-react";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LicenseInfo } from "@/components/networking";
|
||||
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
|
||||
import { formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "@/utils/licenseUtils";
|
||||
|
|
@ -76,16 +78,22 @@ export const LicenseExpiryBannerView: React.FC<LicenseExpiryBannerViewProps> = (
|
|||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message={message}
|
||||
description={description}
|
||||
type={tier === "warning" ? "warning" : "error"}
|
||||
showIcon
|
||||
banner
|
||||
closable={isDismissible}
|
||||
onClose={handleClose}
|
||||
style={{ marginBottom: 0, borderRadius: 0 }}
|
||||
/>
|
||||
<Alert variant={tier === "warning" ? "warning" : "error"} className="rounded-none border-x-0 border-t-0">
|
||||
{tier === "warning" ? (
|
||||
<TriangleAlert className="size-4" aria-hidden />
|
||||
) : (
|
||||
<CircleAlert className="size-4" aria-hidden />
|
||||
)}
|
||||
<AlertTitle>{message}</AlertTitle>
|
||||
<AlertDescription>{description}</AlertDescription>
|
||||
{isDismissible && (
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={handleClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,27 @@ describe("BlogDropdown", () => {
|
|||
expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render menu content before the trigger is hovered", () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
expect(screen.queryByRole("link", { name: /view all posts/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Post One")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open the menu on hover", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } };
|
||||
renderWithProviders(<BlogDropdown />);
|
||||
|
||||
expect(screen.queryByText("Post One")).not.toBeInTheDocument();
|
||||
|
||||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Post One")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading state", () => {
|
||||
it("should show a loading spinner", async () => {
|
||||
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true };
|
||||
|
|
@ -74,7 +95,7 @@ describe("BlogDropdown", () => {
|
|||
await openDropdown();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".anticon-loading")).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
|
||||
import { DownOutlined, LoadingOutlined } from "@ant-design/icons";
|
||||
import { Button, Dropdown, Space, Typography } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDown, LoaderCircle } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", {
|
||||
|
|
@ -26,63 +30,70 @@ export const BlogDropdown: React.FC = () => {
|
|||
return null;
|
||||
}
|
||||
|
||||
let items: MenuProps["items"];
|
||||
const renderMenuContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center px-2 py-1.5 text-sm">
|
||||
<LoaderCircle role="img" aria-label="loading" className="size-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
items = [{ key: "loading", label: <LoadingOutlined />, disabled: true }];
|
||||
} else if (isError) {
|
||||
items = [
|
||||
{
|
||||
key: "error",
|
||||
label: (
|
||||
<Space>
|
||||
<Text type="danger">Failed to load posts</Text>
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
} else if (!data || data.posts.length === 0) {
|
||||
items = [{ key: "empty", label: <Text type="secondary">No posts available</Text>, disabled: true }];
|
||||
} else {
|
||||
items = [
|
||||
...data.posts.slice(0, 5).map((post: BlogPost) => ({
|
||||
key: post.url,
|
||||
label: (
|
||||
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: "block", width: 380 }}>
|
||||
<Title level={5} style={{ marginBottom: 2 }}>
|
||||
{post.title}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{formatDate(post.date)}
|
||||
</Text>
|
||||
<Paragraph ellipsis={{ rows: 2 }}>{post.description}</Paragraph>
|
||||
</a>
|
||||
),
|
||||
})),
|
||||
{ type: "divider" as const },
|
||||
{
|
||||
key: "view-all",
|
||||
label: (
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-sm">
|
||||
<span className="text-destructive">Failed to load posts</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.posts.length === 0) {
|
||||
return <div className="px-2 py-1.5 text-sm text-muted-foreground">No posts available</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.posts.slice(0, 5).map((post: BlogPost) => (
|
||||
<DropdownMenuItem key={post.url}>
|
||||
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: "block", width: 380 }}>
|
||||
<h5 className="text-sm font-semibold" style={{ marginBottom: 2 }}>
|
||||
{post.title}
|
||||
</h5>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{formatDate(post.date)}
|
||||
</span>
|
||||
<p className="line-clamp-2">{post.description}</p>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
|
||||
View all posts
|
||||
</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Blog opens a post list; Docs is a single outbound link — navbar adds a layout-only chevron there for alignment.
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
|
||||
<Button type="text" className={`${NAV_PRODUCT_LINK_CLASS} border-0! bg-transparent!`}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
openOnHover
|
||||
closeDelay={100}
|
||||
render={<Button variant="ghost" className={`${NAV_PRODUCT_LINK_CLASS} border-0! bg-transparent!`} />}
|
||||
>
|
||||
Blog
|
||||
<DownOutlined className="text-[10px] text-gray-500" aria-hidden />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<ChevronDown className="size-2.5 text-gray-500" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-auto">
|
||||
{renderMenuContent()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
import { GithubOutlined, SlackOutlined } from "@ant-design/icons";
|
||||
import { Tooltip } from "antd";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Github, Slack } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
const iconBtnClass =
|
||||
|
|
@ -18,28 +18,40 @@ export const CommunityEngagementButtons: React.FC = () => {
|
|||
className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0"
|
||||
aria-label="Community links"
|
||||
>
|
||||
<Tooltip title="LiteLLM Slack community">
|
||||
<a
|
||||
href="https://www.litellm.ai/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={iconBtnClass}
|
||||
aria-label="Join Slack"
|
||||
>
|
||||
<SlackOutlined className="text-lg" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip title="LiteLLM on GitHub">
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={iconBtnClass}
|
||||
aria-label="LiteLLM on GitHub"
|
||||
>
|
||||
<GithubOutlined className="text-lg" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href="https://www.litellm.ai/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={iconBtnClass}
|
||||
aria-label="Join Slack"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Slack className="size-[18px]" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>LiteLLM Slack community</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={iconBtnClass}
|
||||
aria-label="LiteLLM on GitHub"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Github className="size-[18px]" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>LiteLLM on GitHub</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import {
|
|||
useHideAutoRouterAnnouncement,
|
||||
} from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement";
|
||||
import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
import { BellOutlined } from "@ant-design/icons";
|
||||
import { Badge, Button, Popover, Typography } from "antd";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Bell } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
|
||||
|
|
@ -24,18 +27,21 @@ export const NotificationsBell: React.FC = () => {
|
|||
|
||||
const content = (
|
||||
<div className="max-w-[280px]">
|
||||
<Typography.Title level={5} className="mt-0! mb-2!">
|
||||
LiteLLM Auto Router
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" className="mb-3! text-sm leading-snug">
|
||||
<PopoverTitle className="mt-0! mb-2!">LiteLLM Auto Router</PopoverTitle>
|
||||
<PopoverDescription className="mb-3! text-sm leading-snug">
|
||||
Route every request to the cheapest model that can handle it, no prompt changes needed.
|
||||
</Typography.Paragraph>
|
||||
</PopoverDescription>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="primary" size="small" href={AUTO_ROUTER_DOCS_URL} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
className={cn(buttonVariants({ size: "sm" }))}
|
||||
href={AUTO_ROUTER_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the docs
|
||||
</Button>
|
||||
</a>
|
||||
{hasUnread ? (
|
||||
<Button type="link" size="small" className="px-1!" onClick={markDismissed}>
|
||||
<Button variant="link" size="sm" className="px-1!" onClick={markDismissed}>
|
||||
Mark as read
|
||||
</Button>
|
||||
) : null}
|
||||
|
|
@ -44,16 +50,17 @@ export const NotificationsBell: React.FC = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
|
||||
<Button
|
||||
type="text"
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className="flex! h-9! w-9! items-center justify-center rounded-md! text-gray-600 transition-colors hover:bg-gray-100! hover:text-gray-900!"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Badge dot={hasUnread} color="#1677ff" size="small" offset={[8, 2]}>
|
||||
<BellOutlined className="text-base" aria-hidden />
|
||||
</Badge>
|
||||
</Button>
|
||||
<span className="relative inline-flex">
|
||||
<Bell className="size-4" aria-hidden />
|
||||
{hasUnread ? <Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden /> : null}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end">{content}</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,23 +9,17 @@ import {
|
|||
setLocalStorageItem,
|
||||
} from "@/utils/localStorageUtils";
|
||||
import { navAccountDisplayName } from "@/components/Navbar/navDisplayName";
|
||||
import {
|
||||
CrownOutlined,
|
||||
DownOutlined,
|
||||
LogoutOutlined,
|
||||
MailOutlined,
|
||||
SafetyOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { ChevronDown, ChevronsUpDown, Crown, LogOut, Mail, ShieldCheck, User } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function hueFromString(seed: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
|
|
@ -80,60 +74,57 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
setDisableShowNewBadge(storedValue === "true");
|
||||
}, []);
|
||||
|
||||
const userItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "logout",
|
||||
label: (
|
||||
<Space>
|
||||
<LogoutOutlined />
|
||||
Logout
|
||||
</Space>
|
||||
),
|
||||
onClick: onLogout,
|
||||
},
|
||||
];
|
||||
|
||||
const renderUserInfoSection = () => (
|
||||
<Space direction="vertical" size="small" style={{ width: "100%", padding: "12px" }}>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<MailOutlined />
|
||||
<Text type="secondary">{userEmail || "-"}</Text>
|
||||
</Space>
|
||||
<div className="flex w-full flex-col gap-2 p-3 text-sm">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="size-4" />
|
||||
<span className="text-muted-foreground">{userEmail || "-"}</span>
|
||||
</div>
|
||||
{premiumUser ? (
|
||||
<Tag icon={<CrownOutlined />} color="gold">
|
||||
<Badge>
|
||||
<Crown className="size-3" />
|
||||
Premium
|
||||
</Tag>
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip title="Upgrade to Premium for advanced features" placement="left">
|
||||
<Tag icon={<CrownOutlined />}>Standard</Tag>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Badge variant="outline" />}>
|
||||
<Crown className="size-3" />
|
||||
Standard
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Upgrade to Premium for advanced features</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Space>
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<Text type="secondary">User ID</Text>
|
||||
</Space>
|
||||
<Text copyable ellipsis style={{ maxWidth: "150px" }} title={userId || "-"}>
|
||||
{userId || "-"}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<SafetyOutlined />
|
||||
<Text type="secondary">Role</Text>
|
||||
</Space>
|
||||
<Text>{userRole}</Text>
|
||||
</Space>
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide New Feature Indicators</Text>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="size-4" />
|
||||
<span className="text-muted-foreground">User ID</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="max-w-[150px] truncate" title={userId || "-"}>
|
||||
{userId || "-"}
|
||||
</span>
|
||||
<CopyButton value={userId} label="Copy User ID" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4" />
|
||||
<span className="text-muted-foreground">Role</span>
|
||||
</div>
|
||||
<span>{userRole}</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Hide New Feature Indicators</span>
|
||||
<Switch
|
||||
size="small"
|
||||
size="sm"
|
||||
checked={disableShowNewBadge}
|
||||
onChange={(checked) => {
|
||||
onCheckedChange={(checked) => {
|
||||
setDisableShowNewBadge(checked);
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableShowNewBadge", "true");
|
||||
|
|
@ -145,13 +136,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
}}
|
||||
aria-label="Toggle hide new feature indicators"
|
||||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide All Prompts</Text>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Hide All Prompts</span>
|
||||
<Switch
|
||||
size="small"
|
||||
size="sm"
|
||||
checked={disableShowPrompts}
|
||||
onChange={(checked) => {
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableShowPrompts", "true");
|
||||
emitLocalStorageChange("disableShowPrompts");
|
||||
|
|
@ -162,13 +153,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
}}
|
||||
aria-label="Toggle hide all prompts"
|
||||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide Blog Posts</Text>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Hide Blog Posts</span>
|
||||
<Switch
|
||||
size="small"
|
||||
size="sm"
|
||||
checked={disableBlogPosts}
|
||||
onChange={(checked) => {
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableBlogPosts", "true");
|
||||
emitLocalStorageChange("disableBlogPosts");
|
||||
|
|
@ -179,13 +170,13 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
}}
|
||||
aria-label="Toggle hide blog posts"
|
||||
/>
|
||||
</Space>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Text type="secondary">Hide Bouncing Icon</Text>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">Hide Bouncing Icon</span>
|
||||
<Switch
|
||||
size="small"
|
||||
size="sm"
|
||||
checked={disableBouncingIcon}
|
||||
onChange={(checked) => {
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setLocalStorageItem("disableBouncingIcon", "true");
|
||||
emitLocalStorageChange("disableBouncingIcon");
|
||||
|
|
@ -196,8 +187,8 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
}}
|
||||
aria-label="Toggle hide bouncing icon"
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const seed = userEmail || userId || "user";
|
||||
|
|
@ -206,30 +197,21 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
const displayName = navAccountDisplayName(userEmail, userId);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement={variant === "sidebar" ? "topLeft" : "bottomRight"}
|
||||
menu={{ items: userItems }}
|
||||
popupRender={(menu) => (
|
||||
<div className="rounded-lg bg-white shadow-lg" data-testid="user-dropdown-panel">
|
||||
{renderUserInfoSection()}
|
||||
<Divider style={{ margin: 0 }} />
|
||||
{React.cloneElement(menu as React.ReactElement, {
|
||||
style: { boxShadow: "none" },
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Popover>
|
||||
{variant === "sidebar" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
|
||||
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
|
||||
)}
|
||||
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
|
||||
aria-haspopup="menu"
|
||||
title={collapsed ? displayName : undefined}
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
|
||||
collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left",
|
||||
)}
|
||||
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
|
||||
aria-haspopup="dialog"
|
||||
title={collapsed ? displayName : undefined}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-[30px] shadow-inner ring-1 ring-black/5" aria-hidden>
|
||||
<AvatarFallback className="font-semibold text-white" style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}>
|
||||
|
|
@ -245,13 +227,17 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
<ChevronsUpDown size={16} strokeWidth={1.75} className="shrink-0 text-muted-foreground" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!"
|
||||
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
|
||||
aria-haspopup="menu"
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!"
|
||||
aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`}
|
||||
aria-haspopup="dialog"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="shadow-inner ring-1 ring-black/5" aria-hidden>
|
||||
<AvatarFallback className="font-semibold text-white" style={{ backgroundColor: `hsl(${hue} 46% 38%)` }}>
|
||||
|
|
@ -261,10 +247,27 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
<span className="hidden min-w-0 truncate text-left text-sm font-medium leading-none text-gray-900 md:inline">
|
||||
{displayName}
|
||||
</span>
|
||||
<DownOutlined className="hidden shrink-0 text-[10px] text-gray-400 md:inline" aria-hidden />
|
||||
</Button>
|
||||
<ChevronDown className="hidden size-2.5 shrink-0 text-gray-400 md:inline" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
</Dropdown>
|
||||
<PopoverContent
|
||||
align={variant === "sidebar" ? "start" : "end"}
|
||||
side={variant === "sidebar" ? "top" : "bottom"}
|
||||
className="w-auto gap-0 rounded-lg bg-white p-1 shadow-lg"
|
||||
data-testid="user-dropdown-panel"
|
||||
>
|
||||
{renderUserInfoSection()}
|
||||
<Separator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
Logout
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Dropdown } from "antd";
|
||||
import { AppstoreOutlined, CheckOutlined } from "@ant-design/icons";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import type { MenuProps } from "antd";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react";
|
||||
import { usePluginMode } from "@/contexts/PluginModeContext";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { migratedHref } from "@/utils/migratedPages";
|
||||
|
|
@ -11,6 +14,13 @@ import { migratedHref } from "@/utils/migratedPages";
|
|||
const GATEWAY = "ai-gateway";
|
||||
const CHAT = "chat";
|
||||
|
||||
interface ViewSwitcherItem {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function ViewSwitcher() {
|
||||
const { mode, setMode, plugins } = usePluginMode();
|
||||
const { data: uiSettings } = useUISettings();
|
||||
|
|
@ -29,15 +39,25 @@ export default function ViewSwitcher() {
|
|||
...plugins.map((p) => ({ key: p.name, label: p.display_name })),
|
||||
];
|
||||
|
||||
const chatItem = chatEnabled
|
||||
const selectMode = (key: string) => {
|
||||
setMode(key);
|
||||
// The chat route lives outside the dashboard SPA shell that reacts to `mode`,
|
||||
// so switching modes from there needs a real navigation, not just state.
|
||||
if (isChatRoute) {
|
||||
window.location.assign(migratedHref(""));
|
||||
}
|
||||
};
|
||||
|
||||
const chatItem: ViewSwitcherItem = chatEnabled
|
||||
? {
|
||||
key: CHAT,
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-6 py-0.5">
|
||||
<span className="font-medium">Chat</span>
|
||||
{isChatRoute && <CheckOutlined className="text-blue-600" />}
|
||||
{isChatRoute && <Check className="size-4 text-blue-600" />}
|
||||
</div>
|
||||
),
|
||||
onClick: () => window.location.assign(migratedHref(CHAT)),
|
||||
}
|
||||
: {
|
||||
key: CHAT,
|
||||
|
|
@ -52,44 +72,43 @@ export default function ViewSwitcher() {
|
|||
),
|
||||
};
|
||||
|
||||
const items: MenuProps["items"] = [
|
||||
const items: ViewSwitcherItem[] = [
|
||||
...modeEntries.map((e) => ({
|
||||
key: e.key,
|
||||
label: (
|
||||
<div className="flex items-center justify-between gap-6 py-0.5">
|
||||
<span className="font-medium">{e.label}</span>
|
||||
{!isChatRoute && e.key === mode && <CheckOutlined className="text-blue-600" />}
|
||||
{!isChatRoute && e.key === mode && <Check className="size-4 text-blue-600" />}
|
||||
</div>
|
||||
),
|
||||
onClick: () => selectMode(e.key),
|
||||
})),
|
||||
chatItem,
|
||||
];
|
||||
|
||||
const onClick: MenuProps["onClick"] = ({ key }) => {
|
||||
if (key === CHAT) {
|
||||
window.location.assign(migratedHref(CHAT));
|
||||
return;
|
||||
}
|
||||
setMode(key);
|
||||
// The chat route lives outside the dashboard SPA shell that reacts to `mode`,
|
||||
// so switching modes from there needs a real navigation, not just state.
|
||||
if (isChatRoute) {
|
||||
window.location.assign(migratedHref(""));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items, onClick, selectedKeys: [isChatRoute ? CHAT : mode] }} trigger={["click"]}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-background pl-1.5 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-background pl-1.5 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="flex size-5 flex-none items-center justify-center rounded bg-muted text-muted-foreground">
|
||||
<AppstoreOutlined className="text-[13px]" />
|
||||
<LayoutGrid className="size-[13px]" />
|
||||
</span>
|
||||
<span className="truncate">{activeLabel}</span>
|
||||
<ChevronsUpDown className="size-3.5 flex-none text-muted-foreground" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto">
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem key={item.key} disabled={item.disabled} onClick={item.onClick}>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,21 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the useWorker hook
|
||||
const mockUseWorker = vi.fn();
|
||||
vi.mock("@/hooks/useWorker", () => ({
|
||||
useWorker: () => mockUseWorker(),
|
||||
}));
|
||||
|
||||
// Mock antd Select
|
||||
vi.mock("antd", () => ({
|
||||
Select: ({ value, options, onChange, style, disabled, ...props }: any) => (
|
||||
<select data-testid="worker-select" value={value} style={style} onChange={(e) => onChange?.(e.target.value)}>
|
||||
{options?.map((opt: any) => (
|
||||
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock icon
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
CloudServerOutlined: () => <span data-testid="cloud-icon" />,
|
||||
}));
|
||||
|
||||
import WorkerDropdown from "./WorkerDropdown";
|
||||
|
||||
async function openWorkerList(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
}
|
||||
|
||||
describe("WorkerDropdown", () => {
|
||||
const mockOnWorkerSwitch = vi.fn();
|
||||
const workers = [
|
||||
|
|
@ -61,31 +50,7 @@ describe("WorkerDropdown", () => {
|
|||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders the select when isControlPlane and selectedWorker exist", () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[0],
|
||||
workers,
|
||||
});
|
||||
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
expect(screen.getByTestId("worker-select")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all worker options", () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[0],
|
||||
workers,
|
||||
});
|
||||
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
expect(screen.getByText("Worker 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Worker 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Worker 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets current worker as selected value", () => {
|
||||
it("renders a collapsed worker combobox when isControlPlane and selectedWorker exist", () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[1],
|
||||
|
|
@ -93,37 +58,109 @@ describe("WorkerDropdown", () => {
|
|||
});
|
||||
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
const select = screen.getByTestId("worker-select") as HTMLSelectElement;
|
||||
expect(select.value).toBe("w2");
|
||||
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("disables the currently selected worker in options", () => {
|
||||
it("reveals every worker only once the combobox is opened", async () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[0],
|
||||
selectedWorker: workers[1],
|
||||
workers,
|
||||
});
|
||||
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
const options = screen.getAllByRole("option");
|
||||
const selectedOption = options.find((opt) => (opt as HTMLOptionElement).value === "w1");
|
||||
expect(selectedOption).toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls onWorkerSwitch when selection changes", async () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[0],
|
||||
workers,
|
||||
});
|
||||
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
const select = screen.getByTestId("worker-select");
|
||||
|
||||
const { default: userEvent } = await import("@testing-library/user-event");
|
||||
const user = userEvent.setup();
|
||||
await user.selectOptions(select, "w2");
|
||||
|
||||
expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w2");
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
expect(screen.queryAllByRole("option")).toHaveLength(0);
|
||||
expect(screen.queryByText("Worker 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Worker 3")).not.toBeInTheDocument();
|
||||
|
||||
await openWorkerList(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Worker 1")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText("Worker 2").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Worker 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks exactly one option as selected, the current worker", async () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[1],
|
||||
workers,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
await openWorkerList(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const selected = screen.getAllByRole("option").filter((o) => o.getAttribute("aria-selected") === "true");
|
||||
expect(selected).toHaveLength(1);
|
||||
expect(selected[0]).toHaveAccessibleName("Worker 2");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onWorkerSwitch with the id of the worker that was picked", async () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[1],
|
||||
workers,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
await openWorkerList(user);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Worker 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Worker 3"));
|
||||
|
||||
expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w3");
|
||||
});
|
||||
|
||||
it("does not call onWorkerSwitch when the already-current worker is picked", async () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[1],
|
||||
workers,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
await openWorkerList(user);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Worker 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
for (const currentWorkerNode of screen.getAllByText("Worker 2")) {
|
||||
fireEvent.click(currentWorkerNode);
|
||||
}
|
||||
|
||||
expect(mockOnWorkerSwitch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters the worker options by the typed search text", async () => {
|
||||
mockUseWorker.mockReturnValue({
|
||||
isControlPlane: true,
|
||||
selectedWorker: workers[1],
|
||||
workers,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
|
||||
await openWorkerList(user);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Worker 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(screen.getByRole("combobox"));
|
||||
await user.type(screen.getByRole("combobox"), "worker 3");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Worker 1")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Worker 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,35 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Select } from "antd";
|
||||
import { CloudServerOutlined } from "@ant-design/icons";
|
||||
import { Server } from "lucide-react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { InputGroupAddon } from "@/components/ui/input-group";
|
||||
import { useWorker } from "@/hooks/useWorker";
|
||||
|
||||
interface WorkerDropdownProps {
|
||||
onWorkerSwitch: (workerId: string) => void;
|
||||
}
|
||||
|
||||
interface WorkerOption {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const WorkerDropdown: React.FC<WorkerDropdownProps> = ({ onWorkerSwitch }) => {
|
||||
const { isControlPlane, selectedWorker, workers } = useWorker();
|
||||
|
||||
if (!isControlPlane || !selectedWorker) return null;
|
||||
|
||||
const options: WorkerOption[] = workers.map((w) => ({
|
||||
label: w.name,
|
||||
value: w.worker_id,
|
||||
disabled: w.worker_id === selectedWorker.worker_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={(input, option) => ((option?.label as string) ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
value={selectedWorker.worker_id}
|
||||
style={{ minWidth: 180 }}
|
||||
suffixIcon={<CloudServerOutlined />}
|
||||
options={workers.map((w) => ({
|
||||
label: w.name,
|
||||
value: w.worker_id,
|
||||
disabled: w.worker_id === selectedWorker.worker_id,
|
||||
}))}
|
||||
onChange={(newWorkerId) => {
|
||||
onWorkerSwitch(newWorkerId);
|
||||
<Combobox
|
||||
items={options}
|
||||
value={options.find((option) => option.value === selectedWorker.worker_id) ?? null}
|
||||
itemToStringLabel={(option: WorkerOption) => option.label}
|
||||
onValueChange={(option: WorkerOption | null) => {
|
||||
if (option) {
|
||||
onWorkerSwitch(option.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<ComboboxInput className="min-w-[180px]" aria-label="Worker">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Server className="size-4" />
|
||||
</InputGroupAddon>
|
||||
</ComboboxInput>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching workers</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: WorkerOption) => (
|
||||
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import BulkCreateUsersButton from "./bulk_create_users_button";
|
||||
|
||||
|
|
@ -20,9 +21,55 @@ vi.mock("./molecules/notifications_manager", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const csvFile = () =>
|
||||
new File(["user_email,user_role\nnew.hire@example.com,internal_user\n"], "users.csv", { type: "text/csv" });
|
||||
|
||||
const openUploadStep = async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
|
||||
await user.click(screen.getByText("+ Bulk Invite Users"));
|
||||
return user;
|
||||
};
|
||||
|
||||
describe("BulkCreateUsersButton", () => {
|
||||
it("should render", () => {
|
||||
const { getByText } = render(<BulkCreateUsersButton accessToken="test-token" teams={[]} possibleUIRoles={null} />);
|
||||
expect(getByText("+ Bulk Invite Users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("parses a CSV chosen through the file input", async () => {
|
||||
await openUploadStep();
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput, { target: { files: [csvFile()] } });
|
||||
|
||||
expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("parses a CSV dropped onto the drop zone", async () => {
|
||||
await openUploadStep();
|
||||
|
||||
const dropZone = screen.getByLabelText(/drag and drop your csv file here/i).closest("label");
|
||||
fireEvent.drop(dropZone as HTMLLabelElement, { dataTransfer: { files: [csvFile()], types: ["Files"] } });
|
||||
|
||||
expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes the drop zone as a label for a keyboard-reachable file input", async () => {
|
||||
await openUploadStep();
|
||||
|
||||
const fileInput = screen.getByLabelText(/drag and drop your csv file here/i) as HTMLInputElement;
|
||||
expect(fileInput).toHaveAttribute("type", "file");
|
||||
expect(fileInput).toHaveAttribute("accept", ".csv");
|
||||
expect(fileInput).toBeVisible();
|
||||
|
||||
const dropZone = fileInput.closest("label") as HTMLLabelElement;
|
||||
expect(fileInput.id).not.toBe("");
|
||||
expect(dropZone.htmlFor).toBe(fileInput.id);
|
||||
|
||||
const danglingLabels = [...document.querySelectorAll("label[for]")].filter(
|
||||
(label) => document.getElementById(label.getAttribute("for") as string) === null,
|
||||
);
|
||||
expect(danglingLabels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Text } from "@tremor/react";
|
||||
import { Button, Modal, Table, Upload, Typography } from "antd";
|
||||
import {
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
WarningOutlined,
|
||||
FileTextOutlined,
|
||||
DeleteOutlined,
|
||||
FileExclamationOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Download, FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react";
|
||||
import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking";
|
||||
import Papa from "papaparse";
|
||||
import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline";
|
||||
|
|
@ -38,6 +32,8 @@ interface UserData {
|
|||
invitation_link?: string;
|
||||
}
|
||||
|
||||
const PREVIEW_PAGE_SIZE = 5;
|
||||
|
||||
// Define an interface for the UI settings
|
||||
interface UISettings {
|
||||
PROXY_BASE_URL: string | null;
|
||||
|
|
@ -61,6 +57,9 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState("http://localhost:4000");
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const csvInputId = React.useId();
|
||||
|
||||
useEffect(() => {
|
||||
// Get UI settings
|
||||
|
|
@ -93,7 +92,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
if (file.type !== "text/csv" && !file.name.endsWith(".csv")) {
|
||||
setFileError(`Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`);
|
||||
NotificationsManager.fromBackend("Invalid file type. Please upload a CSV file.");
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file size (limit to 5MB)
|
||||
|
|
@ -101,7 +100,7 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
setFileError(
|
||||
`File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`,
|
||||
);
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
Papa.parse(file, {
|
||||
|
|
@ -262,7 +261,27 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
},
|
||||
header: false,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(true);
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelectedFile = () => {
|
||||
|
|
@ -273,6 +292,12 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
setFileError(null);
|
||||
};
|
||||
|
||||
const resetParsedData = () => {
|
||||
setParsedData([]);
|
||||
setParseError(null);
|
||||
setPageIndex(0);
|
||||
};
|
||||
|
||||
const handleBulkCreate = async () => {
|
||||
setIsProcessing(true);
|
||||
const updatedData = parsedData.map((user) => ({ ...user, status: "pending" }));
|
||||
|
|
@ -434,340 +459,395 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Row",
|
||||
dataIndex: "rowNumber",
|
||||
key: "rowNumber",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "Email",
|
||||
dataIndex: "user_email",
|
||||
key: "user_email",
|
||||
},
|
||||
{
|
||||
title: "Role",
|
||||
dataIndex: "user_role",
|
||||
key: "user_role",
|
||||
},
|
||||
{
|
||||
title: "Teams",
|
||||
dataIndex: "teams",
|
||||
key: "teams",
|
||||
},
|
||||
{
|
||||
title: "Budget",
|
||||
dataIndex: "max_budget",
|
||||
key: "max_budget",
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
key: "status",
|
||||
render: (_: any, record: UserData) => {
|
||||
if (!record.isValid) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<span className="text-red-500">Invalid</span>
|
||||
</div>
|
||||
{record.error && <span className="text-sm text-red-500 ml-7">{record.error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!record.status || record.status === "pending") {
|
||||
return <span className="text-gray-500">Pending</span>;
|
||||
}
|
||||
if (record.status === "success") {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
|
||||
<span className="text-green-500">Success</span>
|
||||
</div>
|
||||
{record.invitation_link && (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs text-gray-500 truncate max-w-[150px]">{record.invitation_link}</span>
|
||||
<CopyToClipboard
|
||||
text={record.invitation_link}
|
||||
onCopy={() => NotificationsManager.success("Invitation link copied!")}
|
||||
>
|
||||
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">Copy</button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<span className="text-red-500">Failed</span>
|
||||
</div>
|
||||
{record.error && <span className="text-sm text-red-500 ml-7">{JSON.stringify(record.error)}</span>}
|
||||
const renderStatusCell = (record: UserData) => {
|
||||
if (!record.isValid) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<span className="text-red-500">Invalid</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
{record.error && <span className="text-sm text-red-500 ml-7">{record.error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!record.status || record.status === "pending") {
|
||||
return <span className="text-gray-500">Pending</span>;
|
||||
}
|
||||
if (record.status === "success") {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
|
||||
<span className="text-green-500">Success</span>
|
||||
</div>
|
||||
{record.invitation_link && (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs text-gray-500 truncate max-w-[150px]">{record.invitation_link}</span>
|
||||
<CopyToClipboard
|
||||
text={record.invitation_link}
|
||||
onCopy={() => NotificationsManager.success("Invitation link copied!")}
|
||||
>
|
||||
<button className="ml-1 text-blue-500 text-xs hover:text-blue-700">Copy</button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<span className="text-red-500">Failed</span>
|
||||
</div>
|
||||
{record.error && <span className="text-sm text-red-500 ml-7">{JSON.stringify(record.error)}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(parsedData.length / PREVIEW_PAGE_SIZE));
|
||||
const currentPage = Math.min(pageIndex, pageCount - 1);
|
||||
const visibleRows = parsedData.slice(currentPage * PREVIEW_PAGE_SIZE, (currentPage + 1) * PREVIEW_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" className="mb-0" onClick={() => setIsModalVisible(true)}>
|
||||
<Button className="mb-0" onClick={() => setIsModalVisible(true)}>
|
||||
+ Bulk Invite Users
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
title="Bulk Invite Users"
|
||||
open={isModalVisible}
|
||||
width={800}
|
||||
onCancel={() => setIsModalVisible(false)}
|
||||
bodyStyle={{ maxHeight: "70vh", overflow: "auto" }}
|
||||
footer={null}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{/* Step indicator */}
|
||||
{parsedData.length === 0 ? (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
|
||||
1
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">Download and fill the template</h3>
|
||||
</div>
|
||||
|
||||
<div className="ml-11 mb-6">
|
||||
<p className="mb-4">Add multiple users at once by following these steps:</p>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
|
||||
<li>Download our CSV template</li>
|
||||
<li>Add your users' information to the spreadsheet</li>
|
||||
<li>Save the file and upload it here</li>
|
||||
<li>After creation, download the results file containing the Virtual Keys for each user</li>
|
||||
</ol>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
|
||||
<h4 className="font-medium mb-2">Template Column Names</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">user_email</p>
|
||||
<p className="text-sm text-gray-600">User's email address (required)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">user_role</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
User's role (one of: "proxy_admin", "proxy_admin_viewer",
|
||||
"internal_user", "internal_user_viewer")
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">teams</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Comma-separated team IDs (e.g., "team-1,team-2")
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">max_budget</p>
|
||||
<p className="text-sm text-gray-600">Maximum budget as a number (e.g., "100")</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">budget_duration</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Budget reset period (e.g., "30d", "1mo")
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">models</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4")
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={isModalVisible} onOpenChange={(open) => !open && setIsModalVisible(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Invite Users</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col">
|
||||
{/* Step indicator */}
|
||||
{parsedData.length === 0 ? (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
|
||||
1
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">Download and fill the template</h3>
|
||||
</div>
|
||||
|
||||
<Button type="primary" size="large" className="w-full md:w-auto" icon={<DownloadOutlined />}>
|
||||
Download CSV Template
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ml-11 mb-6">
|
||||
<p className="mb-4">Add multiple users at once by following these steps:</p>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-2 mb-4">
|
||||
<li>Download our CSV template</li>
|
||||
<li>Add your users' information to the spreadsheet</li>
|
||||
<li>Save the file and upload it here</li>
|
||||
<li>After creation, download the results file containing the Virtual Keys for each user</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
|
||||
2
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
|
||||
</div>
|
||||
|
||||
<div className="ml-11">
|
||||
{selectedFile ? (
|
||||
<div
|
||||
className={`mb-4 p-4 rounded-md border ${fileError ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{fileError ? (
|
||||
<FileExclamationOutlined className="text-red-500 text-xl mr-3" />
|
||||
) : (
|
||||
<FileTextOutlined className="text-blue-500 text-xl mr-3" />
|
||||
)}
|
||||
<div className="bg-gray-50 p-4 rounded-md border border-gray-200 mb-4">
|
||||
<h4 className="font-medium mb-2">Template Column Names</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<Typography.Text strong className={fileError ? "text-red-800" : "text-blue-800"}>
|
||||
{selectedFile.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={`block text-xs ${fileError ? "text-red-600" : "text-blue-600"}`}>
|
||||
{(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()}
|
||||
</Typography.Text>
|
||||
<p className="font-medium">user_email</p>
|
||||
<p className="text-sm text-gray-600">User's email address (required)</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={removeSelectedFile}
|
||||
className="flex items-center"
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fileError ? (
|
||||
<div className="mt-3 text-red-600 text-sm flex items-start">
|
||||
<WarningOutlined className="mr-2 mt-0.5" />
|
||||
<span>{fileError}</span>
|
||||
</div>
|
||||
) : (
|
||||
!csvStructureError && (
|
||||
<div className="mt-3 flex items-center">
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div className="bg-blue-500 h-1.5 rounded-full w-full animate-pulse"></div>
|
||||
</div>
|
||||
<span className="ml-2 text-xs text-blue-600">Processing...</span>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">user_role</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
User's role (one of: "proxy_admin", "proxy_admin_viewer",
|
||||
"internal_user", "internal_user_viewer")
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">teams</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Comma-separated team IDs (e.g., "team-1,team-2")
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">max_budget</p>
|
||||
<p className="text-sm text-gray-600">Maximum budget as a number (e.g., "100")</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">budget_duration</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Budget reset period (e.g., "30d", "1mo")
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 mt-1.5 mr-2 shrink-0"></div>
|
||||
<div>
|
||||
<p className="font-medium">models</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4")
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Upload beforeUpload={handleFileUpload} accept=".csv" maxCount={1} showUploadList={false}>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer">
|
||||
<UploadOutlined className="text-3xl text-gray-400 mb-2" />
|
||||
<p className="mb-1">Drag and drop your CSV file here</p>
|
||||
<p className="text-sm text-gray-500 mb-3">or</p>
|
||||
<Button size="small">Browse files</Button>
|
||||
<p className="text-xs text-gray-500 mt-4">Only CSV files (.csv) are supported</p>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
|
||||
{csvStructureError && (
|
||||
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-500 mr-2 mt-0.5" />
|
||||
<div>
|
||||
<Typography.Text strong className="text-yellow-800">
|
||||
CSV Structure Error
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph className="text-yellow-700 mt-1 mb-0">
|
||||
{csvStructureError}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph className="text-yellow-700 mt-2 mb-0">
|
||||
Please download our template and ensure your CSV follows the required format.
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
|
||||
3
|
||||
|
||||
<Button size="lg" className="w-full md:w-auto">
|
||||
<Download className="size-4" />
|
||||
Download CSV Template
|
||||
</Button>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">
|
||||
{parsedData.some((user) => user.status === "success" || user.status === "failed")
|
||||
? "User Creation Results"
|
||||
: "Review and create users"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{parseError && (
|
||||
<div className="ml-11 mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<WarningOutlined className="text-red-500 mr-2 mt-1" />
|
||||
<div>
|
||||
<Text className="text-red-600 font-medium">{parseError}</Text>
|
||||
{parsedData.some((user) => !user.isValid) && (
|
||||
<ul className="mt-2 list-disc list-inside text-red-600 text-sm">
|
||||
<li>Check the table below for specific errors in each row</li>
|
||||
<li>
|
||||
Common issues include invalid email formats, missing required fields, or incorrect role
|
||||
values
|
||||
</li>
|
||||
<li>Fix these issues in your CSV file and upload again</li>
|
||||
</ul>
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
|
||||
2
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">Upload your completed CSV</h3>
|
||||
</div>
|
||||
|
||||
<div className="ml-11">
|
||||
{selectedFile ? (
|
||||
<div
|
||||
className={`mb-4 p-4 rounded-md border ${fileError ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center min-w-0">
|
||||
{fileError ? (
|
||||
<FileWarning className="size-5 shrink-0 text-red-500 mr-3" />
|
||||
) : (
|
||||
<FileText className="size-5 shrink-0 text-blue-500 mr-3" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<strong className={`break-words ${fileError ? "text-red-800" : "text-blue-800"}`}>
|
||||
{selectedFile.name}
|
||||
</strong>
|
||||
<span className={`block text-xs ${fileError ? "text-red-600" : "text-blue-600"}`}>
|
||||
{(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={removeSelectedFile} className="flex items-center">
|
||||
<Trash2 className="size-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fileError ? (
|
||||
<div className="mt-3 text-red-600 text-sm flex items-start">
|
||||
<TriangleAlert className="size-3.5 shrink-0 mr-2 mt-0.5" />
|
||||
<span className="min-w-0 break-words">{fileError}</span>
|
||||
</div>
|
||||
) : (
|
||||
!csvStructureError && (
|
||||
<div className="mt-3 flex items-center">
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div className="bg-blue-500 h-1.5 rounded-full w-full animate-pulse"></div>
|
||||
</div>
|
||||
<span className="ml-2 text-xs text-blue-600">Processing...</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<label
|
||||
htmlFor={csvInputId}
|
||||
className="block"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={() => setIsDraggingOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
className={`border-2 border-dashed ${isDraggingOver ? "border-blue-500" : "border-gray-300"} rounded-lg p-8 text-center hover:border-blue-500 focus-within:border-blue-500 transition-colors cursor-pointer`}
|
||||
>
|
||||
<input
|
||||
id={csvInputId}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="sr-only"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<Upload className="size-[30px] text-gray-400 mb-2" />
|
||||
<p className="mb-1">Drag and drop your CSV file here</p>
|
||||
<p className="text-sm text-gray-500 mb-3">or</p>
|
||||
<span className={buttonVariants({ variant: "outline", size: "sm" })}>Browse files</span>
|
||||
<p className="text-xs text-gray-500 mt-4">Only CSV files (.csv) are supported</p>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="ml-11">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center">
|
||||
{parsedData.some((user) => user.status === "success" || user.status === "failed") ? (
|
||||
<div className="flex items-center">
|
||||
<Text className="text-lg font-medium mr-3">Creation Summary</Text>
|
||||
<Text className="text-sm bg-green-100 text-green-800 px-2 py-1 rounded-sm mr-2">
|
||||
{parsedData.filter((d) => d.status === "success").length} Successful
|
||||
</Text>
|
||||
{parsedData.some((d) => d.status === "failed") && (
|
||||
<Text className="text-sm bg-red-100 text-red-800 px-2 py-1 rounded-sm">
|
||||
{parsedData.filter((d) => d.status === "failed").length} Failed
|
||||
</Text>
|
||||
{csvStructureError && (
|
||||
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<ExclamationIcon className="h-5 w-5 shrink-0 text-yellow-500 mr-2 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<strong className="text-yellow-800">CSV Structure Error</strong>
|
||||
<p className="text-yellow-700 mt-1 mb-0 break-words">{csvStructureError}</p>
|
||||
<p className="text-yellow-700 mt-2 mb-0">
|
||||
Please download our template and ensure your CSV follows the required format.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center mr-3">
|
||||
3
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">
|
||||
{parsedData.some((user) => user.status === "success" || user.status === "failed")
|
||||
? "User Creation Results"
|
||||
: "Review and create users"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{parseError && (
|
||||
<div className="ml-11 mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<TriangleAlert className="size-4 shrink-0 text-red-500 mr-2 mt-1" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-red-600 font-medium break-words">{parseError}</p>
|
||||
{parsedData.some((user) => !user.isValid) && (
|
||||
<ul className="mt-2 list-disc list-inside text-red-600 text-sm">
|
||||
<li>Check the table below for specific errors in each row</li>
|
||||
<li>
|
||||
Common issues include invalid email formats, missing required fields, or incorrect role
|
||||
values
|
||||
</li>
|
||||
<li>Fix these issues in your CSV file and upload again</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<Text className="text-lg font-medium mr-3">User Preview</Text>
|
||||
<Text className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded-sm">
|
||||
{parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ml-11">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center">
|
||||
{parsedData.some((user) => user.status === "success" || user.status === "failed") ? (
|
||||
<div className="flex items-center">
|
||||
<p className="text-lg font-medium mr-3">Creation Summary</p>
|
||||
<p className="text-sm bg-green-100 text-green-800 px-2 py-1 rounded-sm mr-2">
|
||||
{parsedData.filter((d) => d.status === "success").length} Successful
|
||||
</p>
|
||||
{parsedData.some((d) => d.status === "failed") && (
|
||||
<p className="text-sm bg-red-100 text-red-800 px-2 py-1 rounded-sm">
|
||||
{parsedData.filter((d) => d.status === "failed").length} Failed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<p className="text-lg font-medium mr-3">User Preview</p>
|
||||
<p className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded-sm">
|
||||
{parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
|
||||
<div className="flex space-x-3">
|
||||
<Button variant="outline" onClick={resetParsedData}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBulkCreate}
|
||||
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
|
||||
>
|
||||
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
|
||||
<div className="flex space-x-3">
|
||||
{parsedData.some((user) => user.status === "success") && (
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<div className="mr-3 mt-1">
|
||||
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-blue-800">User creation complete</p>
|
||||
<p className="block text-sm text-blue-700 mt-1">
|
||||
<span className="font-medium">Next step:</span> Download the credentials file containing
|
||||
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
|
||||
through LiteLLM.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">Row</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Teams</TableHead>
|
||||
<TableHead>Budget</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleRows.map((record) => (
|
||||
<TableRow key={record.rowNumber} className={!record.isValid ? "bg-red-50" : ""}>
|
||||
<TableCell>{record.rowNumber}</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">{record.user_email}</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">{record.user_role}</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">{record.teams}</TableCell>
|
||||
<TableCell>{record.max_budget}</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">{renderStatusCell(record)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center justify-end gap-3 mt-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
Page {currentPage + 1} of {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setParsedData([]);
|
||||
setParseError(null);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex(currentPage - 1)}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex(currentPage + 1)}
|
||||
disabled={currentPage >= pageCount - 1}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="outline" onClick={resetParsedData} className="mr-3">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleBulkCreate}
|
||||
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
|
||||
>
|
||||
|
|
@ -775,77 +855,24 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsedData.some((user) => user.status === "success") && (
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<div className="mr-3 mt-1">
|
||||
<CheckCircleIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium text-blue-800">User creation complete</Text>
|
||||
<Text className="block text-sm text-blue-700 mt-1">
|
||||
<span className="font-medium">Next step:</span> Download the credentials file containing
|
||||
Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests
|
||||
through LiteLLM.
|
||||
</Text>
|
||||
</div>
|
||||
{parsedData.some((user) => user.status === "success" || user.status === "failed") && (
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="outline" onClick={resetParsedData} className="mr-3">
|
||||
Start New Bulk Import
|
||||
</Button>
|
||||
<Button onClick={downloadResults}>
|
||||
<Download className="size-4" />
|
||||
Download User Credentials
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table
|
||||
dataSource={parsedData}
|
||||
columns={columns}
|
||||
size="small"
|
||||
pagination={{ pageSize: 5 }}
|
||||
scroll={{ y: 300 }}
|
||||
rowClassName={(record) => (!record.isValid ? "bg-red-50" : "")}
|
||||
/>
|
||||
|
||||
{!parsedData.some((user) => user.status === "success" || user.status === "failed") && (
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setParsedData([]);
|
||||
setParseError(null);
|
||||
}}
|
||||
className="mr-3"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleBulkCreate}
|
||||
disabled={parsedData.filter((d) => d.isValid).length === 0 || isProcessing}
|
||||
>
|
||||
{isProcessing ? "Creating..." : `Create ${parsedData.filter((d) => d.isValid).length} Users`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedData.some((user) => user.status === "success" || user.status === "failed") && (
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setParsedData([]);
|
||||
setParseError(null);
|
||||
}}
|
||||
className="mr-3"
|
||||
>
|
||||
Start New Bulk Import
|
||||
</Button>
|
||||
<Button type="primary" onClick={downloadResults} icon={<DownloadOutlined />}>
|
||||
Download User Credentials
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { Tag, Typography } from "antd";
|
||||
|
||||
const { Text } = Typography;
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const DEFAULT_USER_ID = "default_user_id";
|
||||
|
||||
|
|
@ -8,15 +6,10 @@ interface DefaultProxyAdminTagProps {
|
|||
userId: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders "Default Proxy Admin" as a blue Tag when the given userId is
|
||||
* the well-known `default_user_id`, otherwise renders the raw value as
|
||||
* plain text.
|
||||
*/
|
||||
export default function DefaultProxyAdminTag({ userId }: DefaultProxyAdminTagProps) {
|
||||
if (userId === DEFAULT_USER_ID) {
|
||||
return <Tag color="blue">Default Proxy Admin</Tag>;
|
||||
return <Badge variant="secondary">Default Proxy Admin</Badge>;
|
||||
}
|
||||
|
||||
return <Text>{userId}</Text>;
|
||||
return <span>{userId}</span>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,20 @@ describe("DeleteResourceModal", () => {
|
|||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call onCancel when escape is pressed and no deletion is in flight", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DeleteResourceModal {...defaultProps} />);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should ignore escape while confirmLoading is true so the modal cannot close mid-deletion", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DeleteResourceModal {...defaultProps} confirmLoading={true} />);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(mockOnCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should disable delete button when confirmLoading is true even if requiredConfirmation matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DeleteResourceModal {...defaultProps} confirmLoading={true} requiredConfirmation="DELETE" />);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { Alert, Card, Descriptions, Input, Modal, Typography, theme } from "antd";
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Alert, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
||||
interface DeleteResourceModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -8,12 +12,11 @@ interface DeleteResourceModalProps {
|
|||
alertMessage?: string;
|
||||
message: string;
|
||||
resourceInformationTitle?: string;
|
||||
resourceInformation?: Array<
|
||||
{
|
||||
label: string;
|
||||
value: string | number | undefined | null;
|
||||
} & Omit<React.ComponentProps<typeof Typography.Text>, "children">
|
||||
>;
|
||||
resourceInformation?: Array<{
|
||||
label: string;
|
||||
value: string | number | undefined | null;
|
||||
code?: boolean;
|
||||
}>;
|
||||
onCancel: () => void;
|
||||
onOk: () => void;
|
||||
confirmLoading: boolean;
|
||||
|
|
@ -32,8 +35,6 @@ export default function DeleteResourceModal({
|
|||
confirmLoading,
|
||||
requiredConfirmation,
|
||||
}: DeleteResourceModalProps) {
|
||||
const { Text } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
const [requiredConfirmationInput, setRequiredConfirmationInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -43,69 +44,69 @@ export default function DeleteResourceModal({
|
|||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={isOpen}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={confirmLoading}
|
||||
okText={confirmLoading ? "Deleting..." : "Delete"}
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{
|
||||
danger: true,
|
||||
disabled: (!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading,
|
||||
}}
|
||||
cancelButtonProps={{ disabled: confirmLoading }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{alertMessage && <Alert message={alertMessage} type="warning" />}
|
||||
<Card
|
||||
title={resourceInformationTitle}
|
||||
className="mt-4"
|
||||
styles={{
|
||||
body: { padding: "16px" },
|
||||
header: {
|
||||
backgroundColor: token.colorErrorBg,
|
||||
borderColor: token.colorErrorBorder,
|
||||
},
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: token.colorErrorBg,
|
||||
borderColor: token.colorErrorBorder,
|
||||
}}
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
{resourceInformation &&
|
||||
resourceInformation.map(({ label, value, ...textProps }) => (
|
||||
<Descriptions.Item key={label} label={<span className="font-semibold">{label}</span>}>
|
||||
<Text {...textProps}>{value ?? "-"}</Text>
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<div>
|
||||
<Text>{message}</Text>
|
||||
</div>
|
||||
{requiredConfirmation && (
|
||||
<div className="mb-6 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Text className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<Text>Type </Text>
|
||||
<Text strong type="danger">
|
||||
{requiredConfirmation}
|
||||
</Text>
|
||||
<Text> to confirm deletion:</Text>
|
||||
</Text>
|
||||
<Input
|
||||
value={requiredConfirmationInput}
|
||||
onChange={(e) => setRequiredConfirmationInput(e.target.value)}
|
||||
placeholder={requiredConfirmation}
|
||||
className="rounded-md"
|
||||
prefix={<ExclamationCircleOutlined style={{ color: token.colorError }} />}
|
||||
autoFocus
|
||||
/>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && !confirmLoading && onCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{alertMessage && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>{alertMessage}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
<Card size="sm" className="mt-4">
|
||||
{resourceInformationTitle && (
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>{resourceInformationTitle}</CardTitle>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1">
|
||||
{resourceInformation?.map(({ label, value, code }) => (
|
||||
<React.Fragment key={label}>
|
||||
<dt className="font-semibold">{label}</dt>
|
||||
<dd className="min-w-0 break-words">{code ? <code>{value ?? "-"}</code> : value ?? "-"}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
{requiredConfirmation && (
|
||||
<div className="mb-6 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Type <span className="font-semibold text-destructive">{requiredConfirmation}</span> to confirm deletion:
|
||||
</p>
|
||||
<InputGroup className="rounded-md">
|
||||
<InputGroupAddon>
|
||||
<CircleAlert className="size-3.5 text-destructive" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={requiredConfirmationInput}
|
||||
onChange={(e) => setRequiredConfirmationInput(e.target.value)}
|
||||
placeholder={requiredConfirmation}
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={confirmLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onOk}
|
||||
disabled={(!!requiredConfirmation && requiredConfirmationInput !== requiredConfirmation) || confirmLoading}
|
||||
>
|
||||
{confirmLoading ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { PlusCircleIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { Card, Title, Text, Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
|
||||
import { Card, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from "@/components/ui/table";
|
||||
import ModelSelector from "./ModelSelector";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
|
||||
|
|
@ -141,7 +142,7 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
|
|||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mb-6">
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Alias</Text>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Add New Alias</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Alias Name</label>
|
||||
|
|
@ -186,17 +187,17 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</Text>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</p>
|
||||
<div className="rounded-lg custom-border relative mb-6">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
<TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="py-1 h-8">Alias Name</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">Target Model</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">Actions</TableHeaderCell>
|
||||
<TableHead className="py-1 h-8">Alias Name</TableHead>
|
||||
<TableHead className="py-1 h-8">Target Model</TableHead>
|
||||
<TableHead className="py-1 h-8">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{aliases.map((alias) => (
|
||||
<TableRow key={alias.id} className="h-8">
|
||||
|
|
@ -284,9 +285,9 @@ const ModelAliasManager: React.FC<ModelAliasManagerProps> = ({
|
|||
|
||||
{/* Configuration Example */}
|
||||
{showExampleConfig && (
|
||||
<Card>
|
||||
<Title className="mb-4">Configuration Example</Title>
|
||||
<Text className="text-gray-600 mb-4">Here's how your current aliases would look in the config:</Text>
|
||||
<Card className="px-6">
|
||||
<CardTitle className="mb-4">Configuration Example</CardTitle>
|
||||
<p className="text-gray-600 mb-4">Here's how your current aliases would look in the config:</p>
|
||||
<div className="bg-gray-100 rounded-lg p-4 font-mono text-sm">
|
||||
<div className="text-gray-700">
|
||||
model_aliases:
|
||||
|
|
|
|||
|
|
@ -1,28 +1,20 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelSelector from "./ModelSelector";
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const openCustomModelInput = () => {
|
||||
const selector = document.querySelector(".ant-select-selector");
|
||||
expect(selector).toBeTruthy();
|
||||
act(() => {
|
||||
fireEvent.mouseDown(selector!);
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Enter custom model"));
|
||||
});
|
||||
const openCustomModelInput = async () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByText("Enter custom model"));
|
||||
return screen.getByPlaceholderText("Enter custom model name");
|
||||
};
|
||||
|
||||
describe("ModelSelector custom model debounce", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
|
|
@ -30,11 +22,12 @@ describe("ModelSelector custom model debounce", () => {
|
|||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not call onChange before the debounce wait elapses", () => {
|
||||
it("does not call onChange before the debounce wait elapses", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
|
||||
|
||||
const input = openCustomModelInput();
|
||||
const input = await openCustomModelInput();
|
||||
vi.useFakeTimers();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "gpt-4o" } });
|
||||
|
|
@ -49,11 +42,12 @@ describe("ModelSelector custom model debounce", () => {
|
|||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onChange exactly once with the last typed value after the wait", () => {
|
||||
it("calls onChange exactly once with the last typed value after the wait", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
|
||||
|
||||
const input = openCustomModelInput();
|
||||
const input = await openCustomModelInput();
|
||||
vi.useFakeTimers();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "g" } });
|
||||
|
|
@ -71,11 +65,12 @@ describe("ModelSelector custom model debounce", () => {
|
|||
expect(onChange).toHaveBeenCalledWith("gpt-5.2");
|
||||
});
|
||||
|
||||
it("does not call onChange when unmounted mid-wait", () => {
|
||||
it("does not call onChange when unmounted mid-wait", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { unmount } = render(<ModelSelector accessToken="test-token" onChange={onChange} />);
|
||||
|
||||
const input = openCustomModelInput();
|
||||
const input = await openCustomModelInput();
|
||||
vi.useFakeTimers();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value: "gpt-4o" } });
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { TextInput, Text } from "@tremor/react";
|
||||
import { Select } from "antd";
|
||||
import { RobotOutlined } from "@ant-design/icons";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
|
||||
const MODEL_SELECT_DEBOUNCE_MS = 500;
|
||||
|
|
@ -80,32 +80,30 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
return (
|
||||
<div>
|
||||
{showLabel && (
|
||||
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
|
||||
<RobotOutlined className="mr-2" /> {labelText}
|
||||
</Text>
|
||||
<p className="font-medium block mb-2 text-gray-700 flex items-center">
|
||||
<Bot className="mr-2 size-3.5" /> {labelText}
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
value={selectedModel}
|
||||
placeholder={placeholder}
|
||||
onChange={onModelChange}
|
||||
options={[
|
||||
...Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group, index) => ({
|
||||
value: model_group,
|
||||
label: model_group,
|
||||
key: index,
|
||||
})),
|
||||
{ value: "custom", label: "Enter custom model", key: "custom" },
|
||||
]}
|
||||
style={{ width: "100%", ...style }}
|
||||
showSearch={true}
|
||||
className={`rounded-md ${className || ""}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div style={{ width: "100%", ...style }} className={`rounded-md ${className || ""}`}>
|
||||
<SearchSelect
|
||||
options={[
|
||||
...Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group) => ({
|
||||
value: model_group,
|
||||
label: model_group,
|
||||
})),
|
||||
{ value: "custom", label: "Enter custom model" },
|
||||
]}
|
||||
value={selectedModel}
|
||||
placeholder={placeholder}
|
||||
onValueChange={onModelChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{showCustomModelInput && (
|
||||
<TextInput
|
||||
<Input
|
||||
className="mt-2"
|
||||
placeholder="Enter custom model name"
|
||||
onValueChange={debouncedSelect}
|
||||
onChange={(e) => debouncedSelect(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -54,12 +54,28 @@ describe("OrganizationDropdown", () => {
|
|||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(await screen.findByText("Engineering"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("org-1", expect.anything());
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0][0]).toBe("org-1");
|
||||
});
|
||||
|
||||
it("should add ant-select-disabled class when disabled prop is true", () => {
|
||||
const { container } = render(<OrganizationDropdown organizations={MOCK_ORGS} disabled={true} />);
|
||||
expect(container.querySelector(".ant-select-disabled")).toBeTruthy();
|
||||
it("should filter options by organization id", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OrganizationDropdown organizations={MOCK_ORGS} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.type(screen.getByRole("combobox"), "org-2");
|
||||
|
||||
expect(await screen.findByText("Sales")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not open the option list when disabled prop is true", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OrganizationDropdown organizations={MOCK_ORGS} disabled={true} />);
|
||||
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with empty organizations list", () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import React from "react";
|
||||
import { Select, Typography } from "antd";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { Organization } from "../networking";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface OrganizationDropdownProps {
|
||||
organizations?: Organization[] | null;
|
||||
value?: string;
|
||||
|
|
@ -12,6 +10,7 @@ interface OrganizationDropdownProps {
|
|||
loading?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
|
||||
|
|
@ -22,36 +21,24 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
|
|||
loading,
|
||||
style,
|
||||
placeholder = "All Organizations",
|
||||
id,
|
||||
}) => {
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
allowClear
|
||||
style={{ minWidth: 280, ...style }}
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const org = organizations?.find((o) => o.organization_id === option.key);
|
||||
if (!org) return false;
|
||||
|
||||
const searchTerm = input.toLowerCase().trim();
|
||||
const orgAlias = (org.organization_alias || "").toLowerCase();
|
||||
const orgId = (org.organization_id || "").toLowerCase();
|
||||
|
||||
return orgAlias.includes(searchTerm) || orgId.includes(searchTerm);
|
||||
}}
|
||||
>
|
||||
{organizations?.map((org) => (
|
||||
<Select.Option key={org.organization_id} value={org.organization_id}>
|
||||
<span className="font-medium">{org.organization_alias}</span>{" "}
|
||||
<Text type="secondary">({org.organization_id})</Text>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<div style={{ minWidth: 280, ...style }}>
|
||||
<SearchSelect
|
||||
options={(organizations ?? []).map((org) => ({
|
||||
label: org.organization_alias || org.organization_id,
|
||||
value: org.organization_id,
|
||||
sublabel: org.organization_id,
|
||||
}))}
|
||||
value={value}
|
||||
onValueChange={(organizationId) => onChange?.(organizationId)}
|
||||
placeholder={placeholder}
|
||||
emptyText={loading ? "Loading organizations…" : "No organizations found"}
|
||||
disabled={disabled}
|
||||
inputId={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
|
||||
import { getPassThroughEndpointsCall } from "../networking";
|
||||
|
||||
interface PassThroughRoutesSelectorProps {
|
||||
|
|
@ -17,6 +17,11 @@ interface PassThroughEndpoint {
|
|||
methods?: string[];
|
||||
}
|
||||
|
||||
const routeOption = (endpoint: PassThroughEndpoint): MultiSelectOption => ({
|
||||
label: endpoint.methods?.length ? `${endpoint.methods.join(", ")} ${endpoint.path}` : endpoint.path,
|
||||
value: endpoint.path,
|
||||
});
|
||||
|
||||
const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
|
||||
onChange,
|
||||
value,
|
||||
|
|
@ -26,7 +31,7 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
|
|||
disabled = false,
|
||||
teamId,
|
||||
}) => {
|
||||
const [passThroughRoutes, setPassThroughRoutes] = useState<Array<{ label: string; value: string }>>([]);
|
||||
const [passThroughRoutes, setPassThroughRoutes] = useState<MultiSelectOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -37,27 +42,7 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
|
|||
try {
|
||||
const response = await getPassThroughEndpointsCall(accessToken, teamId);
|
||||
if (response.endpoints) {
|
||||
const routes = response.endpoints.flatMap((endpoint: PassThroughEndpoint) => {
|
||||
const path = endpoint.path;
|
||||
const methods = endpoint.methods;
|
||||
|
||||
// If methods are specified, create one entry per method
|
||||
if (methods && methods.length > 0) {
|
||||
return methods.map((method) => ({
|
||||
label: `${method} ${path}`,
|
||||
value: path, // Keep value as path for backward compatibility
|
||||
}));
|
||||
}
|
||||
|
||||
// If no methods specified, show just the path (all methods supported)
|
||||
return [
|
||||
{
|
||||
label: path,
|
||||
value: path,
|
||||
},
|
||||
];
|
||||
});
|
||||
setPassThroughRoutes(routes);
|
||||
setPassThroughRoutes(response.endpoints.map(routeOption));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching pass through routes:", error);
|
||||
|
|
@ -70,19 +55,16 @@ const PassThroughRoutesSelector: React.FC<PassThroughRoutesSelectorProps> = ({
|
|||
}, [accessToken, teamId]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
className={className}
|
||||
allowClear
|
||||
<MultiSelect
|
||||
options={passThroughRoutes}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={value}
|
||||
onValueChange={(routes) => onChange?.(routes)}
|
||||
placeholder={placeholder}
|
||||
emptyText="No pass through routes found"
|
||||
loading={loading}
|
||||
allowCustomValues
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React from "react";
|
||||
import { Text } from "@tremor/react";
|
||||
import LoggingSettings from "../team/LoggingSettings";
|
||||
|
||||
interface PremiumLoggingSettingsProps {
|
||||
|
|
@ -29,14 +28,14 @@ export function PremiumLoggingSettings({
|
|||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<Text className="text-sm text-yellow-800">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Setting Key/Team logging settings is a LiteLLM Enterprise feature. Global Logging Settings are available for
|
||||
all free users. Get a trial key{" "}
|
||||
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from "react";
|
||||
import { Select, Spin } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
|
||||
interface ProjectDropdownProps {
|
||||
|
|
@ -11,42 +10,38 @@ interface ProjectDropdownProps {
|
|||
loading?: boolean;
|
||||
/** When set, only show projects belonging to this team */
|
||||
teamId?: string | null;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({ projects, value, onChange, disabled, loading, teamId }) => {
|
||||
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
|
||||
projects,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
loading,
|
||||
teamId,
|
||||
id,
|
||||
}) => {
|
||||
const filtered = teamId ? projects?.filter((p) => p.team_id === teamId) : projects;
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select a project"
|
||||
<SearchSelect
|
||||
options={
|
||||
loading
|
||||
? []
|
||||
: (filtered ?? []).map((project) => ({
|
||||
label: project.project_alias || project.project_id,
|
||||
value: project.project_id,
|
||||
sublabel: project.project_id,
|
||||
}))
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValueChange={(projectId) => onChange?.(projectId)}
|
||||
placeholder="Search or select a project"
|
||||
emptyText={loading ? "Loading projects…" : "No projects found"}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
allowClear
|
||||
notFoundContent={loading ? <Spin indicator={<LoadingOutlined spin />} size="small" /> : undefined}
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const project = filtered?.find((p) => p.project_id === option.key);
|
||||
if (!project) return false;
|
||||
|
||||
const searchTerm = input.toLowerCase().trim();
|
||||
const alias = (project.project_alias || "").toLowerCase();
|
||||
const id = (project.project_id || "").toLowerCase();
|
||||
|
||||
return alias.includes(searchTerm) || id.includes(searchTerm);
|
||||
}}
|
||||
optionFilterProp="children"
|
||||
>
|
||||
{!loading &&
|
||||
filtered?.map((project) => (
|
||||
<Select.Option key={project.project_id} value={project.project_id}>
|
||||
<span className="font-medium">{project.project_alias || project.project_id}</span>{" "}
|
||||
<span className="text-gray-500">({project.project_id})</span>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
inputId={id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -21,14 +21,6 @@ vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
TabGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Tab: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabPanels: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../router_settings/RouterSettingsForm", () => ({
|
||||
default: ({
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
|
||||
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { getRouterSettingsCall } from "../networking";
|
||||
|
|
@ -344,13 +344,13 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
|
|||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<TabGroup className="w-full">
|
||||
<TabList variant="line" defaultValue="1" className="px-8 pt-4">
|
||||
<Tab value="1">Loadbalancing</Tab>
|
||||
<Tab value="2">Fallbacks</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="px-8 py-6">
|
||||
<TabPanel>
|
||||
<Tabs defaultValue="1" className="w-full">
|
||||
<TabsList variant="line" className="px-8 pt-4">
|
||||
<TabsTrigger value="1">Loadbalancing</TabsTrigger>
|
||||
<TabsTrigger value="2">Fallbacks</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="px-8 py-6">
|
||||
<TabsContent value="1" keepMounted>
|
||||
<RouterSettingsForm
|
||||
value={formValue}
|
||||
onChange={setFormValue}
|
||||
|
|
@ -358,17 +358,17 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
|
|||
availableRoutingStrategies={availableRoutingStrategies}
|
||||
routingStrategyDescriptions={routingStrategyDescriptions}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
</TabsContent>
|
||||
<TabsContent value="2" keepMounted>
|
||||
<FallbackSelectionForm
|
||||
groups={fallbackGroups}
|
||||
onGroupsChange={handleFallbackGroupsChange}
|
||||
availableModels={availableModels}
|
||||
maxGroups={5}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import React from "react";
|
||||
import { Select } from "antd";
|
||||
|
||||
const { Option } = Select;
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
export const NEVER_RESETS_BUDGET_DURATION = "none";
|
||||
|
||||
const DURATION_LABELS: Record<string, string> = {
|
||||
[NEVER_RESETS_BUDGET_DURATION]: "Never resets",
|
||||
"1h": "hourly",
|
||||
"24h": "daily",
|
||||
"7d": "weekly",
|
||||
"30d": "monthly",
|
||||
};
|
||||
|
||||
interface BudgetDurationDropdownProps {
|
||||
value?: string | null;
|
||||
onChange?: (value: string | undefined) => void;
|
||||
|
|
@ -24,18 +30,21 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
|
|||
}) => {
|
||||
return (
|
||||
<Select
|
||||
style={{ width: "100%", ...style }}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
allowClear
|
||||
items={DURATION_LABELS}
|
||||
value={value || null}
|
||||
onValueChange={(next: string | null) => onChange?.(next ?? undefined)}
|
||||
>
|
||||
{showNeverResets ? <Option value={NEVER_RESETS_BUDGET_DURATION}>Never resets</Option> : null}
|
||||
<Option value="1h">hourly</Option>
|
||||
<Option value="24h">daily</Option>
|
||||
<Option value="7d">weekly</Option>
|
||||
<Option value="30d">monthly</Option>
|
||||
<SelectTrigger className={`w-full ${className}`} style={style}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>{placeholder}</SelectItem>
|
||||
{showNeverResets ? <SelectItem value={NEVER_RESETS_BUDGET_DURATION}>Never resets</SelectItem> : null}
|
||||
<SelectItem value="1h">hourly</SelectItem>
|
||||
<SelectItem value="24h">daily</SelectItem>
|
||||
<SelectItem value="7d">weekly</SelectItem>
|
||||
<SelectItem value="30d">monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CustomLegend, CustomTooltip } from "./chartUtils";
|
||||
import type { CustomTooltipProps } from "@tremor/react";
|
||||
import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip";
|
||||
import { SpendMetrics } from "../UsagePage/types";
|
||||
|
||||
type TooltipPayload = NonNullable<ChartTooltipProps["payload"]>;
|
||||
|
||||
describe("CustomTooltip", () => {
|
||||
const mockPayload = [
|
||||
{
|
||||
|
|
@ -28,9 +30,9 @@ describe("CustomTooltip", () => {
|
|||
];
|
||||
|
||||
it("should render", () => {
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: mockPayload,
|
||||
payload: mockPayload as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
render(<CustomTooltip {...props} />);
|
||||
|
|
@ -38,9 +40,9 @@ describe("CustomTooltip", () => {
|
|||
});
|
||||
|
||||
it("should return null when not active", () => {
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: false,
|
||||
payload: mockPayload,
|
||||
payload: mockPayload as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
const { container } = render(<CustomTooltip {...props} />);
|
||||
|
|
@ -48,9 +50,9 @@ describe("CustomTooltip", () => {
|
|||
});
|
||||
|
||||
it("should return null when payload is empty", () => {
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: [],
|
||||
payload: [] as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
const { container } = render(<CustomTooltip {...props} />);
|
||||
|
|
@ -58,9 +60,9 @@ describe("CustomTooltip", () => {
|
|||
});
|
||||
|
||||
it("should display formatted category names", () => {
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: mockPayload,
|
||||
payload: mockPayload as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
render(<CustomTooltip {...props} />);
|
||||
|
|
@ -89,9 +91,9 @@ describe("CustomTooltip", () => {
|
|||
},
|
||||
},
|
||||
];
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: payloadWithUnderscores,
|
||||
payload: payloadWithUnderscores as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
render(<CustomTooltip {...props} />);
|
||||
|
|
@ -120,9 +122,9 @@ describe("CustomTooltip", () => {
|
|||
},
|
||||
},
|
||||
];
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: spendPayload,
|
||||
payload: spendPayload as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
render(<CustomTooltip {...props} />);
|
||||
|
|
@ -130,9 +132,9 @@ describe("CustomTooltip", () => {
|
|||
});
|
||||
|
||||
it("should format non-spend numeric values with locale string", () => {
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: mockPayload,
|
||||
payload: mockPayload as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
render(<CustomTooltip {...props} />);
|
||||
|
|
@ -161,9 +163,9 @@ describe("CustomTooltip", () => {
|
|||
},
|
||||
},
|
||||
];
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: payloadWithUndefined,
|
||||
payload: payloadWithUndefined as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
render(<CustomTooltip {...props} />);
|
||||
|
|
@ -211,9 +213,9 @@ describe("CustomTooltip", () => {
|
|||
},
|
||||
},
|
||||
];
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: multiplePayload,
|
||||
payload: multiplePayload as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
render(<CustomTooltip {...props} />);
|
||||
|
|
@ -222,9 +224,9 @@ describe("CustomTooltip", () => {
|
|||
});
|
||||
|
||||
it("should convert color names to hex values", () => {
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: mockPayload,
|
||||
payload: mockPayload as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
const { container } = render(<CustomTooltip {...props} />);
|
||||
|
|
@ -254,9 +256,9 @@ describe("CustomTooltip", () => {
|
|||
},
|
||||
},
|
||||
];
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: payloadWithHexColor,
|
||||
payload: payloadWithHexColor as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
const { container } = render(<CustomTooltip {...props} />);
|
||||
|
|
@ -286,9 +288,9 @@ describe("CustomTooltip", () => {
|
|||
},
|
||||
},
|
||||
];
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: payloadWithoutDataKey as any,
|
||||
payload: payloadWithoutDataKey as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
render(<CustomTooltip {...props} />);
|
||||
|
|
@ -304,9 +306,9 @@ describe("CustomTooltip", () => {
|
|||
payload: undefined,
|
||||
},
|
||||
];
|
||||
const props: CustomTooltipProps = {
|
||||
const props: ChartTooltipProps = {
|
||||
active: true,
|
||||
payload: payloadWithoutPayload as any,
|
||||
payload: payloadWithoutPayload as unknown as TooltipPayload,
|
||||
label: "2024-01-15",
|
||||
};
|
||||
render(<CustomTooltip {...props} />);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { CustomTooltipProps } from "@tremor/react";
|
||||
import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip";
|
||||
import { SpendMetrics } from "../UsagePage/types";
|
||||
|
||||
interface ChartDataPoint {
|
||||
|
|
@ -16,7 +16,7 @@ const colorNameToHex: { [key: string]: string } = {
|
|||
emerald: "#37bc7d",
|
||||
};
|
||||
|
||||
export const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
||||
export const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const formatCategoryName = (name: string): string => {
|
||||
return name
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FallbackGroup } from "../Settings/RouterSettings/Fallbacks/FallbackGroupConfig";
|
||||
import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
|
||||
|
|
@ -16,14 +16,6 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({
|
|||
fetchAvailableModelsForTeam: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
TabGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Tab: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabPanels: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../router_settings/RouterSettingsForm", () => ({
|
||||
default: ({ value }: { value: RouterSettingsFormValue }) => (
|
||||
<div data-testid="loadbalancing">{JSON.stringify(value.routerSettings)}</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell, Text } from "@tremor/react";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
|
||||
export interface SimpleTableColumn<T> {
|
||||
header: string;
|
||||
|
|
@ -31,20 +31,20 @@ export function SimpleTable<T>({
|
|||
}: SimpleTableProps<T>) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column, index) => (
|
||||
<TableHeaderCell key={index} style={{ width: column.width }}>
|
||||
<TableHead key={index} style={{ width: column.width }}>
|
||||
{column.header}
|
||||
</TableHeaderCell>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center">
|
||||
<Text className="text-gray-500">{loadingMessage}</Text>
|
||||
<span className="text-gray-500">{loadingMessage}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length > 0 ? (
|
||||
|
|
@ -60,7 +60,7 @@ export function SimpleTable<T>({
|
|||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center">
|
||||
<Text className="text-gray-500">{emptyMessage}</Text>
|
||||
<span className="text-gray-500">{emptyMessage}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
import React, { useMemo, useState, type UIEvent } from "react";
|
||||
import { Select, Typography } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TeamDropdownProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
|
|
@ -17,10 +12,9 @@ interface TeamDropdownProps {
|
|||
/** Filter teams by organization. */
|
||||
organizationId?: string | null;
|
||||
pageSize?: number;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const SCROLL_THRESHOLD = 0.8;
|
||||
|
||||
const TeamDropdown: React.FC<TeamDropdownProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -28,15 +22,13 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
|
|||
disabled,
|
||||
organizationId,
|
||||
pageSize = 20,
|
||||
id,
|
||||
}) => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_WAIT_MS,
|
||||
});
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams(
|
||||
pageSize,
|
||||
debouncedSearch || undefined,
|
||||
search || undefined,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
|
|
@ -54,59 +46,35 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
|
|||
return result;
|
||||
}, [data]);
|
||||
|
||||
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
|
||||
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (val: string) => {
|
||||
setSearchInput(val);
|
||||
setDebouncedSearch(val);
|
||||
};
|
||||
|
||||
const handleChange = (teamId: string | undefined) => {
|
||||
onChange?.(teamId ?? "");
|
||||
const handleChange = (teamId: string) => {
|
||||
onChange?.(teamId);
|
||||
if (onTeamSelect) {
|
||||
const team = teamId ? teams.find((t) => t.team_id === teamId) ?? null : null;
|
||||
onTeamSelect(team);
|
||||
onTeamSelect(teamId ? teams.find((t) => t.team_id === teamId) ?? null : null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select a team"
|
||||
value={value || undefined}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
allowClear
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
searchValue={searchInput}
|
||||
onPopupScroll={handlePopupScroll}
|
||||
loading={isLoading}
|
||||
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
|
||||
data-testid="team-dropdown"
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
{isFetchingNextPage && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{teams.map((team) => (
|
||||
<Select.Option key={team.team_id} value={team.team_id}>
|
||||
<span className="font-medium">{team.team_alias}</span> <Text type="secondary">({team.team_id})</Text>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<div data-testid="team-dropdown">
|
||||
<PaginatedSearchSelect
|
||||
options={teams.map((team) => ({
|
||||
label: team.team_alias || team.team_id,
|
||||
value: team.team_id,
|
||||
sublabel: team.team_id,
|
||||
}))}
|
||||
value={value || undefined}
|
||||
onValueChange={handleChange}
|
||||
onSearchChange={setSearch}
|
||||
onLoadMore={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoading}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
placeholder="Search or select a team"
|
||||
emptyText="No teams found"
|
||||
loadingText="Loading teams…"
|
||||
disabled={disabled}
|
||||
inputId={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Tag } from "antd";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CogIcon, BanIcon } from "@heroicons/react/outline";
|
||||
import { callbackInfo, callback_map, reverse_callback_map } from "./callback_info_helpers";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
|
|
@ -29,16 +29,16 @@ export function LoggingSettingsView({
|
|||
return callbackDisplayName || callbackName;
|
||||
};
|
||||
|
||||
const getEventTypeColor = (eventType: string): string | undefined => {
|
||||
const getEventTypeVariant = (eventType: string): React.ComponentProps<typeof Badge>["variant"] => {
|
||||
switch (eventType) {
|
||||
case "success":
|
||||
return "green";
|
||||
return "default";
|
||||
case "failure":
|
||||
return "red";
|
||||
return "destructive";
|
||||
case "success_and_failure":
|
||||
return "blue";
|
||||
return "secondary";
|
||||
default:
|
||||
return undefined;
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ export function LoggingSettingsView({
|
|||
<div className="flex items-center gap-2">
|
||||
<CogIcon className="h-4 w-4 text-blue-600" />
|
||||
<span className="font-semibold text-gray-900">Logging Integrations</span>
|
||||
<Tag color="blue">{loggingConfigs.length}</Tag>
|
||||
<Badge variant="secondary">{loggingConfigs.length}</Badge>
|
||||
</div>
|
||||
|
||||
{loggingConfigs.length > 0 ? (
|
||||
|
|
@ -88,7 +88,9 @@ export function LoggingSettingsView({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Tag color={getEventTypeColor(config.callback_type)}>{getEventTypeLabel(config.callback_type)}</Tag>
|
||||
<Badge variant={getEventTypeVariant(config.callback_type)}>
|
||||
{getEventTypeLabel(config.callback_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -106,7 +108,7 @@ export function LoggingSettingsView({
|
|||
<div className="flex items-center gap-2">
|
||||
<BanIcon className="h-4 w-4 text-red-600" />
|
||||
<span className="font-semibold text-gray-900">Disabled Callbacks</span>
|
||||
<Tag color="red">{disabledCallbacks.length}</Tag>
|
||||
<Badge variant="destructive">{disabledCallbacks.length}</Badge>
|
||||
</div>
|
||||
|
||||
{disabledCallbacks.length > 0 ? (
|
||||
|
|
@ -131,7 +133,7 @@ export function LoggingSettingsView({
|
|||
<span className="block text-xs text-red-600">Disabled for this key</span>
|
||||
</div>
|
||||
</div>
|
||||
<Tag color="red">Disabled</Tag>
|
||||
<Badge variant="destructive">Disabled</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Card, Text } from "@tremor/react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface ModelGroupInfo {
|
||||
model_group: string;
|
||||
|
|
@ -125,7 +125,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
|
|||
const filtersContent = (
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div>
|
||||
<Text className="text-sm font-medium mb-2">Search Models:</Text>
|
||||
<p className="text-sm font-medium mb-2">Search Models:</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search model names..."
|
||||
|
|
@ -135,7 +135,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-sm font-medium mb-2">Provider:</Text>
|
||||
<p className="text-sm font-medium mb-2">Provider:</p>
|
||||
<select
|
||||
value={selectedProvider}
|
||||
onChange={(e) => setSelectedProvider(e.target.value)}
|
||||
|
|
@ -153,7 +153,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-sm font-medium mb-2">Mode:</Text>
|
||||
<p className="text-sm font-medium mb-2">Mode:</p>
|
||||
<select
|
||||
value={selectedMode}
|
||||
onChange={(e) => setSelectedMode(e.target.value)}
|
||||
|
|
@ -171,7 +171,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-sm font-medium mb-2">Features:</Text>
|
||||
<p className="text-sm font-medium mb-2">Features:</p>
|
||||
<select
|
||||
value={selectedFeature}
|
||||
onChange={(e) => setSelectedFeature(e.target.value)}
|
||||
|
|
@ -204,7 +204,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
|
|||
);
|
||||
|
||||
if (showFiltersCard) {
|
||||
return <Card className={`mb-6 ${className}`}>{filtersContent}</Card>;
|
||||
return <Card className={`mb-6 px-6 ${className}`}>{filtersContent}</Card>;
|
||||
}
|
||||
|
||||
return <div className={className}>{filtersContent}</div>;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { PlusCircleIcon, PencilIcon, TrashIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
|
||||
import { setCallbacksCall } from "./networking";
|
||||
import { Card, Title, Text, Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
|
||||
import { Card, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
|
||||
type ModelGroupAliasValue = string | { model: string; hidden?: boolean };
|
||||
|
|
@ -148,10 +149,10 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<Card className="mb-6 px-6">
|
||||
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<div className="flex flex-col">
|
||||
<Title className="mb-0">Model Group Alias Settings</Title>
|
||||
<CardTitle className="mb-0">Model Group Alias Settings</CardTitle>
|
||||
<p className="text-sm text-gray-500">
|
||||
Create aliases for your model groups to simplify API calls. For example, you can create an alias
|
||||
'gpt-4o' that points to 'gpt-4o-mini-openai' model group.
|
||||
|
|
@ -169,7 +170,7 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
{isExpanded && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-6">
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Alias</Text>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Add New Alias</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Alias Name</label>
|
||||
|
|
@ -214,17 +215,17 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</Text>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Manage Existing Aliases</p>
|
||||
<div className="rounded-lg custom-border relative mb-6">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
<TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="py-1 h-8">Alias Name</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">Target Model Group</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">Actions</TableHeaderCell>
|
||||
<TableHead className="py-1 h-8">Alias Name</TableHead>
|
||||
<TableHead className="py-1 h-8">Target Model Group</TableHead>
|
||||
<TableHead className="py-1 h-8">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{aliases.map((alias) => (
|
||||
<TableRow key={alias.id} className="h-8">
|
||||
|
|
@ -275,8 +276,12 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell className="py-0.5 text-sm text-gray-900">{alias.aliasName}</TableCell>
|
||||
<TableCell className="py-0.5 text-sm text-gray-500">{alias.targetModelGroup}</TableCell>
|
||||
<TableCell className="py-0.5 text-sm whitespace-normal text-gray-900">
|
||||
{alias.aliasName}
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 text-sm whitespace-normal text-gray-500">
|
||||
{alias.targetModelGroup}
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 whitespace-nowrap">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
|
|
@ -299,7 +304,7 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
))}
|
||||
{aliases.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="py-0.5 text-sm text-gray-500 text-center">
|
||||
<TableCell colSpan={3} className="py-0.5 text-sm whitespace-normal text-gray-500 text-center">
|
||||
No aliases added yet. Add a new alias above.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -310,11 +315,9 @@ const ModelGroupAliasSettings: React.FC<ModelGroupAliasSettingsProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Configuration Example */}
|
||||
<Card>
|
||||
<Title className="mb-4">Configuration Example</Title>
|
||||
<Text className="text-gray-600 mb-4">
|
||||
Here's how your current aliases would look in the config.yaml:
|
||||
</Text>
|
||||
<Card className="px-6">
|
||||
<CardTitle className="mb-4">Configuration Example</CardTitle>
|
||||
<p className="text-gray-600 mb-4">Here's how your current aliases would look in the config.yaml:</p>
|
||||
<div className="bg-gray-100 rounded-lg p-4 font-mono text-sm">
|
||||
<div className="text-gray-700">
|
||||
router_settings:
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { useTheme } from "@/contexts/ThemeContext";
|
|||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
|
||||
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
|
||||
import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||
import { Tag } from "antd";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChevronDown, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
|
||||
|
|
@ -71,7 +71,13 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<span className="text-lg">{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}</span>
|
||||
<span className="text-lg">
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeftOpen className="size-[18px]" />
|
||||
) : (
|
||||
<PanelLeftClose className="size-[18px]" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
@ -98,7 +104,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
🌑
|
||||
</span>
|
||||
)}
|
||||
<Tag className="relative z-10 cursor-pointer text-xs font-medium">
|
||||
<Badge variant="outline" className="relative z-10 cursor-pointer text-xs font-medium">
|
||||
<a
|
||||
href="https://docs.litellm.ai/release_notes"
|
||||
target="_blank"
|
||||
|
|
@ -107,7 +113,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
>
|
||||
v{version}
|
||||
</a>
|
||||
</Tag>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -138,7 +144,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
>
|
||||
Docs
|
||||
{/* Layout parity with Blog chevron — intentional single-level link */}
|
||||
<DownOutlined className="pointer-events-none text-[10px] opacity-0" aria-hidden />
|
||||
<ChevronDown className="pointer-events-none size-2.5 opacity-0" aria-hidden />
|
||||
</a>
|
||||
<BlogDropdown />
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React from "react";
|
||||
import { Text } from "@tremor/react";
|
||||
import VectorStorePermissions from "./permissions/VectorStorePermissions";
|
||||
import MCPServerPermissions from "./permissions/MCPServerPermissions";
|
||||
import AgentPermissions from "./permissions/AgentPermissions";
|
||||
|
|
@ -38,14 +37,14 @@ export function ObjectPermissionsView({
|
|||
accessToken={accessToken}
|
||||
/>
|
||||
<AgentPermissions agents={agents} agentAccessGroups={agentAccessGroups} accessToken={accessToken} />
|
||||
<div className="rounded-md border border-gray-100 p-4">
|
||||
<Text className="text-sm font-medium text-gray-800">Search tools</Text>
|
||||
<div className="min-w-0 rounded-md border border-gray-100 p-4">
|
||||
<p className="text-sm font-medium text-gray-800">Search tools</p>
|
||||
{searchTools.length === 0 ? (
|
||||
<Text className="mt-1 block text-xs text-gray-500">
|
||||
<p className="mt-1 block text-xs text-gray-500">
|
||||
No restriction — all configured search tools are allowed for this team.
|
||||
</Text>
|
||||
</p>
|
||||
) : (
|
||||
<Text className="mt-1 block text-xs text-gray-700">{searchTools.join(", ")}</Text>
|
||||
<p className="mt-1 block text-xs break-words text-gray-700">{searchTools.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -56,8 +55,8 @@ export function ObjectPermissionsView({
|
|||
<div className={`@container bg-white border border-gray-200 rounded-lg p-6 ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div>
|
||||
<Text className="font-semibold text-gray-900">Object Permissions</Text>
|
||||
<Text className="text-xs text-gray-500">Access control for Vector Stores and MCP Servers</Text>
|
||||
<p className="font-semibold text-gray-900">Object Permissions</p>
|
||||
<p className="text-xs text-gray-500">Access control for Vector Stores and MCP Servers</p>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
|
|
@ -67,7 +66,7 @@ export function ObjectPermissionsView({
|
|||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<Text className="font-medium text-gray-900 mb-3">Object Permissions</Text>
|
||||
<p className="font-medium text-gray-900 mb-3">Object Permissions</p>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Form } from "antd";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking";
|
||||
import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall, setCallbacksCall } from "./networking";
|
||||
import Settings, { backendCallbackLogoSrc, CallbackSelector } from "./settings";
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
|
|
@ -114,42 +114,20 @@ describe("Settings", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should display edit modal with fields when edit is clicked", async () => {
|
||||
const mockCallback = {
|
||||
name: "langfuse",
|
||||
variables: {
|
||||
LANGFUSE_PUBLIC_KEY: "test-public-key",
|
||||
LANGFUSE_SECRET_KEY: "test-secret-key",
|
||||
LANGFUSE_HOST: "https://test.langfuse.com",
|
||||
SLACK_WEBHOOK_URL: null,
|
||||
OPENMETER_API_KEY: null,
|
||||
},
|
||||
};
|
||||
|
||||
const mockCallbackConfig = {
|
||||
id: "langfuse",
|
||||
displayName: "Langfuse",
|
||||
dynamic_params: {
|
||||
LANGFUSE_PUBLIC_KEY: {
|
||||
type: "text",
|
||||
ui_name: "Public Key",
|
||||
required: true,
|
||||
},
|
||||
LANGFUSE_SECRET_KEY: {
|
||||
type: "password",
|
||||
ui_name: "Secret Key",
|
||||
required: true,
|
||||
},
|
||||
LANGFUSE_HOST: {
|
||||
type: "text",
|
||||
ui_name: "Host",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const openLangfuseEditModal = async () => {
|
||||
mockGetCallbacksCall.mockResolvedValue({
|
||||
callbacks: [mockCallback],
|
||||
callbacks: [
|
||||
{
|
||||
name: "langfuse",
|
||||
variables: {
|
||||
LANGFUSE_PUBLIC_KEY: "test-public-key",
|
||||
LANGFUSE_SECRET_KEY: "test-secret-key",
|
||||
LANGFUSE_HOST: "https://test.langfuse.com",
|
||||
SLACK_WEBHOOK_URL: null,
|
||||
OPENMETER_API_KEY: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
available_callbacks: {
|
||||
langfuse: {
|
||||
litellm_callback_name: "langfuse",
|
||||
|
|
@ -160,30 +138,118 @@ describe("Settings", () => {
|
|||
alerts: [],
|
||||
});
|
||||
|
||||
mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]);
|
||||
mockGetCallbackConfigsCall.mockResolvedValue([
|
||||
{
|
||||
id: "langfuse",
|
||||
displayName: "Langfuse",
|
||||
dynamic_params: {
|
||||
LANGFUSE_PUBLIC_KEY: { type: "text", ui_name: "Public Key", required: true },
|
||||
LANGFUSE_SECRET_KEY: { type: "password", ui_name: "Secret Key", required: true },
|
||||
LANGFUSE_HOST: { type: "text", ui_name: "Host", required: false },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { getByText } = render(<Settings {...defaultProps} />);
|
||||
render(<Settings {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Langfuse")).toBeInTheDocument();
|
||||
expect(screen.getByText("Langfuse")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("callback-actions-langfuse-success"));
|
||||
await user.click(await screen.findByTestId("callback-action-edit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Edit Callback Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit Callback Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
it("should display edit modal with fields when edit is clicked", async () => {
|
||||
await openLangfuseEditModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Public Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Secret Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Host")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Public Key")).toBeInTheDocument();
|
||||
expect(getByText("Secret Key")).toBeInTheDocument();
|
||||
expect(getByText("Host")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key");
|
||||
});
|
||||
expect(screen.getByLabelText("Secret Key")).toHaveValue("test-secret-key");
|
||||
expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com");
|
||||
|
||||
const danglingLabels = [...document.querySelectorAll("label[for]")].filter(
|
||||
(label) => document.getElementById(label.getAttribute("for") as string) === null,
|
||||
);
|
||||
expect(danglingLabels).toEqual([]);
|
||||
});
|
||||
|
||||
it("should post the edited callback variables when the edit modal is saved", async () => {
|
||||
const user = await openLangfuseEditModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com");
|
||||
});
|
||||
|
||||
await user.clear(screen.getByLabelText("Host"));
|
||||
await user.type(screen.getByLabelText("Host"), "https://edited.langfuse.com");
|
||||
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", {
|
||||
environment_variables: {
|
||||
callback: "langfuse",
|
||||
LANGFUSE_PUBLIC_KEY: "test-public-key",
|
||||
LANGFUSE_SECRET_KEY: "test-secret-key",
|
||||
LANGFUSE_HOST: "https://edited.langfuse.com",
|
||||
},
|
||||
litellm_settings: { success_callback: ["langfuse"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should block the edit submit when a required field is emptied", async () => {
|
||||
const user = await openLangfuseEditModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key");
|
||||
});
|
||||
|
||||
await user.clear(screen.getByLabelText("Public Key"));
|
||||
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
expect(await screen.findByText("Please enter the public key")).toBeInTheDocument();
|
||||
expect(vi.mocked(setCallbacksCall)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should send the typed webhook url for an alert type when the alerting tab is saved", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Settings {...defaultProps} />);
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: "Alerting Types" }));
|
||||
|
||||
const webhookInput = document.querySelector('input[name="llm_exceptions"]') as HTMLInputElement;
|
||||
expect(webhookInput).not.toBeNull();
|
||||
await user.type(webhookInput, "https://hooks.example.com/llm-exceptions");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", {
|
||||
general_settings: expect.objectContaining({
|
||||
alert_to_webhook_url: expect.objectContaining({
|
||||
llm_exceptions: "https://hooks.example.com/llm-exceptions",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -252,6 +318,19 @@ describe("backendCallbackLogoSrc", () => {
|
|||
});
|
||||
});
|
||||
|
||||
const CallbackSelectorHarness = ({
|
||||
callbackConfigs,
|
||||
}: {
|
||||
callbackConfigs: { id: string; displayName: string; logo?: string }[];
|
||||
}) => {
|
||||
const form = useForm<Record<string, string>>();
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<CallbackSelector callbackConfigs={callbackConfigs} selectedCallback={null} onCallbackChange={vi.fn()} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("CallbackSelector logos", () => {
|
||||
it("resolves backend logos per entry: bare filename, external url, and missing logo", async () => {
|
||||
const callbackConfigs = [
|
||||
|
|
@ -260,13 +339,9 @@ describe("CallbackSelector logos", () => {
|
|||
{ id: "nologo", displayName: "NoLogo" },
|
||||
];
|
||||
|
||||
render(
|
||||
<Form>
|
||||
<CallbackSelector callbackConfigs={callbackConfigs} selectedCallback={null} onCallbackChange={vi.fn()} />
|
||||
</Form>,
|
||||
);
|
||||
render(<CallbackSelectorHarness callbackConfigs={callbackConfigs} />);
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("combobox"));
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
|
||||
expect(await screen.findByAltText("Langfuse logo")).toHaveAttribute("src", "/ui/assets/logos/langfuse.png");
|
||||
expect(screen.getByAltText("Hosted logo")).toHaveAttribute("src", "https://logos.example.com/hosted.png");
|
||||
|
|
|
|||
|
|
@ -1,31 +1,26 @@
|
|||
import {
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
SelectItem,
|
||||
Switch,
|
||||
Tab,
|
||||
TabGroup,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@tremor/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
|
||||
|
||||
import { Button as Button2, Form, Input, Modal, Select } from "antd";
|
||||
import { Field, FieldError, FieldLabel } from "@/components/shared/form/field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import EmailSettings from "./email_settings";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
|
||||
import FormItem from "antd/es/form/FormItem";
|
||||
import AlertingSettings from "./alerting/alerting_settings";
|
||||
import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking";
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
|
|
@ -46,6 +41,8 @@ interface SettingsPageProps {
|
|||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
type CallbackFormValues = Record<string, string>;
|
||||
|
||||
const assetsLogoFolder = "/ui/assets/logos/";
|
||||
|
||||
export const backendCallbackLogoSrc = (logo: string | null | undefined): string | undefined => {
|
||||
|
|
@ -61,6 +58,9 @@ interface DynamicParamsFieldsProps {
|
|||
}
|
||||
|
||||
const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callbackConfigs, selectedCallback }) => {
|
||||
const { register, formState } = useFormContext<CallbackFormValues>();
|
||||
const fieldIdPrefix = React.useId();
|
||||
|
||||
if (!params || params.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -73,54 +73,51 @@ const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callb
|
|||
const paramType = paramConfig.type || "text";
|
||||
const fieldLabel = paramConfig.ui_name || param.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
const isRequired = paramConfig.required || false;
|
||||
const fieldId = `${fieldIdPrefix}-${param}`;
|
||||
const registration = register(
|
||||
param,
|
||||
isRequired ? { required: `Please enter the ${fieldLabel.toLowerCase()}` } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem
|
||||
label={<span className="text-sm font-medium text-gray-700">{fieldLabel} </span>}
|
||||
name={param}
|
||||
key={param}
|
||||
className="mb-4"
|
||||
rules={
|
||||
isRequired
|
||||
? [
|
||||
{
|
||||
required: true,
|
||||
message: `Please enter the ${fieldLabel.toLowerCase()}`,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Field key={param} className="mb-4">
|
||||
<FieldLabel htmlFor={fieldId}>
|
||||
<span className="text-sm font-medium text-gray-700">{fieldLabel} </span>
|
||||
</FieldLabel>
|
||||
{paramType === "password" ? (
|
||||
<Input.Password
|
||||
size="large"
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="password"
|
||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||
className="w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500"
|
||||
{...registration}
|
||||
/>
|
||||
) : paramType === "number" ? (
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
size="large"
|
||||
placeholder={`Enter ${fieldLabel.toLowerCase()}`}
|
||||
className="w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
{...registration}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
size="large"
|
||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||
className="w-full rounded-md border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
<Input id={fieldId} placeholder={`Enter your ${fieldLabel.toLowerCase()}`} {...registration} />
|
||||
)}
|
||||
</FormItem>
|
||||
<FieldError errors={[formState.errors[param]]} />
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CallbackConfigOption {
|
||||
id: string;
|
||||
displayName: string;
|
||||
logo?: string | null;
|
||||
}
|
||||
|
||||
// Shared component for rendering callback selector
|
||||
interface CallbackSelectorProps {
|
||||
callbackConfigs: any[];
|
||||
|
|
@ -135,42 +132,64 @@ export const CallbackSelector: React.FC<CallbackSelectorProps> = ({
|
|||
onCallbackChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { control } = useFormContext<CallbackFormValues>();
|
||||
const inputId = React.useId();
|
||||
const selectedConfig = callbackConfigs.find((config) => config.id === selectedCallback) ?? null;
|
||||
|
||||
return (
|
||||
<FormItem
|
||||
label="Callback"
|
||||
<Controller
|
||||
control={control}
|
||||
name="callback"
|
||||
rules={disabled ? undefined : [{ required: true, message: "Please select a callback" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Choose a logging callback..."
|
||||
size="large"
|
||||
className="w-full"
|
||||
showSearch
|
||||
disabled={disabled}
|
||||
value={selectedCallback}
|
||||
filterOption={(input, option) => {
|
||||
return (option?.value?.toString() ?? "").toLowerCase().includes(input.toLowerCase());
|
||||
}}
|
||||
onChange={onCallbackChange}
|
||||
>
|
||||
{callbackConfigs.map((callbackConfig) => {
|
||||
return (
|
||||
<SelectItem key={callbackConfig.id} value={callbackConfig.id}>
|
||||
<div className="flex items-center space-x-3 py-1">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<Logo
|
||||
src={backendCallbackLogoSrc(callbackConfig.logo)}
|
||||
label={callbackConfig.displayName}
|
||||
className="w-6 h-6 rounded-sm object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium text-gray-900">{callbackConfig.displayName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormItem>
|
||||
rules={disabled ? undefined : { required: "Please select a callback" }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={inputId}>Callback</FieldLabel>
|
||||
<Combobox
|
||||
items={callbackConfigs}
|
||||
value={selectedConfig}
|
||||
onValueChange={(config: CallbackConfigOption | null) => {
|
||||
field.onChange(config?.id ?? "");
|
||||
onCallbackChange(config?.id ?? "");
|
||||
}}
|
||||
isItemEqualToValue={(a: CallbackConfigOption, b: CallbackConfigOption) => a.id === b.id}
|
||||
itemToStringLabel={(config: CallbackConfigOption) => config.displayName}
|
||||
filter={(config: CallbackConfigOption, query: string) =>
|
||||
config.id.toLowerCase().includes(query.trim().toLowerCase())
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={inputId}
|
||||
placeholder="Choose a logging callback..."
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={fieldState.error !== undefined || undefined}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No results</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(callbackConfig: CallbackConfigOption) => (
|
||||
<ComboboxItem key={callbackConfig.id} value={callbackConfig}>
|
||||
<div className="flex items-center space-x-3 py-1">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<Logo
|
||||
src={backendCallbackLogoSrc(callbackConfig.logo)}
|
||||
label={callbackConfig.displayName}
|
||||
className="w-6 h-6 rounded-sm object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium text-gray-900">{callbackConfig.displayName}</span>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -206,8 +225,8 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
const [callbacks, setCallbacks] = useState<AlertingObject[]>([]);
|
||||
const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true);
|
||||
const [alerts, setAlerts] = useState<any[]>([]);
|
||||
const [addForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const addForm = useForm<CallbackFormValues>({ shouldUnregister: true });
|
||||
const editForm = useForm<CallbackFormValues>({ shouldUnregister: true });
|
||||
const [selectedCallback, setSelectedCallback] = useState<string | null>(null);
|
||||
const [catchAllWebhookURL, setCatchAllWebhookURL] = useState<string>("");
|
||||
const [alertToWebhooks, setAlertToWebhooks] = useState<Record<string, string>>({});
|
||||
|
|
@ -254,7 +273,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
const normalized = Object.fromEntries(
|
||||
Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [k, v ?? ""]),
|
||||
);
|
||||
editForm.setFieldsValue({
|
||||
editForm.reset({
|
||||
...normalized,
|
||||
callback: selectedEditCallback.name,
|
||||
});
|
||||
|
|
@ -337,11 +356,11 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
|
||||
if (isEdit) {
|
||||
setShowEditCallback(false);
|
||||
editForm.resetFields();
|
||||
editForm.reset();
|
||||
setSelectedEditCallback(null);
|
||||
} else {
|
||||
setShowAddCallbacksModal(false);
|
||||
addForm.resetFields();
|
||||
addForm.reset();
|
||||
setSelectedCallback(null);
|
||||
setSelectedCallbackParams([]);
|
||||
}
|
||||
|
|
@ -383,6 +402,23 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
setSelectedCallbackParams(params);
|
||||
};
|
||||
|
||||
const closeAddCallbackModal = () => {
|
||||
setShowAddCallbacksModal(false);
|
||||
setSelectedCallback(null);
|
||||
setSelectedCallbackParams([]);
|
||||
};
|
||||
|
||||
const cancelAddCallback = () => {
|
||||
closeAddCallbackModal();
|
||||
addForm.reset();
|
||||
};
|
||||
|
||||
const closeEditCallbackModal = () => {
|
||||
setShowEditCallback(false);
|
||||
setSelectedEditCallback(null);
|
||||
editForm.reset();
|
||||
};
|
||||
|
||||
const handleSaveAlerts = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
|
|
@ -447,257 +483,216 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
|
||||
return (
|
||||
<div className="mx-4">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<TabGroup>
|
||||
<TabList variant="line" defaultValue="1">
|
||||
<Tab value="1">Logging Callbacks</Tab>
|
||||
<Tab value="2">CloudZero Cost Tracking</Tab>
|
||||
<Tab value="2">Alerting Types</Tab>
|
||||
<Tab value="3">Alerting Settings</Tab>
|
||||
<Tab value="4">Email Alerts</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<LoggingCallbacksTable
|
||||
callbacks={callbacks}
|
||||
availableCallbacks={allCallbacks}
|
||||
isLoading={isLoadingCallbacks}
|
||||
onAdd={() => setShowAddCallbacksModal(true)}
|
||||
onEdit={(cb) => {
|
||||
setSelectedEditCallback(cb);
|
||||
setShowEditCallback(true);
|
||||
}}
|
||||
onDelete={(cb) => handleDeleteCallback(cb)}
|
||||
onTest={async (cb) => {
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, cb.name);
|
||||
NotificationsManager.success("Health check triggered");
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(parseErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className="p-8">
|
||||
<CloudZeroCostTracking />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<Text className="my-2">
|
||||
Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "}
|
||||
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }}>
|
||||
here
|
||||
</a>
|
||||
</Text>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
<TableHeaderCell>Slack Webhook URL</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<div className="grid grid-cols-1 gap-2 p-8 w-full mt-2">
|
||||
<Tabs defaultValue="logging-callbacks">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="logging-callbacks">Logging Callbacks</TabsTrigger>
|
||||
<TabsTrigger value="cloudzero-cost-tracking">CloudZero Cost Tracking</TabsTrigger>
|
||||
<TabsTrigger value="alerting-types">Alerting Types</TabsTrigger>
|
||||
<TabsTrigger value="alerting-settings">Alerting Settings</TabsTrigger>
|
||||
<TabsTrigger value="email-alerts">Email Alerts</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="logging-callbacks">
|
||||
<LoggingCallbacksTable
|
||||
callbacks={callbacks}
|
||||
availableCallbacks={allCallbacks}
|
||||
isLoading={isLoadingCallbacks}
|
||||
onAdd={() => setShowAddCallbacksModal(true)}
|
||||
onEdit={(cb) => {
|
||||
setSelectedEditCallback(cb);
|
||||
setShowEditCallback(true);
|
||||
}}
|
||||
onDelete={(cb) => handleDeleteCallback(cb)}
|
||||
onTest={async (cb) => {
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, cb.name);
|
||||
NotificationsManager.success("Health check triggered");
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(parseErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="cloudzero-cost-tracking">
|
||||
<div className="p-8">
|
||||
<CloudZeroCostTracking />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="alerting-types">
|
||||
<Card className="p-6">
|
||||
<p className="my-2">
|
||||
Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "}
|
||||
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }}>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead></TableHead>
|
||||
<TableHead></TableHead>
|
||||
<TableHead>Slack Webhook URL</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{Object.entries(alerts_to_UI_NAME).map(([key, value], index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
{key == "region_outage_alerts" ? (
|
||||
premiumUser ? (
|
||||
<Switch
|
||||
id="switch"
|
||||
name="switch"
|
||||
checked={isAlertOn(key)}
|
||||
onChange={() => handleSwitchChange(key)}
|
||||
/>
|
||||
) : (
|
||||
<Button className="flex items-center justify-center">
|
||||
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
|
||||
✨ Enterprise Feature
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<TableBody>
|
||||
{Object.entries(alerts_to_UI_NAME).map(([key, value], index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
{key == "region_outage_alerts" ? (
|
||||
premiumUser ? (
|
||||
<Switch
|
||||
id="switch"
|
||||
name="switch"
|
||||
checked={isAlertOn(key)}
|
||||
onChange={() => handleSwitchChange(key)}
|
||||
onCheckedChange={() => handleSwitchChange(key)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text>{value}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextInput
|
||||
name={key}
|
||||
type="password"
|
||||
defaultValue={
|
||||
alertToWebhooks && alertToWebhooks[key]
|
||||
? alertToWebhooks[key]
|
||||
: (catchAllWebhookURL as string)
|
||||
}
|
||||
></TextInput>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Button size="xs" className="mt-2" onClick={handleSaveAlerts}>
|
||||
Save Changes
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="flex items-center justify-center">
|
||||
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
|
||||
✨ Enterprise Feature
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Switch
|
||||
id="switch"
|
||||
name="switch"
|
||||
checked={isAlertOn(key)}
|
||||
onCheckedChange={() => handleSwitchChange(key)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">
|
||||
<p>{value}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
name={key}
|
||||
type="password"
|
||||
defaultValue={
|
||||
alertToWebhooks && alertToWebhooks[key]
|
||||
? alertToWebhooks[key]
|
||||
: (catchAllWebhookURL as string)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Button size="xs" className="mt-2" onClick={handleSaveAlerts}>
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, "slack");
|
||||
NotificationsManager.success(
|
||||
"Alert test triggered. Test request to slack made - check logs/alerts on slack to verify",
|
||||
);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(parseErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
className="mx-2"
|
||||
>
|
||||
Test Alerts
|
||||
</Button>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AlertingSettings accessToken={accessToken} premiumUser={premiumUser} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<EmailSettings accessToken={accessToken} premiumUser={premiumUser} alerts={alerts} />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</Grid>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, "slack");
|
||||
NotificationsManager.success(
|
||||
"Alert test triggered. Test request to slack made - check logs/alerts on slack to verify",
|
||||
);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(parseErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
className="mx-2"
|
||||
>
|
||||
Test Alerts
|
||||
</Button>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="alerting-settings">
|
||||
<AlertingSettings accessToken={accessToken} premiumUser={premiumUser} />
|
||||
</TabsContent>
|
||||
<TabsContent value="email-alerts">
|
||||
<EmailSettings accessToken={accessToken} premiumUser={premiumUser} alerts={alerts} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="Add Logging Callback"
|
||||
open={showAddCallbacksModal}
|
||||
width={800}
|
||||
onCancel={() => {
|
||||
setShowAddCallbacksModal(false);
|
||||
setSelectedCallback(null);
|
||||
setSelectedCallbackParams([]);
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/logging"
|
||||
className="mb-8 mt-4"
|
||||
target="_blank"
|
||||
style={{ color: "blue" }}
|
||||
>
|
||||
{" "}
|
||||
LiteLLM Docs: Logging
|
||||
</a>
|
||||
<Dialog open={showAddCallbacksModal} onOpenChange={(open) => !open && closeAddCallbackModal()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Logging Callback</DialogTitle>
|
||||
</DialogHeader>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/logging"
|
||||
className="mb-8 mt-4"
|
||||
target="_blank"
|
||||
style={{ color: "blue" }}
|
||||
>
|
||||
{" "}
|
||||
LiteLLM Docs: Logging
|
||||
</a>
|
||||
|
||||
<Form
|
||||
form={addForm}
|
||||
onFinish={addNewCallbackCall}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<CallbackSelector
|
||||
callbackConfigs={callbackConfigs}
|
||||
selectedCallback={selectedCallback}
|
||||
onCallbackChange={handleSelectedCallbackChange}
|
||||
/>
|
||||
|
||||
<DynamicParamsFields
|
||||
params={selectedCallbackParams}
|
||||
callbackConfigs={callbackConfigs}
|
||||
selectedCallback={selectedCallback}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
|
||||
<Button2
|
||||
onClick={() => {
|
||||
setShowAddCallbacksModal(false);
|
||||
setSelectedCallback(null);
|
||||
setSelectedCallbackParams([]);
|
||||
addForm.resetFields();
|
||||
}}
|
||||
disabled={isAddingCallback}
|
||||
>
|
||||
Cancel
|
||||
</Button2>
|
||||
<Button2 htmlType="submit" loading={isAddingCallback} disabled={isAddingCallback}>
|
||||
{isAddingCallback ? "Adding..." : "Add Callback"}
|
||||
</Button2>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={showEditCallback}
|
||||
width={800}
|
||||
title={"Edit Callback Settings"}
|
||||
onCancel={() => {
|
||||
setShowEditCallback(false);
|
||||
setSelectedEditCallback(null);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
onFinish={updateCallbackCall}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
{selectedEditCallback && (
|
||||
<>
|
||||
<FormProvider {...addForm}>
|
||||
<form onSubmit={addForm.handleSubmit(addNewCallbackCall)}>
|
||||
<CallbackSelector
|
||||
callbackConfigs={callbackConfigs}
|
||||
selectedCallback={selectedEditCallback.name}
|
||||
onCallbackChange={() => {}}
|
||||
disabled={true}
|
||||
selectedCallback={selectedCallback}
|
||||
onCallbackChange={handleSelectedCallbackChange}
|
||||
/>
|
||||
|
||||
<DynamicParamsFields
|
||||
params={getDynamicParamsForCallback(
|
||||
selectedEditCallback.name,
|
||||
callbackConfigs,
|
||||
selectedEditCallback.variables,
|
||||
)}
|
||||
params={selectedCallbackParams}
|
||||
callbackConfigs={callbackConfigs}
|
||||
selectedCallback={selectedEditCallback.name}
|
||||
selectedCallback={selectedCallback}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
|
||||
<Button2
|
||||
onClick={() => {
|
||||
setShowEditCallback(false);
|
||||
setSelectedEditCallback(null);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
disabled={isUpdatingCallback}
|
||||
>
|
||||
Cancel
|
||||
</Button2>
|
||||
<Button2
|
||||
onClick={() => {
|
||||
editForm.submit();
|
||||
}}
|
||||
loading={isUpdatingCallback}
|
||||
disabled={isUpdatingCallback}
|
||||
>
|
||||
{isUpdatingCallback ? "Saving..." : "Save Changes"}
|
||||
</Button2>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
|
||||
<Button type="button" variant="outline" onClick={cancelAddCallback} disabled={isAddingCallback}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isAddingCallback}>
|
||||
{isAddingCallback ? "Adding..." : "Add Callback"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showEditCallback} onOpenChange={(open) => !open && closeEditCallbackModal()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Callback Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FormProvider {...editForm}>
|
||||
<form onSubmit={editForm.handleSubmit(updateCallbackCall)}>
|
||||
{selectedEditCallback && (
|
||||
<>
|
||||
<CallbackSelector
|
||||
callbackConfigs={callbackConfigs}
|
||||
selectedCallback={selectedEditCallback.name}
|
||||
onCallbackChange={() => {}}
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
<DynamicParamsFields
|
||||
params={getDynamicParamsForCallback(
|
||||
selectedEditCallback.name,
|
||||
callbackConfigs,
|
||||
selectedEditCallback.variables,
|
||||
)}
|
||||
callbackConfigs={callbackConfigs}
|
||||
selectedCallback={selectedEditCallback.name}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
|
||||
<Button type="button" variant="outline" onClick={closeEditCallbackModal} disabled={isUpdatingCallback}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isUpdatingCallback}>
|
||||
{isUpdatingCallback ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={showDeleteConfirmModal}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ describe("SearchSelect", () => {
|
|||
expect(screen.getByRole("combobox")).toHaveValue("Growth");
|
||||
});
|
||||
|
||||
it("shows a value the options do not carry yet instead of blanking the field", () => {
|
||||
const { rerender } = render(<SearchSelect options={[]} value="team-2" onValueChange={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toHaveValue("team-2");
|
||||
rerender(<SearchSelect options={OPTIONS} value="team-2" onValueChange={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Growth");
|
||||
});
|
||||
|
||||
it("shows a clear control only when a value is selected", () => {
|
||||
const { rerender } = render(<SearchSelect options={OPTIONS} onValueChange={vi.fn()} />);
|
||||
expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ interface SearchSelectProps {
|
|||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
const matchesQuery = (option: SearchSelectOption, query: string): boolean => {
|
||||
|
|
@ -40,12 +41,18 @@ export function SearchSelect({
|
|||
emptyText = "No results",
|
||||
disabled = false,
|
||||
className,
|
||||
inputId,
|
||||
}: SearchSelectProps) {
|
||||
const selected = options.find((option) => option.value === value) ?? null;
|
||||
const selected =
|
||||
value === undefined || value === ""
|
||||
? null
|
||||
: options.find((option) => option.value === value) ?? { label: value, value };
|
||||
const items =
|
||||
selected !== null && !options.some((option) => option.value === selected.value) ? [selected, ...options] : options;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
items={options}
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
|
||||
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
|
||||
|
|
@ -54,6 +61,7 @@ export function SearchSelect({
|
|||
disabled={disabled}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={inputId}
|
||||
placeholder={placeholder}
|
||||
showClear={value != null && value !== ""}
|
||||
className={`h-8 w-full text-sm ${className ?? ""}`}
|
||||
|
|
|
|||
|
|
@ -989,9 +989,8 @@ describe("TeamInfoView", () => {
|
|||
const user = userEvent.setup({ delay: null });
|
||||
const resetBudgetItem = await openSettingsEditorForTeam(user, { budget_duration: "30d" });
|
||||
|
||||
const clearIcon = resetBudgetItem.querySelector(".ant-select-clear");
|
||||
expect(clearIcon).not.toBeNull();
|
||||
fireEvent.mouseDown(clearIcon as Element);
|
||||
await user.click(within(resetBudgetItem).getByRole("combobox"));
|
||||
await user.click(await screen.findByText("Never resets"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument();
|
||||
|
|
@ -1554,13 +1553,14 @@ describe("TeamInfoView", () => {
|
|||
|
||||
await user.click(within(routesFormItem).getByRole("combobox"));
|
||||
|
||||
const option = await screen.findByTitle("POST /bedrock-passthrough");
|
||||
const option = await screen.findByText("POST /bedrock-passthrough");
|
||||
await user.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(routesFormItem).getByText(/\/bedrock-passthrough/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -961,9 +961,8 @@ describe("KeyEditView", () => {
|
|||
);
|
||||
|
||||
const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement;
|
||||
const clearIcon = resetBudgetItem.querySelector(".ant-select-clear");
|
||||
expect(clearIcon).not.toBeNull();
|
||||
fireEvent.mouseDown(clearIcon as Element);
|
||||
await userEvent.click(within(resetBudgetItem).getByRole("combobox"));
|
||||
await userEvent.click(await screen.findByText("Never resets"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument();
|
||||
|
|
@ -995,7 +994,8 @@ describe("KeyEditView", () => {
|
|||
);
|
||||
|
||||
const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement;
|
||||
fireEvent.mouseDown(resetBudgetItem.querySelector(".ant-select-clear") as Element);
|
||||
await userEvent.click(within(resetBudgetItem).getByRole("combobox"));
|
||||
await userEvent.click(await screen.findByText("Never resets"));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
|
|
@ -1251,9 +1251,10 @@ describe("KeyEditView", () => {
|
|||
expect(screen.getByText("Organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const orgFormItem = screen.getByText("Organization").closest(".ant-form-item");
|
||||
const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled");
|
||||
expect(disabledSelect).toBeTruthy();
|
||||
const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement;
|
||||
await userEvent.click(within(orgFormItem).getByRole("combobox"));
|
||||
|
||||
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not disable the organization dropdown for admin users", async () => {
|
||||
|
|
@ -1273,9 +1274,10 @@ describe("KeyEditView", () => {
|
|||
expect(screen.getByText("Organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const orgFormItem = screen.getByText("Organization").closest(".ant-form-item");
|
||||
const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled");
|
||||
expect(disabledSelect).toBeFalsy();
|
||||
const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement;
|
||||
await userEvent.click(within(orgFormItem).getByRole("combobox"));
|
||||
|
||||
expect(await screen.findByText("Engineering")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should initialize organization from keyData", async () => {
|
||||
|
|
@ -1296,8 +1298,9 @@ describe("KeyEditView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const orgFormItem = (await screen.findByText("Organization")).closest(".ant-form-item") as HTMLElement;
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Engineering")).toBeInTheDocument();
|
||||
expect(within(orgFormItem).getByRole("combobox")).toHaveValue("Engineering");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
|
||||
import { Col, Grid } from "@tremor/react";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { fetchTeams } from "./common_components/fetch_teams";
|
||||
|
|
@ -218,8 +217,8 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
|||
|
||||
return (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<Col numColSpan={1} className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 p-8 w-full mt-2">
|
||||
<div className="col-span-1 flex flex-col gap-2">
|
||||
<VirtualKeysTable
|
||||
headerActions={
|
||||
canCreateKey ? (
|
||||
|
|
@ -235,8 +234,8 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
|
|||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Collapse } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
export interface CostBreakdown {
|
||||
|
|
@ -49,6 +50,7 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isCached = cacheHit?.toLowerCase() === "true";
|
||||
const hasTokenCounts = promptTokens !== undefined || completionTokens !== undefined;
|
||||
|
||||
|
|
@ -90,197 +92,195 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
|
||||
<div className="flex items-center space-x-2 mr-4">
|
||||
<span className="text-sm text-gray-500">Total:</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{formatCost(totalSpend)}
|
||||
{isCached && " (Cached)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Base Token Costs */}
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
{(() => {
|
||||
const hasCacheBreakdown =
|
||||
costBreakdown?.cache_read_cost !== undefined || costBreakdown?.cache_creation_cost !== undefined;
|
||||
if (hasCacheBreakdown) {
|
||||
// Separate line items: Input / Cache Read / Cache Write
|
||||
const rawCost = isCached
|
||||
? 0
|
||||
: (inputCost ?? 0) -
|
||||
(costBreakdown?.cache_read_cost ?? 0) -
|
||||
(costBreakdown?.cache_creation_cost ?? 0);
|
||||
return (
|
||||
<>
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(rawCost)}
|
||||
{rawInputTokens !== undefined && rawInputTokens !== null && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({rawInputTokens.toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{(costBreakdown?.cache_read_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Read Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)}
|
||||
{(cacheReadTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({(cacheReadTokens ?? 0).toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(costBreakdown?.cache_creation_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Write Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)}
|
||||
{(cacheCreationTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({(cacheCreationTokens ?? 0).toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
|
||||
<div className="flex items-center space-x-2 mr-4">
|
||||
<span className="text-sm text-gray-500">Total:</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{formatCost(totalSpend)}
|
||||
{isCached && " (Cached)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Base Token Costs */}
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
{(() => {
|
||||
const hasCacheBreakdown =
|
||||
costBreakdown?.cache_read_cost !== undefined || costBreakdown?.cache_creation_cost !== undefined;
|
||||
if (hasCacheBreakdown) {
|
||||
// Separate line items: Input / Cache Read / Cache Write
|
||||
const rawCost = isCached
|
||||
? 0
|
||||
: (inputCost ?? 0) -
|
||||
(costBreakdown?.cache_read_cost ?? 0) -
|
||||
(costBreakdown?.cache_creation_cost ?? 0);
|
||||
return (
|
||||
<>
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(inputCost)}
|
||||
{promptTokens !== undefined && (
|
||||
{formatCost(rawCost)}
|
||||
{rawInputTokens !== undefined && rawInputTokens !== null && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({promptTokens.toLocaleString()} prompt tokens)
|
||||
({rawInputTokens.toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(costBreakdown?.cache_read_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Read Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)}
|
||||
{(cacheReadTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({(cacheReadTokens ?? 0).toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(costBreakdown?.cache_creation_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Write Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)}
|
||||
{(cacheCreationTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({(cacheCreationTokens ?? 0).toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Output Cost:</span>
|
||||
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(outputCost)}
|
||||
{completionTokens !== undefined && (
|
||||
{formatCost(inputCost)}
|
||||
{promptTokens !== undefined && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({completionTokens.toLocaleString()} completion tokens)
|
||||
({promptTokens.toLocaleString()} prompt tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Tool Usage Cost:</span>
|
||||
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown?.additional_costs &&
|
||||
Object.entries(costBreakdown.additional_costs)
|
||||
.filter(([, value]) => value != null && value !== 0)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
|
||||
<span className="text-gray-900">{formatCost(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Subtotal / Original Cost - hide when cached since it would be $0 */}
|
||||
{!isCached && (
|
||||
<div className="pt-2 border-t border-gray-100 max-w-2xl">
|
||||
<div className="flex text-sm font-semibold">
|
||||
<span className="text-gray-900 w-1/3">Original LLM Cost:</span>
|
||||
<span className="text-gray-900">{formatCost(originalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Adjustments (Discount & Margin) */}
|
||||
{(hasDiscount || hasMargin) && (
|
||||
<div className="pt-2 space-y-2 max-w-2xl">
|
||||
{/* Discounts */}
|
||||
{hasDiscount && (
|
||||
<div className="space-y-2">
|
||||
{costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">
|
||||
Discount ({formatPercent(costBreakdown.discount_percent)}):
|
||||
</span>
|
||||
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown.discount_amount !== undefined &&
|
||||
costBreakdown.discount_percent === undefined && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">Discount Amount:</span>
|
||||
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Margins */}
|
||||
{hasMargin && (
|
||||
<div className="space-y-2">
|
||||
{costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">
|
||||
Margin ({formatPercent(costBreakdown.margin_percent)}):
|
||||
</span>
|
||||
<span className="text-gray-900">
|
||||
+
|
||||
{formatCost(
|
||||
(costBreakdown.margin_total_amount || 0) - (costBreakdown.margin_fixed_amount || 0),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">Margin:</span>
|
||||
<span className="text-gray-900">+{formatCost(costBreakdown.margin_fixed_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 max-w-2xl">
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-sm text-gray-900 w-1/3">Final Calculated Cost:</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{formatCost(totalCost)}
|
||||
{isCached && " (Cached)"}
|
||||
);
|
||||
})()}
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Output Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(outputCost)}
|
||||
{completionTokens !== undefined && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({completionTokens.toLocaleString()} completion tokens)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Tool Usage Cost:</span>
|
||||
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown?.additional_costs &&
|
||||
Object.entries(costBreakdown.additional_costs)
|
||||
.filter(([, value]) => value != null && value !== 0)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
|
||||
<span className="text-gray-900">{formatCost(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Subtotal / Original Cost - hide when cached since it would be $0 */}
|
||||
{!isCached && (
|
||||
<div className="pt-2 border-t border-gray-100 max-w-2xl">
|
||||
<div className="flex text-sm font-semibold">
|
||||
<span className="text-gray-900 w-1/3">Original LLM Cost:</span>
|
||||
<span className="text-gray-900">{formatCost(originalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: Adjustments (Discount & Margin) */}
|
||||
{(hasDiscount || hasMargin) && (
|
||||
<div className="pt-2 space-y-2 max-w-2xl">
|
||||
{/* Discounts */}
|
||||
{hasDiscount && (
|
||||
<div className="space-y-2">
|
||||
{costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">
|
||||
Discount ({formatPercent(costBreakdown.discount_percent)}):
|
||||
</span>
|
||||
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown.discount_amount !== undefined && costBreakdown.discount_percent === undefined && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">Discount Amount:</span>
|
||||
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Margins */}
|
||||
{hasMargin && (
|
||||
<div className="space-y-2">
|
||||
{costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">
|
||||
Margin ({formatPercent(costBreakdown.margin_percent)}):
|
||||
</span>
|
||||
<span className="text-gray-900">
|
||||
+
|
||||
{formatCost(
|
||||
(costBreakdown.margin_total_amount || 0) - (costBreakdown.margin_fixed_amount || 0),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">Margin:</span>
|
||||
<span className="text-gray-900">+{formatCost(costBreakdown.margin_fixed_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 max-w-2xl">
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-sm text-gray-900 w-1/3">Final Calculated Cost:</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{formatCost(totalCost)}
|
||||
{isCached && " (Cached)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React from "react";
|
||||
import { Card, Tag, Table, Typography, Space, Tooltip } from "antd";
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined } from "@ant-design/icons";
|
||||
|
||||
const { Text } = Typography;
|
||||
import { CircleCheck, CircleX, FlaskConical } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface EvalVerdict {
|
||||
criterion_name: string;
|
||||
|
|
@ -36,10 +37,10 @@ export default function EvalViewer({ data }: EvalViewerProps) {
|
|||
return (
|
||||
<div className="mb-6">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<ExperimentOutlined style={{ fontSize: 16, color: "#6366f1" }} />
|
||||
<Text strong style={{ fontSize: 15 }}>
|
||||
<FlaskConical className="size-4" style={{ color: "#6366f1" }} />
|
||||
<span className="font-semibold" style={{ fontSize: 15 }}>
|
||||
LLM Judge Results
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entries.map((entry, idx) => (
|
||||
|
|
@ -56,151 +57,159 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
|||
// Filter out synthetic "Overall" row the judge sometimes appends — it's already in the header
|
||||
const verdicts = (entry.verdicts || []).filter((v) => (v.criterion_name || "").toLowerCase() !== "overall");
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Criterion",
|
||||
dataIndex: "criterion_name",
|
||||
key: "criterion_name",
|
||||
width: 160,
|
||||
render: (v: string) => (
|
||||
<Text strong style={{ whiteSpace: "nowrap" }}>
|
||||
{v}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Weight",
|
||||
dataIndex: "weight",
|
||||
key: "weight",
|
||||
width: 65,
|
||||
render: (v: number) =>
|
||||
v != null ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{v}%
|
||||
</Text>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: "Score",
|
||||
dataIndex: "score",
|
||||
key: "score",
|
||||
width: 65,
|
||||
render: (v: number) => (
|
||||
<Text style={{ color: v >= 70 ? "#52c41a" : v >= 50 ? "#faad14" : "#ff4d4f", fontWeight: 600 }}>{v}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="Score × Weight — how much each criterion contributes to the final score">
|
||||
<span style={{ borderBottom: "1px dashed #aaa", cursor: "help" }}>Weighted</span>
|
||||
</Tooltip>
|
||||
),
|
||||
key: "weighted",
|
||||
width: 75,
|
||||
render: (_: unknown, row: EvalVerdict) => {
|
||||
if (row.weight == null) return null;
|
||||
const contrib = (row.score * row.weight) / 100;
|
||||
return (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{contrib % 1 === 0 ? contrib : contrib.toFixed(1)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Comment",
|
||||
dataIndex: "reasoning",
|
||||
key: "reasoning",
|
||||
ellipsis: { showTitle: false },
|
||||
render: (v: string) => (
|
||||
<Tooltip title={v}>
|
||||
<span style={{ fontSize: 12 }}>{v}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
const hasWeights = verdicts.some((v) => v.weight != null);
|
||||
const weightedTotal = verdicts.reduce((sum, v) => sum + (v.weight != null ? (v.score * v.weight) / 100 : 0), 0);
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-3"
|
||||
style={{ borderLeft: `3px solid ${scoreColor}` }}
|
||||
title={
|
||||
<Space>
|
||||
{passed ? (
|
||||
<CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
) : (
|
||||
<CloseCircleOutlined style={{ color: "#ff4d4f" }} />
|
||||
)}
|
||||
<Text strong>{entry.eval_name}</Text>
|
||||
<Tag color={passed ? "success" : "error"}>{passed ? "PASSED" : "FAILED"}</Tag>
|
||||
<Tooltip
|
||||
title={`Weighted average of all criterion scores. Each criterion has a weight (%) set when the eval was created — higher-weight criteria count more toward the final score.`}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12, cursor: "help", borderBottom: "1px dashed #aaa" }}>
|
||||
{entry.overall_score?.toFixed(0)} / 100
|
||||
{entry.threshold != null && ` (threshold: ${entry.threshold})`}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space size="small">
|
||||
{entry.judge_model && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Judge: {entry.judge_model}
|
||||
</Text>
|
||||
)}
|
||||
{entry.iteration != null && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Iter: {entry.iteration + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{entry.eval_error && (
|
||||
<Text type="warning" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
|
||||
Judge error: {entry.eval_error}
|
||||
</Text>
|
||||
)}
|
||||
<Card size="sm" className="mb-3" style={{ borderLeft: `3px solid ${scoreColor}` }}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{passed ? (
|
||||
<CircleCheck className="size-4" style={{ color: "#52c41a" }} />
|
||||
) : (
|
||||
<CircleX className="size-4" style={{ color: "#ff4d4f" }} />
|
||||
)}
|
||||
<span className="font-semibold">{entry.eval_name}</span>
|
||||
<Badge variant={passed ? "secondary" : "destructive"}>{passed ? "PASSED" : "FAILED"}</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{ fontSize: 12, cursor: "help", borderBottom: "1px dashed #aaa" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{entry.overall_score?.toFixed(0)} / 100
|
||||
{entry.threshold != null && ` (threshold: ${entry.threshold})`}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Weighted average of all criterion scores. Each criterion has a weight (%) set when the eval was
|
||||
created — higher-weight criteria count more toward the final score.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.judge_model && (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
Judge: {entry.judge_model}
|
||||
</span>
|
||||
)}
|
||||
{entry.iteration != null && (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
Iter: {entry.iteration + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
{verdicts.length > 0 ? (
|
||||
<Table
|
||||
dataSource={verdicts}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey="criterion_name"
|
||||
scroll={{ x: true }}
|
||||
summary={() => {
|
||||
const hasWeights = verdicts.some((v) => v.weight != null);
|
||||
if (!hasWeights) return null;
|
||||
const total = verdicts.reduce((sum, v) => sum + (v.weight != null ? (v.score * v.weight) / 100 : 0), 0);
|
||||
return (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>
|
||||
<Text strong style={{ fontSize: 12 }}>
|
||||
Total
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} />
|
||||
<Table.Summary.Cell index={2} />
|
||||
<Table.Summary.Cell index={3}>
|
||||
<Text strong style={{ fontSize: 12, color: scoreColor }}>
|
||||
{total % 1 === 0 ? total : total.toFixed(1)}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} />
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Score: {entry.overall_score?.toFixed(1)} — no per-criterion breakdown available.
|
||||
</Text>
|
||||
)}
|
||||
<CardContent>
|
||||
{entry.eval_error && (
|
||||
<span className="text-amber-600" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
|
||||
Judge error: {entry.eval_error}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{verdicts.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead style={{ width: 160 }}>Criterion</TableHead>
|
||||
<TableHead style={{ width: 65 }}>Weight</TableHead>
|
||||
<TableHead style={{ width: 65 }}>Score</TableHead>
|
||||
<TableHead style={{ width: 75 }}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span style={{ borderBottom: "1px dashed #aaa", cursor: "help" }} />}>
|
||||
Weighted
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Score × Weight — how much each criterion contributes to the final score
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{verdicts.map((row) => {
|
||||
const contrib = row.weight != null ? (row.score * row.weight) / 100 : null;
|
||||
return (
|
||||
<TableRow key={row.criterion_name}>
|
||||
<TableCell>
|
||||
<span className="font-semibold" style={{ whiteSpace: "nowrap" }}>
|
||||
{row.criterion_name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.weight != null ? (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
{row.weight}%
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
color: row.score >= 70 ? "#52c41a" : row.score >= 50 ? "#faad14" : "#ff4d4f",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{row.score}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{contrib != null ? (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
{contrib % 1 === 0 ? contrib : contrib.toFixed(1)}
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span style={{ fontSize: 12 }} />}>{row.reasoning}</TooltipTrigger>
|
||||
<TooltipContent>{row.reasoning}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
{hasWeights && (
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="font-semibold" style={{ fontSize: 12 }}>
|
||||
Total
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<span className="font-semibold" style={{ fontSize: 12, color: scoreColor }}>
|
||||
{weightedTotal % 1 === 0 ? weightedTotal : weightedTotal.toFixed(1)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
) : (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
Score: {entry.overall_score?.toFixed(1)} — no per-criterion breakdown available.
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
checkEuAiActCompliance,
|
||||
checkGdprCompliance,
|
||||
|
|
@ -66,9 +66,12 @@ const ComplianceCard = ({
|
|||
{loading ? (
|
||||
<SpinnerIcon />
|
||||
) : error ? (
|
||||
<Tooltip title={error}>
|
||||
<span className="text-gray-400 text-sm">--</span>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="text-gray-400 text-sm" />}>--</TooltipTrigger>
|
||||
<TooltipContent>{error}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : data?.compliant ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useMemo } from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import PresidioDetectedEntities from "./PresidioDetectedEntities";
|
||||
import BedrockGuardrailDetails, {
|
||||
BedrockGuardrailResponse,
|
||||
|
|
@ -517,13 +517,20 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
)}
|
||||
|
||||
{riskScore != null && success && (
|
||||
<Tooltip title={`Risk score: ${riskScore}/10`}>
|
||||
<span
|
||||
className={`px-2 py-0.5 border rounded-sm text-[11px] font-semibold shrink-0 ${getRiskColor(riskScore)}`}
|
||||
>
|
||||
Risk {riskScore}/10
|
||||
</span>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={`px-2 py-0.5 border rounded-sm text-[11px] font-semibold shrink-0 ${getRiskColor(riskScore)}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Risk {riskScore}/10
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{`Risk score: ${riskScore}/10`}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { Button, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, ChevronUp, Copy, X } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LogEntry } from "../columns";
|
||||
import { AutoRouterTag } from "@/components/shared/table_cells";
|
||||
import { ClassifyTag } from "./ClassifyTag";
|
||||
|
|
@ -10,15 +13,11 @@ import {
|
|||
COLOR_BORDER,
|
||||
COLOR_BACKGROUND,
|
||||
SPACING_MEDIUM,
|
||||
SPACING_LARGE,
|
||||
FONT_SIZE_HEADER,
|
||||
FONT_SIZE_MEDIUM,
|
||||
FONT_FAMILY_MONO,
|
||||
SPACING_SMALL,
|
||||
} from "./constants";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface DrawerHeaderProps {
|
||||
log: LogEntry;
|
||||
onClose: () => void;
|
||||
|
|
@ -96,7 +95,7 @@ function ModelProviderSection({
|
|||
providerName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Space size={SPACING_MEDIUM} style={{ marginBottom: SPACING_MEDIUM }}>
|
||||
<div className="flex items-center gap-2" style={{ marginBottom: SPACING_MEDIUM }}>
|
||||
{providerLogo && (
|
||||
<img
|
||||
src={providerLogo}
|
||||
|
|
@ -108,19 +107,19 @@ function ModelProviderSection({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<Space size={SPACING_MEDIUM} direction="horizontal">
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold" style={{ fontSize: 14 }}>
|
||||
{model}
|
||||
</Text>
|
||||
</span>
|
||||
{providerName && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
{providerName}
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
<AutoRouterTag modelGroup={modelGroup} />
|
||||
<ClassifyTag origin={internalCallOrigin} />
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -128,24 +127,50 @@ function ModelProviderSection({
|
|||
* Request ID display with copy functionality
|
||||
*/
|
||||
function RequestIdSection({ requestId }: { requestId: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(requestId);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* clipboard unavailable in non-secure contexts */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip title={requestId}>
|
||||
<Text
|
||||
strong
|
||||
copyable={{ text: requestId, tooltips: ["Copy Request ID", "Copied!"] }}
|
||||
style={{
|
||||
fontSize: FONT_SIZE_HEADER,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{requestId}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{
|
||||
fontSize: FONT_SIZE_HEADER,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{requestId}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copied ? "Copied!" : "Copy Request ID"}
|
||||
onClick={handleCopy}
|
||||
className="ml-1 align-middle text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{requestId}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -172,21 +197,29 @@ function NavigationSection({
|
|||
marginLeft: 4,
|
||||
background: "#fafafa",
|
||||
};
|
||||
const splitStyle = { width: 1, height: 20, background: COLOR_BORDER };
|
||||
|
||||
return (
|
||||
<Space size={SPACING_SMALL} split={<div style={{ width: 1, height: 20, background: COLOR_BORDER }} />}>
|
||||
<Button type="text" size="small" onClick={onPrevious}>
|
||||
<UpOutlined />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={onPrevious}>
|
||||
<ChevronUp className="size-4" />
|
||||
<span style={keyboardShortcutStyle}>K</span>
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={onNext}>
|
||||
<DownOutlined />
|
||||
<div style={splitStyle} />
|
||||
<Button variant="ghost" size="sm" onClick={onNext}>
|
||||
<ChevronDown className="size-4" />
|
||||
<span style={keyboardShortcutStyle}>J</span>
|
||||
</Button>
|
||||
<Tooltip title="ESC to close">
|
||||
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<div style={splitStyle} />
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Button variant="ghost" size="icon-sm" onClick={onClose} />}>
|
||||
<X className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>ESC to close</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -205,17 +238,17 @@ function StatusBar({
|
|||
environment: string;
|
||||
}) {
|
||||
return (
|
||||
<Space size={SPACING_LARGE}>
|
||||
<Tag color={statusColor}>{statusLabel}</Tag>
|
||||
<Tag>Env: {environment}</Tag>
|
||||
<Space size={SPACING_MEDIUM}>
|
||||
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={statusColor === "error" ? "destructive" : "secondary"}>{statusLabel}</Badge>
|
||||
<Badge variant="outline">Env: {environment}</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
{moment(log.startTime).format("MMM D, YYYY h:mm:ss A")}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
</span>
|
||||
<span className="text-muted-foreground" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
({moment(log.startTime).fromNow()})
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,12 +177,19 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.getByText("Loading request & response data...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Request & Response section with Pretty and JSON view modes", () => {
|
||||
it("should switch the Request & Response body between the Pretty and JSON view modes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LogDetailContent logEntry={createLogEntry()} />);
|
||||
|
||||
expect(screen.getByText("Request & Response")).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Pretty" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "JSON" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Pretty")).toBeInTheDocument();
|
||||
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("JSON"));
|
||||
expect(screen.getByRole("tab", { name: "Request" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Pretty"));
|
||||
expect(screen.queryByRole("tab", { name: "Request" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Request and Response tabs when JSON view is selected", async () => {
|
||||
|
|
@ -259,7 +266,7 @@ describe("LogDetailContent", () => {
|
|||
render(<LogDetailContent logEntry={createLogEntry({ cache_hit: "True" })} />);
|
||||
|
||||
expect(screen.getByText("Response Cache")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hit").closest(".ant-tag")).toHaveClass("ant-tag-green");
|
||||
expect(screen.getByText("Hit").className).toMatch(/green/);
|
||||
});
|
||||
|
||||
it("should show prompt cache tokens without an alarming red tag when only provider prompt caching occurred", () => {
|
||||
|
|
@ -282,7 +289,7 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.getByText("34,462")).toBeInTheDocument();
|
||||
expect(screen.getByText("Prompt Cache Creation Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("83")).toBeInTheDocument();
|
||||
expect(screen.getByText("Miss").closest(".ant-tag")).not.toHaveClass("ant-tag-red");
|
||||
expect(screen.getByText("Miss").className).not.toMatch(/red|destructive/);
|
||||
expect(screen.queryByText("Cache Hit")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -310,8 +317,10 @@ describe("LogDetailContent", () => {
|
|||
const user = userEvent.setup();
|
||||
render(<LogDetailContent logEntry={createLogEntry({ cache_hit: "True" })} />);
|
||||
|
||||
const label = screen.getByText("Response Cache").closest(".ant-space") as HTMLElement;
|
||||
await user.hover(within(label).getByRole("img", { name: "info-circle" }));
|
||||
expect(screen.getByText("Response Cache")).toBeInTheDocument();
|
||||
const infoIcons = screen.getAllByRole("img", { name: /info/i });
|
||||
expect(infoIcons).toHaveLength(1);
|
||||
await user.hover(infoIcons[0]);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Docs" })).toHaveAttribute(
|
||||
"href",
|
||||
|
|
@ -333,8 +342,10 @@ describe("LogDetailContent", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const label = screen.getByText("Prompt Cache Read Tokens").closest(".ant-space") as HTMLElement;
|
||||
await user.hover(within(label).getByRole("img", { name: "info-circle" }));
|
||||
expect(screen.getByText("Prompt Cache Read Tokens")).toBeInTheDocument();
|
||||
const infoIcons = screen.getAllByRole("img", { name: /info/i });
|
||||
expect(infoIcons).toHaveLength(1);
|
||||
await user.hover(infoIcons[0]);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Docs" })).toHaveAttribute(
|
||||
"href",
|
||||
|
|
@ -370,7 +381,7 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.queryByText("LiteLLM Overhead")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const retriesItem = () => screen.getByText("Retries").closest(".ant-descriptions-item") as HTMLElement;
|
||||
const retriesItem = () => screen.getByText("Retries").parentElement as HTMLElement;
|
||||
|
||||
it("should display attempted_retries / max_retries for Retries when attempted_retries > 0", () => {
|
||||
render(
|
||||
|
|
@ -386,7 +397,7 @@ describe("LogDetailContent", () => {
|
|||
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success", attempted_retries: 0 } })} />);
|
||||
|
||||
const noneTag = within(retriesItem()).getByText("None");
|
||||
expect(noneTag.closest(".ant-tag")).toHaveClass("ant-tag-green");
|
||||
expect(noneTag.className).toMatch(/green/);
|
||||
});
|
||||
|
||||
it("should display '-' for Retries when attempted_retries is absent from metadata", () => {
|
||||
|
|
@ -444,8 +455,8 @@ describe("LogDetailContent", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const descriptions = screen.getByText("Provider").closest(".ant-descriptions-item");
|
||||
const descriptions = screen.getByText("Provider").parentElement as HTMLElement;
|
||||
expect(descriptions).toBeInTheDocument();
|
||||
expect(within(descriptions as HTMLElement).getByText("-")).toBeInTheDocument();
|
||||
expect(within(descriptions).getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { useState } from "react";
|
||||
import { Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space, Spin, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Check, ChevronDown, ChevronRight, CircleAlert, Copy, Info } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { LogEntry } from "../columns";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { PROMPT_CACHE_CREATION_TOOLTIP, PROMPT_CACHE_READ_TOOLTIP } from "@/utils/promptCacheUsage";
|
||||
|
|
@ -32,13 +38,10 @@ import {
|
|||
FONT_SIZE_SMALL,
|
||||
FONT_FAMILY_MONO,
|
||||
SPACING_XLARGE,
|
||||
SPACING_MEDIUM,
|
||||
} from "./constants";
|
||||
import { ToolsSection } from "../ToolsSection";
|
||||
import { PrettyMessagesView } from "./PrettyMessagesView";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export interface LogDetailContentProps {
|
||||
logEntry: LogEntry;
|
||||
/** When true, log details (messages/response) are still being lazy-loaded. */
|
||||
|
|
@ -100,13 +103,16 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
|
|||
<div style={{ padding: `${DRAWER_CONTENT_PADDING} ${DRAWER_CONTENT_PADDING} 0` }}>
|
||||
{/* Error Alert */}
|
||||
{hasError && errorInfo && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Request Failed"
|
||||
description={<ErrorDescription errorInfo={errorInfo} />}
|
||||
className="mb-6"
|
||||
/>
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-6 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<CircleAlert className="size-4 shrink-0 text-destructive" />
|
||||
<div>
|
||||
<div className="font-medium text-destructive">Request Failed</div>
|
||||
<ErrorDescription errorInfo={errorInfo} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
|
|
@ -116,26 +122,31 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
|
|||
|
||||
{/* Request Details */}
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Card title="Request Details" size="small" bordered={false} style={{ marginBottom: 0 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item>
|
||||
<Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="Model ID">
|
||||
<TruncatedValue value={logEntry.model_id} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API Base">
|
||||
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
|
||||
</Descriptions.Item>
|
||||
{logEntry.requester_ip_address && (
|
||||
<Descriptions.Item label="IP Address">{logEntry.requester_ip_address}</Descriptions.Item>
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<Descriptions.Item label="Guardrail">
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
<Card size="sm" style={{ marginBottom: 0 }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Request Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList>
|
||||
<DescriptionItem label="Model">{logEntry.model}</DescriptionItem>
|
||||
<DescriptionItem label="Provider">{logEntry.custom_llm_provider || "-"}</DescriptionItem>
|
||||
<DescriptionItem label="Call Type">{logEntry.call_type}</DescriptionItem>
|
||||
<DescriptionItem label="Model ID">
|
||||
<TruncatedValue value={logEntry.model_id} />
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label="API Base">
|
||||
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
|
||||
</DescriptionItem>
|
||||
{logEntry.requester_ip_address && (
|
||||
<DescriptionItem label="IP Address">{logEntry.requester_ip_address}</DescriptionItem>
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<DescriptionItem label="Guardrail">
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
</DescriptionItem>
|
||||
)}
|
||||
</DescriptionList>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
|
@ -170,7 +181,7 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
|
|||
{/* Request/Response JSON */}
|
||||
{isLoadingDetails ? (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6 p-8 text-center">
|
||||
<Spin size="default" />
|
||||
<UiLoadingSpinner className="inline-block size-5" />
|
||||
<div style={{ marginTop: 8, color: "#999" }}>Loading request & response data...</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -221,17 +232,64 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
|
|||
// Helper Components
|
||||
// ============================================================================
|
||||
|
||||
function DescriptionList({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">{children}</div>;
|
||||
}
|
||||
|
||||
function DescriptionItem({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-0.5">
|
||||
<span className="shrink-0 text-muted-foreground after:content-[':']">{label}</span>
|
||||
<span className="min-w-0 break-words">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({
|
||||
getText,
|
||||
label,
|
||||
disabled = false,
|
||||
}: {
|
||||
getText: () => string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(getText());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* clipboard unavailable in non-secure contexts */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleCopy}
|
||||
disabled={disabled}
|
||||
aria-label={copied ? "Copied!" : label}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorDescription({ errorInfo }: { errorInfo: any }) {
|
||||
return (
|
||||
<div>
|
||||
{errorInfo.error_code && (
|
||||
<div>
|
||||
<Text strong>Error Code:</Text> {errorInfo.error_code}
|
||||
<span className="font-semibold">Error Code:</span> {errorInfo.error_code}
|
||||
</div>
|
||||
)}
|
||||
{errorInfo.error_message && (
|
||||
<div>
|
||||
<Text strong>Message:</Text> {errorInfo.error_message}
|
||||
<span className="font-semibold">Message:</span> {errorInfo.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -241,16 +299,16 @@ function ErrorDescription({ errorInfo }: { errorInfo: any }) {
|
|||
function TagsSection({ tags }: { tags: Record<string, any> }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden p-4 mb-6">
|
||||
<Text strong style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
|
||||
<span className="font-semibold" style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
|
||||
Tags
|
||||
</Text>
|
||||
<Space size={SPACING_MEDIUM} wrap>
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.entries(tags).map(([key, value]) => (
|
||||
<Tag key={key}>
|
||||
<Badge key={key} variant="outline">
|
||||
{key}: {String(value)}
|
||||
</Tag>
|
||||
</Badge>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -262,12 +320,12 @@ function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: nu
|
|||
};
|
||||
|
||||
return (
|
||||
<Space size={SPACING_MEDIUM}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<a onClick={handleClick} style={{ cursor: "pointer" }}>
|
||||
{label}
|
||||
</a>
|
||||
{maskedCount > 0 && <Tag color="blue">{maskedCount} masked</Tag>}
|
||||
</Space>
|
||||
{maskedCount > 0 && <Badge variant="secondary">{maskedCount} masked</Badge>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -291,26 +349,24 @@ const PROMPT_CACHE_DOCS_URL = "https://docs.litellm.ai/docs/completion/prompt_ca
|
|||
|
||||
function MetricLabel({ label, tooltip, docsUrl }: { label: string; tooltip: string; docsUrl: string }) {
|
||||
return (
|
||||
<Space size={4}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span role="img" aria-label={`${label} info`} className="inline-flex text-muted-foreground" />}
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tooltip}{" "}
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#91caff", textDecoration: "underline" }}
|
||||
>
|
||||
<a href={docsUrl} target="_blank" rel="noreferrer" className="underline">
|
||||
Docs
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InfoCircleOutlined style={{ color: "#8c8c8c" }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -333,102 +389,111 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Card title="Metrics" size="small" style={{ marginBottom: 0 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
{showAnthropicMessagesInputOutput ? (
|
||||
<>
|
||||
<Descriptions.Item label="Input Tokens">{formatNumberWithCommas(uncachedInputTokens)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Output Tokens">
|
||||
{formatNumberWithCommas(logEntry.completion_tokens)}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
) : (
|
||||
<Descriptions.Item label="Tokens">
|
||||
<TokenFlow
|
||||
prompt={logEntry.prompt_tokens}
|
||||
completion={logEntry.completion_tokens}
|
||||
total={logEntry.total_tokens}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Duration">
|
||||
{logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s
|
||||
</Descriptions.Item>
|
||||
{ttftMs != null && ttftMs > 0 && (
|
||||
<Descriptions.Item label="Time to First Token">{(ttftMs / 1000).toFixed(3)} s</Descriptions.Item>
|
||||
)}
|
||||
|
||||
{showResponseCache && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Response Cache"
|
||||
tooltip={RESPONSE_CACHE_TOOLTIP}
|
||||
docsUrl={RESPONSE_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tag color={isResponseCacheHit ? "green" : "default"}>{isResponseCacheHit ? "Hit" : "Miss"}</Tag>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{promptCacheReadTokens > 0 && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Read Tokens"
|
||||
tooltip={PROMPT_CACHE_READ_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formatNumberWithCommas(promptCacheReadTokens)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{promptCacheCreationTokens > 0 && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Creation Tokens"
|
||||
tooltip={PROMPT_CACHE_CREATION_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formatNumberWithCommas(promptCacheCreationTokens)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
|
||||
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
|
||||
<Descriptions.Item label="LiteLLM Overhead">
|
||||
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
|
||||
<Descriptions.Item label="Retries">
|
||||
{metadata?.attempted_retries !== undefined && metadata?.attempted_retries !== null ? (
|
||||
metadata.attempted_retries > 0 ? (
|
||||
<>
|
||||
{metadata.attempted_retries}
|
||||
{metadata.max_retries !== undefined && metadata.max_retries !== null
|
||||
? ` / ${metadata.max_retries}`
|
||||
: ""}
|
||||
</>
|
||||
) : (
|
||||
<Tag color="green">None</Tag>
|
||||
)
|
||||
<Card size="sm" style={{ marginBottom: 0 }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList>
|
||||
{showAnthropicMessagesInputOutput ? (
|
||||
<>
|
||||
<DescriptionItem label="Input Tokens">{formatNumberWithCommas(uncachedInputTokens)}</DescriptionItem>
|
||||
<DescriptionItem label="Output Tokens">
|
||||
{formatNumberWithCommas(logEntry.completion_tokens)}
|
||||
</DescriptionItem>
|
||||
</>
|
||||
) : (
|
||||
"-"
|
||||
<DescriptionItem label="Tokens">
|
||||
<TokenFlow
|
||||
prompt={logEntry.prompt_tokens}
|
||||
completion={logEntry.completion_tokens}
|
||||
total={logEntry.total_tokens}
|
||||
/>
|
||||
</DescriptionItem>
|
||||
)}
|
||||
<DescriptionItem label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</DescriptionItem>
|
||||
<DescriptionItem label="Duration">
|
||||
{logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s
|
||||
</DescriptionItem>
|
||||
{ttftMs != null && ttftMs > 0 && (
|
||||
<DescriptionItem label="Time to First Token">{(ttftMs / 1000).toFixed(3)} s</DescriptionItem>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="Start Time">
|
||||
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="End Time">
|
||||
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{showResponseCache && (
|
||||
<DescriptionItem
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Response Cache"
|
||||
tooltip={RESPONSE_CACHE_TOOLTIP}
|
||||
docsUrl={RESPONSE_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Badge variant="secondary" className={isResponseCacheHit ? "bg-green-100 text-green-700" : undefined}>
|
||||
{isResponseCacheHit ? "Hit" : "Miss"}
|
||||
</Badge>
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{promptCacheReadTokens > 0 && (
|
||||
<DescriptionItem
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Read Tokens"
|
||||
tooltip={PROMPT_CACHE_READ_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formatNumberWithCommas(promptCacheReadTokens)}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{promptCacheCreationTokens > 0 && (
|
||||
<DescriptionItem
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Creation Tokens"
|
||||
tooltip={PROMPT_CACHE_CREATION_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formatNumberWithCommas(promptCacheCreationTokens)}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
|
||||
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
|
||||
<DescriptionItem label="LiteLLM Overhead">
|
||||
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
|
||||
</DescriptionItem>
|
||||
)}
|
||||
|
||||
<DescriptionItem label="Retries">
|
||||
{metadata?.attempted_retries !== undefined && metadata?.attempted_retries !== null ? (
|
||||
metadata.attempted_retries > 0 ? (
|
||||
<>
|
||||
{metadata.attempted_retries}
|
||||
{metadata.max_retries !== undefined && metadata.max_retries !== null
|
||||
? ` / ${metadata.max_retries}`
|
||||
: ""}
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-700">
|
||||
None
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</DescriptionItem>
|
||||
|
||||
<DescriptionItem label="Start Time">
|
||||
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label="End Time">
|
||||
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -449,6 +514,7 @@ function RequestResponseSection({
|
|||
getFormattedResponse,
|
||||
logEntry,
|
||||
}: RequestResponseSectionProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
|
||||
const [viewMode, setViewMode] = useState<"pretty" | "json">("pretty");
|
||||
|
||||
|
|
@ -476,90 +542,76 @@ function RequestResponseSection({
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".ant-radio-group")) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-medium text-gray-900" style={{ margin: 0 }}>
|
||||
Request & Response
|
||||
</h3>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="pretty">Pretty</Radio.Button>
|
||||
<Radio.Button value="json">JSON</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{viewMode === "pretty" ? (
|
||||
<PrettyMessagesView
|
||||
request={getRawRequest()}
|
||||
response={getFormattedResponse()}
|
||||
metrics={{
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
input_cost: inputCost,
|
||||
output_cost: outputCost,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
|
||||
tabBarExtraContent={
|
||||
<Text
|
||||
copyable={{
|
||||
text: getCopyText(),
|
||||
tooltips: ["Copy JSON", "Copied!"],
|
||||
}}
|
||||
disabled={activeTab === TAB_RESPONSE && !hasResponse && !hasError}
|
||||
/>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: TAB_REQUEST,
|
||||
label: "Request",
|
||||
children: (
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
<JsonViewer data={getRawRequest()} mode="formatted" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TAB_RESPONSE,
|
||||
label: "Response",
|
||||
children: (
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
{hasResponse || hasError ? (
|
||||
<JsonViewer data={getFormattedResponse()} mode="formatted" />
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
|
||||
Response data not available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<Tabs value={viewMode} onValueChange={(value) => setViewMode(value as "pretty" | "json")}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
|
||||
<CollapsibleTrigger className="flex flex-1 items-center gap-3 px-4 py-3 text-left">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900" style={{ margin: 0 }}>
|
||||
Request & Response
|
||||
</h3>
|
||||
</CollapsibleTrigger>
|
||||
<TabsList className="mr-4">
|
||||
<TabsTrigger value="pretty">Pretty</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div>
|
||||
<TabsContent value="pretty">
|
||||
<PrettyMessagesView
|
||||
request={getRawRequest()}
|
||||
response={getFormattedResponse()}
|
||||
metrics={{
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
input_cost: inputCost,
|
||||
output_cost: outputCost,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="json">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value={TAB_REQUEST}>Request</TabsTrigger>
|
||||
<TabsTrigger value={TAB_RESPONSE}>Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<CopyButton
|
||||
getText={getCopyText}
|
||||
label="Copy JSON"
|
||||
disabled={activeTab === TAB_RESPONSE && !hasResponse && !hasError}
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value={TAB_REQUEST}>
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
<JsonViewer data={getRawRequest()} mode="formatted" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value={TAB_RESPONSE}>
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
{hasResponse || hasError ? (
|
||||
<JsonViewer data={getFormattedResponse()} mode="formatted" />
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
|
||||
Response data not available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Tabs>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -602,43 +654,40 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
|
|||
}
|
||||
|
||||
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: <h3 className="text-lg font-medium text-gray-900">Metadata</h3>,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
|
||||
<Text
|
||||
copyable={{
|
||||
text: JSON.stringify(metadata, null, 2),
|
||||
tooltips: ["Copy Metadata", "Copied!"],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: METADATA_MAX_HEIGHT,
|
||||
overflowY: "auto",
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900">Metadata</h3>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
|
||||
<CopyButton getText={() => JSON.stringify(metadata, null, 2)} label="Copy Metadata" />
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: METADATA_MAX_HEIGHT,
|
||||
overflowY: "auto",
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Drawer, Segmented } from "antd";
|
||||
import { CheckOutlined, CopyOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { Bot, Sparkles, Wrench } from "lucide-react";
|
||||
import { Bot, Check, ChevronLeft, ChevronRight, Copy, Sparkles, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LogEntry } from "../columns";
|
||||
import { AutoRouterIcon, useIsAutoRoutedModelGroup } from "@/components/shared/table_cells";
|
||||
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants";
|
||||
|
|
@ -297,181 +298,186 @@ export function LogDetailsDrawer({
|
|||
if (!currentLog || !enrichedLog) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={null}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
<Sheet
|
||||
open={open}
|
||||
width={DRAWER_WIDTH}
|
||||
closable={false}
|
||||
mask={true}
|
||||
maskClosable={true}
|
||||
styles={{
|
||||
body: { padding: 0, overflow: "hidden" },
|
||||
header: { display: "none" },
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onClose();
|
||||
}}
|
||||
>
|
||||
<div style={{ height: "100%" }} className="flex relative">
|
||||
{!isSidebarCollapsed ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => setIsSidebarCollapsed(true)}
|
||||
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
|
||||
aria-label="Collapse trace sidebar"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => setIsSidebarCollapsed(false)}
|
||||
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
|
||||
aria-label="Expand trace sidebar"
|
||||
/>
|
||||
)}
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="border-r border-slate-200 bg-slate-50 flex flex-col" style={{ width: SIDEBAR_WIDTH_PX }}>
|
||||
<div className="pl-12 pr-3 py-2 border-b border-slate-200 bg-white">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">
|
||||
{isSessionMode ? "Session" : "Trace"}
|
||||
</div>
|
||||
<div className="font-mono text-[12px] text-slate-900 leading-tight flex items-center gap-1">
|
||||
<span className="truncate">{leftPanelDisplayId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLeftPanelId}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
aria-label="Copy trace id"
|
||||
>
|
||||
{copiedLeftPanelId ? (
|
||||
<CheckOutlined className="text-[11px]" />
|
||||
) : (
|
||||
<CopyOutlined className="text-[11px]" />
|
||||
)}
|
||||
</button>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="gap-0 overflow-hidden p-0 data-[side=right]:sm:max-w-none"
|
||||
style={{ width: DRAWER_WIDTH }}
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
{logEntry?.request_id ? `Request ${logEntry.request_id} details` : "Request details"}
|
||||
</SheetTitle>
|
||||
<div style={{ height: "100%" }} className="flex relative">
|
||||
{!isSidebarCollapsed ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setIsSidebarCollapsed(true)}
|
||||
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
|
||||
aria-label="Collapse trace sidebar"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setIsSidebarCollapsed(false)}
|
||||
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
|
||||
aria-label="Expand trace sidebar"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="border-r border-slate-200 bg-slate-50 flex flex-col" style={{ width: SIDEBAR_WIDTH_PX }}>
|
||||
<div className="pl-12 pr-3 py-2 border-b border-slate-200 bg-white">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">
|
||||
{isSessionMode ? "Session" : "Trace"}
|
||||
</div>
|
||||
<div className="font-mono text-[12px] text-slate-900 leading-tight flex items-center gap-1">
|
||||
<span className="truncate">{leftPanelDisplayId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLeftPanelId}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
aria-label="Copy trace id"
|
||||
>
|
||||
{copiedLeftPanelId ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-slate-500 font-mono">
|
||||
{logsForList.length} req
|
||||
{[
|
||||
isSessionMode
|
||||
? llmCount
|
||||
: logsForList.filter(
|
||||
(row) => !MCP_CALL_TYPES.includes(row.call_type) && !AGENT_CALL_TYPES.includes(row.call_type),
|
||||
).length,
|
||||
isSessionMode
|
||||
? agentCount
|
||||
: logsForList.filter((row) => AGENT_CALL_TYPES.includes(row.call_type)).length,
|
||||
isSessionMode ? mcpCount : logsForList.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length,
|
||||
].map((count, i) => {
|
||||
const label = [" LLM", " Agent", " MCP"][i];
|
||||
return count > 0 ? (
|
||||
<span key={label}>
|
||||
<div className="mt-1 text-[11px] text-slate-500 font-mono">
|
||||
{logsForList.length} req
|
||||
{[
|
||||
isSessionMode
|
||||
? llmCount
|
||||
: logsForList.filter(
|
||||
(row) => !MCP_CALL_TYPES.includes(row.call_type) && !AGENT_CALL_TYPES.includes(row.call_type),
|
||||
).length,
|
||||
isSessionMode
|
||||
? agentCount
|
||||
: logsForList.filter((row) => AGENT_CALL_TYPES.includes(row.call_type)).length,
|
||||
isSessionMode
|
||||
? mcpCount
|
||||
: logsForList.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length,
|
||||
].map((count, i) => {
|
||||
const label = [" LLM", " Agent", " MCP"][i];
|
||||
return count > 0 ? (
|
||||
<span key={label}>
|
||||
<span className="mx-1.5">·</span>
|
||||
{count}
|
||||
{label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
<span className="mx-1.5">·</span>
|
||||
{isSessionMode ? getSpendString(totalSessionCost) : getSpendString(currentLog.spend || 0)}
|
||||
{isSessionMode && (
|
||||
<>
|
||||
<span className="mx-1.5">·</span>
|
||||
{count}
|
||||
{label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
<span className="mx-1.5">·</span>
|
||||
{isSessionMode ? getSpendString(totalSessionCost) : getSpendString(currentLog.spend || 0)}
|
||||
{sessionDurationSeconds}s
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isSessionMode && sessionTruncated && (
|
||||
<div className="mt-1 text-[11px] text-amber-600 font-mono">
|
||||
Showing most recent {logsForList.length} of {sessionTotalCount}
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode && (
|
||||
<>
|
||||
<span className="mx-1.5">·</span>
|
||||
{sessionDurationSeconds}s
|
||||
</>
|
||||
<Tabs
|
||||
className="mt-1.5"
|
||||
value={sessionSortMode}
|
||||
onValueChange={(value) => setSessionSortMode(value as SessionLogSortMode)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="duration" className="text-[11px]">
|
||||
Duration
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="start_time" className="text-[11px]">
|
||||
Start time
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
{isSessionMode && sessionTruncated && (
|
||||
<div className="mt-1 text-[11px] text-amber-600 font-mono">
|
||||
Showing most recent {logsForList.length} of {sessionTotalCount}
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode && (
|
||||
<Segmented
|
||||
block
|
||||
size="small"
|
||||
className="mt-1.5 [&_.ant-segmented-item-label]:text-[11px]"
|
||||
options={[
|
||||
{ label: "Duration", value: "duration" },
|
||||
{ label: "Start time", value: "start_time" },
|
||||
]}
|
||||
value={sessionSortMode}
|
||||
onChange={(value) => setSessionSortMode(value as SessionLogSortMode)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && (
|
||||
<div className="px-3 pt-2">
|
||||
<GuardrailJumpLink guardrailEntries={normalizeGuardrailEntries(metadata?.guardrail_information)} />
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode ? (
|
||||
<div className="py-1">
|
||||
{/* Child events — vertical tree line with horizontal connectors */}
|
||||
<div className="relative pl-2">
|
||||
<div className="absolute left-4 top-1 bottom-1 border-l border-slate-300" />
|
||||
{logsForList.map((row, idx) => {
|
||||
const isLast = idx === logsForList.length - 1;
|
||||
return (
|
||||
<div key={row.request_id} className="relative">
|
||||
<div className="absolute left-4 top-3 w-3 border-t border-slate-300" />
|
||||
{isLast && <div className="absolute left-4 top-3 bottom-0 w-px bg-slate-50" />}
|
||||
<TraceEventRow
|
||||
row={row}
|
||||
isSelected={row.request_id === currentLog.request_id}
|
||||
onClick={() => {
|
||||
setSelectedSessionRequestId(row.request_id);
|
||||
onSelectLog?.(row);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && (
|
||||
<div className="px-3 pt-2">
|
||||
<GuardrailJumpLink guardrailEntries={normalizeGuardrailEntries(metadata?.guardrail_information)} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{logsForList.map((row) => (
|
||||
<TraceEventRow
|
||||
key={row.request_id}
|
||||
row={row}
|
||||
isSelected={row.request_id === currentLog.request_id}
|
||||
onClick={() => onSelectLog?.(row)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{isSessionMode ? (
|
||||
<div className="py-1">
|
||||
{/* Child events — vertical tree line with horizontal connectors */}
|
||||
<div className="relative pl-2">
|
||||
<div className="absolute left-4 top-1 bottom-1 border-l border-slate-300" />
|
||||
{logsForList.map((row, idx) => {
|
||||
const isLast = idx === logsForList.length - 1;
|
||||
return (
|
||||
<div key={row.request_id} className="relative">
|
||||
<div className="absolute left-4 top-3 w-3 border-t border-slate-300" />
|
||||
{isLast && <div className="absolute left-4 top-3 bottom-0 w-px bg-slate-50" />}
|
||||
<TraceEventRow
|
||||
row={row}
|
||||
isSelected={row.request_id === currentLog.request_id}
|
||||
onClick={() => {
|
||||
setSelectedSessionRequestId(row.request_id);
|
||||
onSelectLog?.(row);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{logsForList.map((row) => (
|
||||
<TraceEventRow
|
||||
key={row.request_id}
|
||||
row={row}
|
||||
isSelected={row.request_id === currentLog.request_id}
|
||||
onClick={() => onSelectLog?.(row)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<DrawerHeader
|
||||
log={currentLog}
|
||||
onClose={onClose}
|
||||
onPrevious={selectPreviousLog}
|
||||
onNext={selectNextLog}
|
||||
statusLabel={statusLabel}
|
||||
statusColor={statusColor}
|
||||
environment={environment}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<LogDetailContent
|
||||
logEntry={enrichedLog}
|
||||
isLoadingDetails={isLoadingDetails}
|
||||
accessToken={accessToken ?? null}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<DrawerHeader
|
||||
log={currentLog}
|
||||
onClose={onClose}
|
||||
onPrevious={selectPreviousLog}
|
||||
onNext={selectNextLog}
|
||||
statusLabel={statusLabel}
|
||||
statusColor={statusColor}
|
||||
environment={environment}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<LogDetailContent
|
||||
logEntry={enrichedLog}
|
||||
isLoadingDetails={isLoadingDetails}
|
||||
accessToken={accessToken ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,6 @@ import userEvent from "@testing-library/user-event";
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { RealtimePrettyView, isRealtimeResponse } from "./RealtimePrettyView";
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const actual = await vi.importActual<typeof import("antd")>("antd");
|
||||
return {
|
||||
...actual,
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const sampleRealtimeResponse = {
|
||||
usage: {
|
||||
total_tokens: 587,
|
||||
|
|
|
|||
|
|
@ -5,19 +5,11 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Tag, Tooltip } from "antd";
|
||||
import {
|
||||
SoundOutlined,
|
||||
MessageOutlined,
|
||||
SettingOutlined,
|
||||
AudioOutlined,
|
||||
DownOutlined,
|
||||
UpOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ChevronDown, ChevronUp, MessageSquare, Mic, Settings, Volume2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface RealtimeEvent {
|
||||
type: string;
|
||||
event_id?: string;
|
||||
|
|
@ -163,34 +155,34 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
|
|||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{isCollapsed ? (
|
||||
<DownOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
|
||||
<ChevronDown className="size-2.5 text-muted-foreground" />
|
||||
) : (
|
||||
<UpOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
|
||||
<ChevronUp className="size-2.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<SettingOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
|
||||
<Text style={{ fontWeight: 500, fontSize: 14 }}>Session</Text>
|
||||
<Settings className="size-3.5 text-muted-foreground" />
|
||||
<span style={{ fontWeight: 500, fontSize: 14 }}>Session</span>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
{session.model}
|
||||
</Text>
|
||||
</span>
|
||||
{turnCount > 0 && (
|
||||
<Tag color="purple" style={{ margin: 0, fontWeight: 500 }}>
|
||||
<Badge variant="secondary" style={{ margin: 0, fontWeight: 500 }}>
|
||||
{turnCount} {turnCount === 1 ? "turn" : "turns"}
|
||||
</Tag>
|
||||
</Badge>
|
||||
)}
|
||||
{session.voice && (
|
||||
<Tag color="blue" style={{ margin: 0 }}>
|
||||
<SoundOutlined /> {session.voice}
|
||||
</Tag>
|
||||
<Badge variant="secondary" style={{ margin: 0 }}>
|
||||
<Volume2 className="size-3" /> {session.voice}
|
||||
</Badge>
|
||||
)}
|
||||
{session.modalities && (
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{session.modalities.map((m) => (
|
||||
<Tag key={m} style={{ margin: 0 }}>
|
||||
{m === "audio" ? <AudioOutlined /> : <MessageOutlined />} {m}
|
||||
</Tag>
|
||||
<Badge key={m} variant="outline" style={{ margin: 0 }}>
|
||||
{m === "audio" ? <Mic className="size-3" /> : <MessageSquare className="size-3" />} {m}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -228,8 +220,8 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
|
|||
|
||||
{session.instructions && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.5px",
|
||||
|
|
@ -239,7 +231,7 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
|
|||
}}
|
||||
>
|
||||
Instructions
|
||||
</Text>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
|
|
@ -344,20 +336,25 @@ function ResponseTurn({ response, index }: { response: RealtimeResponse; index:
|
|||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Tag color={response.status === "completed" ? "green" : "orange"} style={{ margin: 0 }}>
|
||||
<Badge variant={response.status === "completed" ? "secondary" : "outline"} style={{ margin: 0 }}>
|
||||
{response.status || "unknown"}
|
||||
</Tag>
|
||||
</Badge>
|
||||
{usage && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{usage.input_tokens ?? 0} in / {usage.output_tokens ?? 0} out tokens
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
{response.conversation_id && (
|
||||
<Tooltip title={response.conversation_id}>
|
||||
<Text type="secondary" style={{ fontSize: 11, cursor: "help" }}>
|
||||
conv: {response.conversation_id.slice(0, 12)}...
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span className="text-muted-foreground" style={{ fontSize: 11, cursor: "help" }} />}
|
||||
>
|
||||
conv: {response.conversation_id.slice(0, 12)}...
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{response.conversation_id}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -381,8 +378,8 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
|
|||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.5px",
|
||||
|
|
@ -392,7 +389,7 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
|
|||
}}
|
||||
>
|
||||
{output.role?.toUpperCase() || "ASSISTANT"}
|
||||
</Text>
|
||||
</span>
|
||||
{contents.map((c, cIdx) => {
|
||||
const text = c.transcript || c.text;
|
||||
if (!text) return null;
|
||||
|
|
@ -407,20 +404,18 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
|
|||
}}
|
||||
>
|
||||
{c.type === "audio" && (
|
||||
<AudioOutlined
|
||||
<Mic
|
||||
className="size-3 text-muted-foreground"
|
||||
style={{
|
||||
color: "#8c8c8c",
|
||||
fontSize: 12,
|
||||
marginTop: 3,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{c.type === "text" && (
|
||||
<MessageOutlined
|
||||
<MessageSquare
|
||||
className="size-3 text-muted-foreground"
|
||||
style={{
|
||||
color: "#8c8c8c",
|
||||
fontSize: 12,
|
||||
marginTop: 3,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
|
|
@ -453,9 +448,12 @@ function TokenBreakdown({ label, details }: { label: string; details: Record<str
|
|||
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 10, letterSpacing: "0.5px", textTransform: "uppercase" }}>
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{ fontSize: 10, letterSpacing: "0.5px", textTransform: "uppercase" }}
|
||||
>
|
||||
{label} Token Breakdown
|
||||
</Text>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
|
@ -467,9 +465,9 @@ function TokenBreakdown({ label, details }: { label: string; details: Record<str
|
|||
{entries.map(([key, value]) => {
|
||||
if (typeof value === "number") {
|
||||
return (
|
||||
<Tag key={key} style={{ margin: 0 }}>
|
||||
<Badge key={key} variant="outline" style={{ margin: 0 }}>
|
||||
{formatTokenLabel(key)}: {value.toLocaleString()}
|
||||
</Tag>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
@ -483,9 +481,9 @@ function ConfigRow({ label, value }: { label: string; value: any }) {
|
|||
if (value === undefined || value === null) return null;
|
||||
return (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{label}
|
||||
</Text>
|
||||
</span>
|
||||
<div style={{ fontSize: 13, color: "#262626" }}>{String(value)}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@
|
|||
* Formatted view of tool definition with parameters table and call data
|
||||
*/
|
||||
|
||||
import { Typography, Table } from "antd";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ParsedTool, ParameterRow } from "./types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface FormattedToolViewProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
|
@ -23,57 +21,27 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
|||
}),
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Parameter",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (name: string, record: ParameterRow) => (
|
||||
<Text code>
|
||||
{name}
|
||||
{record.required && <Text type="danger">*</Text>}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
render: (type: string) => (
|
||||
<Text code style={{ color: "#1890ff" }}>
|
||||
{type}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Description",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
render: (desc: string) => <Text type="secondary">{desc}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Description */}
|
||||
{tool.description && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text
|
||||
<span
|
||||
style={{
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{tool.description}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameters Table */}
|
||||
{parameterRows.length > 0 && (
|
||||
<div>
|
||||
<Text
|
||||
type="secondary"
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: "block",
|
||||
|
|
@ -81,16 +49,42 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
|||
}}
|
||||
>
|
||||
Parameters
|
||||
</Text>
|
||||
<Table dataSource={parameterRows} columns={columns} pagination={false} size="small" bordered />
|
||||
</span>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Parameter</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parameterRows.map((row) => (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell>
|
||||
<code>
|
||||
{row.name}
|
||||
{row.required && <span className="text-destructive">*</span>}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-blue-600">{row.type}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-muted-foreground">{row.description}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* If tool was called, show the arguments used */}
|
||||
{tool.called && tool.callData && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: "block",
|
||||
|
|
@ -98,7 +92,7 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
|||
}}
|
||||
>
|
||||
Called With
|
||||
</Text>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
background: "#f6ffed",
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Radio } from "antd";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ParsedTool } from "./types";
|
||||
import { FormattedToolView } from "./FormattedToolView";
|
||||
import { JsonToolView } from "./JsonToolView";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ViewMode = "formatted" | "json";
|
||||
|
||||
interface ToolExpandedContentProps {
|
||||
|
|
@ -29,13 +27,13 @@ export function ToolExpandedContent({ tool }: ToolExpandedContentProps) {
|
|||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Description
|
||||
</Text>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="formatted">Formatted</Radio.Button>
|
||||
<Radio.Button value="json">JSON</Radio.Button>
|
||||
</Radio.Group>
|
||||
<span className="text-xs text-muted-foreground">Description</span>
|
||||
<Tabs value={viewMode} onValueChange={(value) => setViewMode(value as ViewMode)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="formatted">Formatted</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{viewMode === "formatted" ? <FormattedToolView tool={tool} /> : <JsonToolView tool={tool} />}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Tag } from "antd";
|
||||
import { ToolOutlined, RightOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { ChevronDown, ChevronRight, Wrench } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ParsedTool } from "./types";
|
||||
import { ToolExpandedContent } from "./ToolExpandedContent";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ToolItemProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
|
@ -39,18 +37,18 @@ export function ToolItem({ tool }: ToolItemProps) {
|
|||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ToolOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
|
||||
<Text style={{ fontSize: 14 }}>
|
||||
<Wrench className="size-3.5 text-muted-foreground" />
|
||||
<span style={{ fontSize: 14 }}>
|
||||
{tool.index}. {tool.name}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Tag color={tool.called ? "blue" : "default"}>{tool.called ? "called" : "not called"}</Tag>
|
||||
<Badge variant={tool.called ? "default" : "secondary"}>{tool.called ? "called" : "not called"}</Badge>
|
||||
{expanded ? (
|
||||
<DownOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<RightOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { Collapse } from "antd";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { getProviderLogoAndName } from "../provider_info_helpers";
|
||||
|
||||
interface VectorStoreContent {
|
||||
|
|
@ -31,6 +32,7 @@ interface VectorStoreViewerProps {
|
|||
}
|
||||
|
||||
export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
|
|
@ -57,110 +59,110 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: <h3 className="text-lg font-medium text-gray-900">Vector Store Requests</h3>,
|
||||
children: (
|
||||
<div className="p-4">
|
||||
{data.map((request, index) => (
|
||||
<div key={index} className="mb-6 last:mb-0">
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Query:</span>
|
||||
<span className="font-mono">{request.query}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Vector Store ID:</span>
|
||||
<span className="font-mono">{request.vector_store_id}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Provider:</span>
|
||||
<span className="flex items-center">
|
||||
{(() => {
|
||||
const { logo, displayName } = getProviderLogoAndName(request.custom_llm_provider);
|
||||
return (
|
||||
<>
|
||||
{logo && <img src={logo} alt={`${displayName} logo`} className="h-5 w-5 mr-2" />}
|
||||
{displayName}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900">Vector Store Requests</h3>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="p-4">
|
||||
{data.map((request, index) => (
|
||||
<div key={index} className="mb-6 last:mb-0">
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Query:</span>
|
||||
<span className="font-mono">{request.query}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Vector Store ID:</span>
|
||||
<span className="font-mono">{request.vector_store_id}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Provider:</span>
|
||||
<span className="flex items-center">
|
||||
{(() => {
|
||||
const { logo, displayName } = getProviderLogoAndName(request.custom_llm_provider);
|
||||
return (
|
||||
<>
|
||||
{logo && <img src={logo} alt={`${displayName} logo`} className="h-5 w-5 mr-2" />}
|
||||
{displayName}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{formatTime(request.start_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{formatTime(request.end_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Duration:</span>
|
||||
<span>{calculateDuration(request.start_time, request.end_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium mb-2">Search Results</h4>
|
||||
<div className="space-y-2">
|
||||
{request.vector_store_search_response.data.map((result, resultIndex) => {
|
||||
const isExpanded = expandedResults[`${index}-${resultIndex}`] || false;
|
||||
|
||||
return (
|
||||
<div key={resultIndex} className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center p-3 bg-gray-50 cursor-pointer"
|
||||
onClick={() => toggleResult(index, resultIndex)}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium mr-2">Result {resultIndex + 1}</span>
|
||||
<span className="text-gray-500 text-sm">
|
||||
Score: <span className="font-mono">{result.score.toFixed(4)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{formatTime(request.start_time)}</span>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-3 border-t bg-white">
|
||||
{result.content.map((content, contentIndex) => (
|
||||
<div key={contentIndex} className="mb-2 last:mb-0">
|
||||
<div className="text-xs text-gray-500 mb-1">{content.type}</div>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all bg-gray-50 p-2 rounded-sm">
|
||||
{content.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{formatTime(request.end_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Duration:</span>
|
||||
<span>{calculateDuration(request.start_time, request.end_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium mb-2">Search Results</h4>
|
||||
<div className="space-y-2">
|
||||
{request.vector_store_search_response.data.map((result, resultIndex) => {
|
||||
const isExpanded = expandedResults[`${index}-${resultIndex}`] || false;
|
||||
|
||||
return (
|
||||
<div key={resultIndex} className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center p-3 bg-gray-50 cursor-pointer"
|
||||
onClick={() => toggleResult(index, resultIndex)}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium mr-2">Result {resultIndex + 1}</span>
|
||||
<span className="text-gray-500 text-sm">
|
||||
Score: <span className="font-mono">{result.score.toFixed(4)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-3 border-t bg-white">
|
||||
{result.content.map((content, contentIndex) => (
|
||||
<div key={contentIndex} className="mb-2 last:mb-0">
|
||||
<div className="text-xs text-gray-500 mb-1">{content.type}</div>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all bg-gray-50 p-2 rounded-sm">
|
||||
{content.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue