diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index db11d2a7f125..95ad9c42a480 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -19,6 +19,17 @@ nothing. While no Codex runtime row is stored, ## Codex gateway borrowing +The Extensions navigation exposes three administrator pages: settings at +`/admin/codex-gateway-borrow`, route status at +`/admin/codex-gateway-borrow/status`, and manual comparison/history at +`/admin/codex-pelican-comparison`. Opening or navigating these pages only reads +local data. Settings drafts contain configuration IDs/options only, persist in +the browser session, and never affect the saved configuration used by status or +generation. Only explicit actions save, prepare, verify or generate. The status +page polls read-only status every five seconds while preparation/verification is +active; local countdowns expire displayed routes without renewing them. Leaving +the comparison page cancels its active generation. + `GET/PUT /admin/codex-gateway-borrow/config` owns one JSON setting, `codex_gateway_borrow_config`: `enabled`, `source_account_ids`, `target_account_ids` and `models`. A missing row is off with empty account diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index a6f718262d8d..e3255bae73a5 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -143,6 +143,10 @@ jobs: src/api/admin/__tests__/codexGatewayBorrow.spec.ts \ src/components/admin/codex/__tests__/BorrowPelicanPreview.spec.ts \ src/views/admin/__tests__/CodexGatewayBorrowView.spec.ts \ + src/views/admin/__tests__/CodexGatewayBorrowStatusView.spec.ts \ + src/views/admin/__tests__/CodexPelicanComparisonView.spec.ts \ + src/components/admin/__tests__/CodexBorrowNav.spec.ts \ + src/router/__tests__/codex-borrow-routes.spec.ts \ src/components/account/__tests__/EditAccountModal.spec.ts \ src/utils/__tests__/openaiProtocolModeEdits.spec.ts \ src/components/account/__tests__/ModelWhitelistSelector.spec.ts \ diff --git a/frontend/src/components/admin/CodexBorrowNav.vue b/frontend/src/components/admin/CodexBorrowNav.vue new file mode 100644 index 000000000000..48d959a5995d --- /dev/null +++ b/frontend/src/components/admin/CodexBorrowNav.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/frontend/src/components/admin/__tests__/CodexBorrowNav.spec.ts b/frontend/src/components/admin/__tests__/CodexBorrowNav.spec.ts new file mode 100644 index 000000000000..f1e2aeaa8d4b --- /dev/null +++ b/frontend/src/components/admin/__tests__/CodexBorrowNav.spec.ts @@ -0,0 +1,52 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { describe, expect, it, vi } from 'vitest' +import CodexBorrowNav from '../CodexBorrowNav.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +const paths = [ + '/admin/codex-gateway-borrow', + '/admin/codex-gateway-borrow/status', + '/admin/codex-pelican-comparison', +] + +async function renderNav(path: string) { + const router = createRouter({ + history: createMemoryHistory(), + routes: paths.map(routePath => ({ path: routePath, component: { template: '
' } })), + }) + await router.push(path) + await router.isReady() + const wrapper = mount(CodexBorrowNav, { global: { plugins: [router] } }) + return { router, wrapper } +} + +describe('Codex borrowing page navigation', () => { + it.each(paths)('renders real links and marks only the current page on %s', async path => { + const { wrapper } = await renderNav(path) + + expect(wrapper.findAll('a').map(link => link.attributes('href'))).toEqual(paths) + expect(wrapper.findAll('a[aria-current="page"]')).toHaveLength(1) + expect(wrapper.get('a[aria-current="page"]').attributes('href')).toBe(path) + wrapper.unmount() + }) + + it('navigates between pages and supports browser Back', async () => { + const { router, wrapper } = await renderNav(paths[0]) + + await wrapper.get(`a[href="${paths[1]}"]`).trigger('click') + await flushPromises() + expect(router.currentRoute.value.path).toBe(paths[1]) + await wrapper.get(`a[href="${paths[2]}"]`).trigger('click') + await flushPromises() + expect(router.currentRoute.value.path).toBe(paths[2]) + + router.back() + await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(paths[1])) + expect(wrapper.get('a[aria-current="page"]').attributes('href')).toBe(paths[1]) + wrapper.unmount() + }) +}) diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index ea2313e8af87..c650b5e59845 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -113,7 +113,7 @@ :key="item.path" :to="item.path" class="sidebar-link mb-1" - :class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }" + :class="{ 'sidebar-link-active': route.path === item.path, 'sidebar-link-collapsed': sidebarCollapsed }" :title="sidebarCollapsed ? item.label : undefined" @click="handleMenuItemClick(item.path)" > @@ -507,6 +507,14 @@ const GatewayBorrowIcon = { render: () => h(Icon, { name: 'link', 'aria-hidden': 'true' }) } +const GatewayBorrowStatusIcon = { + render: () => h(Icon, { name: 'checkCircle', 'aria-hidden': 'true' }) +} + +const PelicanComparisonIcon = { + render: () => h(Icon, { name: 'grid', 'aria-hidden': 'true' }) +} + const BellIcon = { render: () => h( @@ -834,6 +842,8 @@ const adminExtensionNavItems = computed((): NavItem[] => [ { path: '/admin/system-prompts', label: t('nav.systemPrompts'), icon: DocumentIcon }, { path: '/admin/codex-runtime', label: t('nav.codexRuntime'), icon: PluginIcon }, { path: '/admin/codex-gateway-borrow', label: t('nav.codexGatewayBorrow'), icon: GatewayBorrowIcon }, + { path: '/admin/codex-gateway-borrow/status', label: t('nav.codexGatewayBorrowStatus'), icon: GatewayBorrowStatusIcon }, + { path: '/admin/codex-pelican-comparison', label: t('nav.codexPelicanComparison'), icon: PelicanComparisonIcon }, { path: '/admin/reasoning-recovery', label: t('nav.reasoningRecovery'), icon: RecoveryIcon }, ]) diff --git a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts index a56b74b12d3a..127b4764c7c7 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts @@ -62,15 +62,15 @@ vi.mock('vue-i18n', async importOriginal => { } }) -async function renderSidebar(options: { admin?: boolean } = {}) { +async function renderSidebar(options: { admin?: boolean; simple?: boolean; path?: string } = {}) { mocks.authStore.isAdmin = options.admin === true - mocks.authStore.isSimpleMode = true + mocks.authStore.isSimpleMode = options.simple !== false const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: { template: '' } }], }) - await router.push(options.admin ? '/admin/dashboard' : '/dashboard') + await router.push(options.path ?? (options.admin ? '/admin/dashboard' : '/dashboard')) await router.isReady() return mount(AppSidebar, { @@ -97,27 +97,42 @@ describe('AppSidebar simple mode extensions', () => { document.documentElement.classList.remove('dark') }) - it('renders no extensions section for a regular user', async () => { - const wrapper = await renderSidebar() + it.each([true, false])('renders no extensions section for a regular user (simple: %s)', async simple => { + const wrapper = await renderSidebar({ simple }) expect(wrapper.find('[data-testid="sidebar-extensions"]').exists()).toBe(false) - expect(wrapper.find('a[href="/usage"]').exists()).toBe(false) + if (simple) expect(wrapper.find('a[href="/usage"]').exists()).toBe(false) + wrapper.unmount() }) - it('renders admin extensions for an admin in simple mode', async () => { - const wrapper = await renderSidebar({ admin: true }) + it.each([true, false])('renders the three admin page entries in order (simple: %s)', async simple => { + const wrapper = await renderSidebar({ admin: true, simple }) expect(extensionLinks(wrapper)).toEqual([ '/admin/system-prompts', '/admin/codex-runtime', '/admin/codex-gateway-borrow', + '/admin/codex-gateway-borrow/status', + '/admin/codex-pelican-comparison', '/admin/reasoning-recovery', ]) - expect(wrapper.text()).not.toContain('nav.myAccount') + if (simple) expect(wrapper.text()).not.toContain('nav.myAccount') wrapper.unmount() - const regularUser = await renderSidebar() + const regularUser = await renderSidebar({ simple }) expect(regularUser.find('a[href="/admin/codex-gateway-borrow"]').exists()).toBe(false) + expect(regularUser.find('a[href="/admin/codex-gateway-borrow/status"]').exists()).toBe(false) + expect(regularUser.find('a[href="/admin/codex-pelican-comparison"]').exists()).toBe(false) regularUser.unmount() }) + + it('marks only Borrow Status active on its own route', async () => { + const wrapper = await renderSidebar({ admin: true, path: '/admin/codex-gateway-borrow/status' }) + const section = wrapper.get('[data-testid="sidebar-extensions"]') + + expect(section.get('a[href="/admin/codex-gateway-borrow/status"]').classes()).toContain('sidebar-link-active') + expect(section.get('a[href="/admin/codex-gateway-borrow"]').classes()).not.toContain('sidebar-link-active') + expect(section.findAll('.sidebar-link-active')).toHaveLength(1) + wrapper.unmount() + }) }) diff --git a/frontend/src/composables/useCodexBorrowUI.ts b/frontend/src/composables/useCodexBorrowUI.ts new file mode 100644 index 000000000000..5898e1369c0b --- /dev/null +++ b/frontend/src/composables/useCodexBorrowUI.ts @@ -0,0 +1,95 @@ +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { accountsAPI } from '@/api/admin/accounts' +import type { BorrowTargetStatus } from '@/api/admin/codexGatewayBorrow' +import type { AccountListItem } from '@/types' +import { resolveOpenAIWSModeFromExtra } from '@/utils/openaiWsMode' + +/** Inventory is local to each page; never discover models or test accounts here. */ +export function useCodexBorrowInventory(signal: AbortSignal) { + const { t, te } = useI18n() + const accounts = ref{{ t('admin.codexGatewayBorrow.statusDescription') }}
+{{ statusError }}
+ {{ inventoryError }}
+ {{ t('common.loading') }}
+ + +{{ t('admin.codexGatewayBorrow.observedAt') }}: {{ formatTime(status.generated_at) }}
+{{ accountName(status.candidate.source_account_id) }} #{{ status.candidate.source_account_id }}
+{{ t('admin.codexGatewayBorrow.proxy') }}: {{ proxyLabel(accountById(status.candidate.source_account_id)) }}
+{{ t('admin.codexGatewayBorrow.remainingSeconds', { seconds: candidateRemaining }) }}
+{{ t('admin.codexGatewayBorrow.candidateExpired') }}
+{{ t('admin.codexGatewayBorrow.validationMeaning') }}
+{{ t('admin.codexGatewayBorrow.prepareRoutesHint') }}
+{{ t('admin.codexGatewayBorrow.pollingHint') }}
+{{ actionFailure.message }}
+{{ actionFailure.detail }}
+ {{ accountName(source.account_id) }} #{{ source.account_id }}
+{{ borrowReasonLabel(source.expires_at && remainingSeconds(source.expires_at) === 0 && source.state === 'ready' ? 'route_expired' : source.reason, t) }}
+{{ source.error }}
+ {{ t('admin.codexGatewayBorrow.revalidateHint') }}
+{{ t('admin.codexGatewayBorrow.accountModel') }}
+{{ accountName(target.account_id) }} #{{ target.account_id }}
+{{ target.model }}
+{{ t('admin.codexGatewayBorrow.proxy') }}: {{ proxyLabel(accountById(target.account_id)) }}
+{{ t('admin.codexGatewayBorrow.validation') }}
+ {{ t(`admin.codexGatewayBorrow.lineStates.${targetState(target)}`) }} +{{ reasonLabel(target) }}
+{{ t('admin.codexGatewayBorrow.stateUnavailable') }}
+{{ t('admin.codexGatewayBorrow.fullError') }}
{{ target.error }}{{ accountStateLabel(accountById(target.account_id)) }}
+{{ t('admin.codexGatewayBorrow.shadowOf', { id: accountById(target.account_id)?.parent_account_id, name: accountName(accountById(target.account_id)!.parent_account_id!) }) }}
+WS: {{ wsLabel(accountById(target.account_id)!) }} · {{ modelMappingLabel(accountById(target.account_id)!) }}
+{{ accountById(target.account_id)?.error_message }}
+ {{ t('admin.codexGatewayBorrow.noConfiguredTargets') }}
+{{ t('admin.codexGatewayBorrow.description') }}
-{{ t('admin.codexGatewayBorrow.description') }}
{{ error }}
+ {{ loadError }}
{{ t('common.loading') }}
+{{ saved.enabled ? t('admin.codexGatewayBorrow.on') : t('admin.codexGatewayBorrow.off') }} · {{ t('admin.codexGatewayBorrow.savedSelection', { sources: saved.source_account_ids.length, targets: saved.target_account_ids.length, models: saved.models.length }) }}
+{{ t('admin.codexGatewayBorrow.currentPreparationState') }}: {{ t('common.loading') }}
+{{ t('admin.codexGatewayBorrow.currentPreparationState') }}: {{ status.preparing ? t('admin.codexGatewayBorrow.preparing') : t('admin.codexGatewayBorrow.preparationIdle') }} {{ t('admin.codexGatewayBorrow.observedAt') }}: {{ formatTime(status.generated_at) }}
+ +{{ statusError }}
+ {{ t('admin.codexGatewayBorrow.draftHint') }}
-{{ t('admin.codexGatewayBorrow.saveHint') }}
-