diff --git a/apps/ai-dial-admin/src/app/[lang]/assets-applications/[id]/page.tsx b/apps/ai-dial-admin/src/app/[lang]/assets-applications/[id]/page.tsx index 45532ff66d..17101383b7 100644 --- a/apps/ai-dial-admin/src/app/[lang]/assets-applications/[id]/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/assets-applications/[id]/page.tsx @@ -24,13 +24,13 @@ import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; import { PLATFORM_ROOT_FOLDER } from '@/src/utils/files/root-folder'; -import { getApp, getApps, getPlatformApplication } from '../actions'; +import { getApp, getApps, getConfigFileApplication, getPlatformApplication } from '../actions'; export const dynamic = 'force-dynamic'; export default async function Page(params: { params: Promise<{ id: string }>; - searchParams: Promise<{ path?: string }>; + searchParams: Promise<{ path?: string; configFile?: string }>; }) { const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); @@ -52,18 +52,25 @@ export default async function Page(params: { // A `path` query param means this is a public-bucket (versioned, folder-nested) application; its // absence means a platform-bucket one — flat, identified by name alone (design.md D3/D5). - const rawPath = (await params.searchParams).path; + const searchParams = await params.searchParams; + const rawPath = searchParams.path; + const isConfigFileMode = searchParams.configFile === 'true'; const isPlatformBucket = !rawPath; const name = decodeURIComponent((await params.params).id); try { if (isPlatformBucket) { - const path = `${PLATFORM_ROOT_FOLDER}/${name}`; - - app = await getPlatformApplication(path, etag).then((res) => { - etag = res?.etag || DEFAULT_ETAG; - return (res?.response as unknown as AssetApp) || null; - }); + if (isConfigFileMode) { + const result = await getConfigFileApplication(name); + app = result.success ? (result.data as unknown as AssetApp) : null; + } else { + const path = `${PLATFORM_ROOT_FOLDER}/${name}`; + + app = await getPlatformApplication(path, etag).then((res) => { + etag = res?.etag || DEFAULT_ETAG; + return (res?.response as unknown as AssetApp) || null; + }); + } } else { const path = decodeURIComponent(rawPath as string); @@ -77,11 +84,16 @@ export default async function Page(params: { ) || []) as AssetApp[]; } - models = await getModelsList(); assetRunners = await getAllRunners(); translators = (await getTranslators('')) || []; - applications = await applicationsApi.getApplicationsList(token); - applicationSchemes = await applicationRunnersApi.getApplicationSchemesList(token); + + // Admin-backend enrichment only: without DIAL_ADMIN_API_URL there is no host to call, so + // `applications`/`applicationSchemes` stay empty and the page renders on Core-direct data alone. + if (process.env.DIAL_ADMIN_API_URL) { + models = await getModelsList(); + applications = await applicationsApi.getApplicationsList(token); + applicationSchemes = await applicationRunnersApi.getApplicationSchemesList(token); + } } catch (e) { errorObjLog(e, 'Failed to fetch app view data'); } @@ -94,8 +106,8 @@ export default async function Page(params: { // buckets (not just the platform-bucket Roles tab that uses `roles`) to match the existing // interceptors read here, which is likewise unconditional. [roles, interceptors, globalInterceptors] = await Promise.all([ - readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings), - readConfigEntities(token, ConfigFileEntityType.Interceptors, optionWarnings), + readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings, false), + readConfigEntities(token, ConfigFileEntityType.Interceptors, optionWarnings, false), readGlobalInterceptors(token, optionWarnings), ]); @@ -117,6 +129,7 @@ export default async function Page(params: { globalInterceptors={globalInterceptors} translators={translators} optionWarnings={optionWarnings} + isConfigFileSource={isConfigFileMode} /> ) : ( { expect(externalServiceConsentApi.withdraw).toHaveBeenCalledWith(TOKEN_MOCK, 'public/als code apps/my app', 'dial'); expect(result).toBe(RESPONSE_MOCK); }); + + test('Should call getConfigFileApplications action', async () => { + (configFileApi.listNames as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileApplications(); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.listNames).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Applications); + expect(result).toBe(RESPONSE_MOCK); + }); + + test('Should call getConfigFileApplication action', async () => { + (configFileApi.getEntity as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileApplication('my-app'); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.getEntity).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Applications, 'my-app'); + expect(result).toBe(RESPONSE_MOCK); + }); }); diff --git a/apps/ai-dial-admin/src/app/[lang]/assets-applications/actions.ts b/apps/ai-dial-admin/src/app/[lang]/assets-applications/actions.ts index e0b75731df..81f9b065ee 100644 --- a/apps/ai-dial-admin/src/app/[lang]/assets-applications/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/assets-applications/actions.ts @@ -2,7 +2,13 @@ import { cookies, headers } from 'next/headers'; -import { assetApi, externalServiceConsentApi, externalServiceOpsApi, toolsetOpsApi } from '@/src/app/api/api'; +import { + assetApi, + configFileApi, + externalServiceConsentApi, + externalServiceOpsApi, + toolsetOpsApi, +} from '@/src/app/api/api'; import { ROOT_FOLDER } from '@/src/constants/file'; import { DialApplicationResource, @@ -19,6 +25,7 @@ import { runAssetExportAction, runAssetImportAction } from '@/src/server/assets/ import { moveAssets } from '@/src/server/assets/move'; import { validateApplicationResourceFields } from '@/src/server/core/asset-validation'; import { encodeCorePath, getVersionedName } from '@/src/server/publications/path'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ImportFileType } from '@/src/types/import'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; @@ -263,3 +270,15 @@ export async function signOutExternalService(appPath: string, serviceId: string, authenticationType: authType, }); } + +/** `config-file-entity-views`: the application names Core's config file declares. */ +export async function getConfigFileApplications() { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.listNames(token, ConfigFileEntityType.Applications); +} + +/** `config-file-entity-views`: reads an application by name from Core's config-file population directly. */ +export async function getConfigFileApplication(name: string) { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.getEntity(token, ConfigFileEntityType.Applications, name); +} diff --git a/apps/ai-dial-admin/src/app/[lang]/assets-applications/page.tsx b/apps/ai-dial-admin/src/app/[lang]/assets-applications/page.tsx index 9a1315ef48..2f94766a44 100644 --- a/apps/ai-dial-admin/src/app/[lang]/assets-applications/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/assets-applications/page.tsx @@ -2,7 +2,7 @@ import { cookies, headers } from 'next/headers'; import { applicationRunnersApi } from '@/src/app/api/api'; import { getAllRunners } from '@/src/app/[lang]/platform-app-runners/actions'; -import AppsList from '@/src/components/Assets/Apps/List'; +import AppsList from '@/src/components/Assets/Apps/PageList'; import { buildAppRunnerOptions } from '@/src/components/SourceField/Application/utils'; import { AppRunnerOption } from '@/src/components/SourceField/Application/models'; import { SaveValidationContextProvider } from '@/src/context/SaveValidationContext'; @@ -20,10 +20,15 @@ export default async function Page() { let runners: DialApplicationScheme[] | null = []; let assetRunners: ResourceInfo[] = []; - try { - runners = await applicationRunnersApi.getApplicationSchemesList(token); - } catch (e) { - errorObjLog(e, 'Failed to fetch applications data'); + // Admin-backend enrichment only: this page is otherwise Core-direct (assetRunners below), so when + // DIAL_ADMIN_API_URL is unset there is no host to call and `runners` stays empty rather than + // attempting a request that would fail. + if (process.env.DIAL_ADMIN_API_URL) { + try { + runners = await applicationRunnersApi.getApplicationSchemesList(token); + } catch (e) { + errorObjLog(e, 'Failed to fetch applications data'); + } } try { diff --git a/apps/ai-dial-admin/src/app/[lang]/assets-applications/tests/admin-api-gating.spec.tsx b/apps/ai-dial-admin/src/app/[lang]/assets-applications/tests/admin-api-gating.spec.tsx new file mode 100644 index 0000000000..c23a9a5bad --- /dev/null +++ b/apps/ai-dial-admin/src/app/[lang]/assets-applications/tests/admin-api-gating.spec.tsx @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('next/headers', () => ({ headers: vi.fn(), cookies: vi.fn() })); +vi.mock('@/src/utils/auth/auth-request', () => ({ getUserToken: vi.fn().mockResolvedValue('token') })); +vi.mock('@/src/utils/env/get-auth-toggle', () => ({ getIsEnableAuthToggle: vi.fn().mockReturnValue(false) })); +vi.mock('@/src/server/logger', () => ({ errorObjLog: vi.fn() })); + +vi.mock('@/src/app/api/api', () => ({ + applicationRunnersApi: { getApplicationSchemesList: vi.fn().mockResolvedValue([]) }, + applicationsApi: { getApplicationsList: vi.fn().mockResolvedValue([]) }, +})); +vi.mock('@/src/app/[lang]/platform-app-runners/actions', () => ({ getAllRunners: vi.fn().mockResolvedValue([]) })); +vi.mock('@/src/components/Assets/Apps/PageList', () => ({ __esModule: true, default: () => null })); + +vi.mock('@/src/app/[lang]/assets-applications/actions', () => ({ + getApp: vi.fn().mockResolvedValue({ etag: 'e', response: { name: 'my-app', folderId: 'f' } }), + getApps: vi.fn().mockResolvedValue([]), + getPlatformApplication: vi.fn().mockResolvedValue({ etag: 'e', response: { name: 'my-app' } }), +})); +vi.mock('@/src/app/[lang]/models/actions', () => ({ getModelsList: vi.fn().mockResolvedValue([]) })); +vi.mock('@/src/app/[lang]/platform-translators/actions', () => ({ getTranslators: vi.fn().mockResolvedValue([]) })); +vi.mock('@/src/server/config-entities/read-page-options', () => ({ + readConfigEntities: vi.fn().mockResolvedValue([]), + readGlobalInterceptors: vi.fn().mockResolvedValue([]), +})); +vi.mock('@/src/components/Assets/Apps/View', () => ({ __esModule: true, default: () => null })); +vi.mock('@/src/components/Assets/Platform/Applications/View', () => ({ __esModule: true, default: () => null })); + +import { applicationRunnersApi, applicationsApi } from '@/src/app/api/api'; +import ListPage from '@/src/app/[lang]/assets-applications/page'; +import DetailPage from '@/src/app/[lang]/assets-applications/[id]/page'; + +describe('Assets > Applications :: admin API gating', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('list page', () => { + test('skips the admin-backend runner-schemes call when DIAL_ADMIN_API_URL is unset', async () => { + vi.stubEnv('DIAL_ADMIN_API_URL', undefined); + + await ListPage(); + + expect(applicationRunnersApi.getApplicationSchemesList).not.toHaveBeenCalled(); + }); + + test('calls the admin-backend runner-schemes call when DIAL_ADMIN_API_URL is set', async () => { + vi.stubEnv('DIAL_ADMIN_API_URL', 'https://admin-be.example.com'); + + await ListPage(); + + expect(applicationRunnersApi.getApplicationSchemesList).toHaveBeenCalledOnce(); + }); + }); + + describe('detail page', () => { + const renderDetailPage = () => + DetailPage({ + params: Promise.resolve({ id: 'my-app' }), + searchParams: Promise.resolve({}), + }); + + test('skips both admin-backend calls when DIAL_ADMIN_API_URL is unset', async () => { + vi.stubEnv('DIAL_ADMIN_API_URL', undefined); + + await renderDetailPage(); + + expect(applicationRunnersApi.getApplicationSchemesList).not.toHaveBeenCalled(); + expect(applicationsApi.getApplicationsList).not.toHaveBeenCalled(); + }); + + test('calls both admin-backend calls when DIAL_ADMIN_API_URL is set', async () => { + vi.stubEnv('DIAL_ADMIN_API_URL', 'https://admin-be.example.com'); + + await renderDetailPage(); + + expect(applicationRunnersApi.getApplicationSchemesList).toHaveBeenCalledOnce(); + expect(applicationsApi.getApplicationsList).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/apps/ai-dial-admin/src/app/[lang]/assets-applications/tests/runner-sources.spec.tsx b/apps/ai-dial-admin/src/app/[lang]/assets-applications/tests/runner-sources.spec.tsx index 08d246b723..7370a75d47 100644 --- a/apps/ai-dial-admin/src/app/[lang]/assets-applications/tests/runner-sources.spec.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/assets-applications/tests/runner-sources.spec.tsx @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { AppRunnerOption, AppRunnerOrigin } from '@/src/components/SourceField/Application/models'; @@ -14,7 +14,7 @@ vi.mock('@/src/app/[lang]/platform-app-runners/actions', () => ({ vi.mock('@/src/utils/auth/auth-request', () => ({ getUserToken: vi.fn().mockResolvedValue('token') })); vi.mock('@/src/utils/env/get-auth-toggle', () => ({ getIsEnableAuthToggle: () => false })); -vi.mock('@/src/components/Assets/Apps/List', () => ({ __esModule: true, default: () => null })); +vi.mock('@/src/components/Assets/Apps/PageList', () => ({ __esModule: true, default: () => null })); import { getAllRunners } from '@/src/app/[lang]/platform-app-runners/actions'; import { applicationRunnersApi } from '@/src/app/api/api'; @@ -29,6 +29,13 @@ const runnersPassedToList = (tree: any): AppRunnerOption[] => tree.props.childre describe('Assets > Applications :: runner sources', () => { beforeEach(() => { vi.clearAllMocks(); + // These cases exercise the admin-BE runner-schemes read itself, so the admin API is on here; + // the on/off gating of that read is covered separately in admin-api-gating.spec.tsx. + vi.stubEnv('DIAL_ADMIN_API_URL', 'https://admin-be.example.com'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); }); test('merges both populations into the picker options', async () => { diff --git a/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/[id]/page.tsx b/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/[id]/page.tsx index 6e81ac3f90..7a9c977ccc 100644 --- a/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/[id]/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/[id]/page.tsx @@ -15,13 +15,13 @@ import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; import { PLATFORM_ROOT_FOLDER } from '@/src/utils/files/root-folder'; -import { getPlatformToolset, getToolset, getToolsets } from '../actions'; +import { getConfigFileToolset, getPlatformToolset, getToolset, getToolsets } from '../actions'; export const dynamic = 'force-dynamic'; export default async function Page(params: { params: Promise<{ id: string }>; - searchParams: Promise<{ path?: string; code?: string }>; + searchParams: Promise<{ path?: string; code?: string; configFile?: string }>; }) { const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); @@ -38,17 +38,23 @@ export default async function Page(params: { const searchParams = await params.searchParams; oAuthCode = searchParams.code; const rawPath = searchParams.path; + const isConfigFileMode = searchParams.configFile === 'true'; const isPlatformBucket = !rawPath; const name = decodeURIComponent((await params.params).id); try { if (isPlatformBucket) { - const path = `${PLATFORM_ROOT_FOLDER}/${name}`; + if (isConfigFileMode) { + const result = await getConfigFileToolset(name); + toolset = result.success ? (result.data as unknown as AssetToolset) : null; + } else { + const path = `${PLATFORM_ROOT_FOLDER}/${name}`; - toolset = await getPlatformToolset(path, etag).then((res) => { - etag = res?.etag || DEFAULT_ETAG; - return (res?.response as unknown as AssetToolset) || null; - }); + toolset = await getPlatformToolset(path, etag).then((res) => { + etag = res?.etag || DEFAULT_ETAG; + return (res?.response as unknown as AssetToolset) || null; + }); + } } else { const path = decodeURIComponent(rawPath as string); @@ -69,7 +75,7 @@ export default async function Page(params: { // interceptors read on the sibling assets-applications page — an option-list problem must not // prevent the toolset from loading. Core-direct (`readConfigEntities`), not the admin-BE role list, // which cannot see a role declared only in Core's configuration file. - roles = await readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings); + roles = await readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings, false); if (toolset == null) { notFound(); @@ -84,6 +90,7 @@ export default async function Page(params: { originalToolset={toolset} roles={roles} optionWarnings={optionWarnings} + isConfigFileSource={isConfigFileMode} /> ) : ( diff --git a/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/actions.spec.ts b/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/actions.spec.ts index 00e851cb40..70db6e8f08 100644 --- a/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/actions.spec.ts +++ b/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/actions.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { assetApi, toolsetOpsApi } from '@/src/app/api/api'; +import { assetApi, configFileApi, toolsetOpsApi } from '@/src/app/api/api'; import * as eximModule from '@/src/server/toolsets/exim'; import * as mcpClientModule from '@/src/server/toolsets/mcp-client'; import * as zipEximModule from '@/src/server/toolsets/zip-exim'; @@ -23,6 +23,8 @@ import { tryOutAssetTool, bulkDeletePlatformToolsets, createPlatformToolset, + getConfigFileToolset, + getConfigFileToolsets, getPlatformToolset, getPlatformToolsets, removePlatformToolset, @@ -30,6 +32,7 @@ import { } from './actions'; import { DialFileNodeType } from '@/src/models/dial/file'; import { DialPlatformToolsetResource } from '@/src/models/dial/resource'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { ToolsetAuthCredentialLevel } from '@/src/models/dial/toolset'; import { ImportFileType } from '@/src/types/import'; @@ -522,4 +525,24 @@ describe('Platform toolset server actions', () => { expect(assetApi.delete).toHaveBeenCalledWith(TOKEN_MOCK, ResourceType.TOOLSET, path); }); }); + + test('Should call getConfigFileToolsets action', async () => { + (configFileApi.listNames as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileToolsets(); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.listNames).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Toolsets); + expect(result).toBe(RESPONSE_MOCK); + }); + + test('Should call getConfigFileToolset action', async () => { + (configFileApi.getEntity as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileToolset('my-toolset'); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.getEntity).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Toolsets, 'my-toolset'); + expect(result).toBe(RESPONSE_MOCK); + }); }); diff --git a/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/actions.ts b/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/actions.ts index 77572df110..356e1a5147 100644 --- a/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/actions.ts @@ -2,14 +2,16 @@ import { cookies, headers } from 'next/headers'; -import { assetApi, toolsetOpsApi } from '@/src/app/api/api'; +import { assetApi, configFileApi, toolsetOpsApi } from '@/src/app/api/api'; import { ROOT_FOLDER } from '@/src/constants/file'; import { AssetToolset } from '@/src/models/dial/deployment-asset'; +import { Toolset } from '@/src/models/dial/toolset'; import { DialPlatformToolsetResource, DialToolsetResource, ToolsetAuthCredentialLevel, } from '@/src/models/dial/resource'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; @@ -188,6 +190,18 @@ export async function exportToolsets(paths: string[], type?: ImportFileType) { }); } +/** `config-file-entity-views`: the toolset names Core's config file declares. */ +export async function getConfigFileToolsets() { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.listNames(token, ConfigFileEntityType.Toolsets); +} + +/** `config-file-entity-views`: reads a toolset by name from Core's config-file population directly. */ +export async function getConfigFileToolset(name: string) { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.getEntity(token, ConfigFileEntityType.Toolsets, name); +} + export async function tryOutAssetTool(body: Record, resourceType = ResourceType.TOOLSET) { const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); diff --git a/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/page.tsx b/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/page.tsx index 331913a07d..1ee34f55b1 100644 --- a/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/assets-toolsets/page.tsx @@ -1,4 +1,4 @@ -import ToolsetsList from '@/src/components/Assets/Toolsets/List'; +import AssetsToolsetsPageList from '@/src/components/Assets/Toolsets/PageList'; import { SaveValidationContextProvider } from '@/src/context/SaveValidationContext'; export const dynamic = 'force-dynamic'; @@ -6,7 +6,7 @@ export const dynamic = 'force-dynamic'; export default async function Page() { return ( - + ); } diff --git a/apps/ai-dial-admin/src/app/[lang]/models/actions.ts b/apps/ai-dial-admin/src/app/[lang]/models/actions.ts index 9343e72b47..36ab2f0a64 100644 --- a/apps/ai-dial-admin/src/app/[lang]/models/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/models/actions.ts @@ -2,7 +2,7 @@ import { cookies, headers } from 'next/headers'; -import { modelsApi, adaptersApi } from '@/src/app/api/api'; +import { adaptersApi, modelsApi } from '@/src/app/api/api'; import { DEFAULT_ROLE_LIMITS } from '@/src/constants/role'; import { DialModel, DialModelType } from '@/src/models/dial/model'; import { getUserToken } from '@/src/utils/auth/auth-request'; diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/[id]/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/[id]/page.tsx index 0bc5dc6e2a..16cf890594 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/[id]/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/[id]/page.tsx @@ -13,11 +13,15 @@ import { errorObjLog } from '@/src/server/logger'; import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; -import { getRunner } from '../actions'; +import { getConfigFileAppRunner, getRunner } from '../actions'; export const dynamic = 'force-dynamic'; -export default async function Page(params: { params: Promise<{ id: string }> }) { +export default async function Page(params: { + params: Promise<{ id: string }>; + searchParams: Promise<{ configFile?: string }>; +}) { + const isConfigFileMode = (await params.searchParams).configFile === 'true'; const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); let etag = DEFAULT_ETAG; @@ -27,10 +31,15 @@ export default async function Page(params: { params: Promise<{ id: string }> }) try { const path = (await params.params).id; - runner = await getRunner(path, etag).then((res) => { - etag = res?.etag || DEFAULT_ETAG; - return res?.response as DialAppRunnerResource | null; - }); + if (isConfigFileMode) { + const result = await getConfigFileAppRunner(decodeURIComponent(path)); + runner = result.success ? (result.data as unknown as DialAppRunnerResource) : null; + } else { + runner = await getRunner(path, etag).then((res) => { + etag = res?.etag || DEFAULT_ETAG; + return res?.response as DialAppRunnerResource | null; + }); + } } catch (e) { errorObjLog(e, 'Failed to fetch app runner asset data'); } @@ -38,8 +47,8 @@ export default async function Page(params: { params: Promise<{ id: string }> }) // Deliberately outside the resource fetch's try, and resolved together: an option-list problem must // not prevent the runner from loading, and one list failing must not skip the others. const [roles, interceptors, globalInterceptors] = await Promise.all([ - readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings), - readConfigEntities(token, ConfigFileEntityType.Interceptors, optionWarnings), + readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings, false), + readConfigEntities(token, ConfigFileEntityType.Interceptors, optionWarnings, false), readGlobalInterceptors(token, optionWarnings), ]); @@ -56,6 +65,7 @@ export default async function Page(params: { params: Promise<{ id: string }> }) interceptors={interceptors} globalInterceptors={globalInterceptors} optionWarnings={optionWarnings} + isConfigFileSource={isConfigFileMode} /> ); diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/actions.spec.ts b/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/actions.spec.ts index a81b32d80e..e2d8728b3a 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/actions.spec.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/actions.spec.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { appRunnerSchemaApi, assetApi } from '@/src/app/api/api'; +import { appRunnerSchemaApi, assetApi, configFileApi } from '@/src/app/api/api'; import { DialAppRunnerResource, DialModelResourceStatus } from '@/src/models/dial/resource'; import { RoutePermission } from '@/src/models/dial/route'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; @@ -10,6 +11,8 @@ import { RESPONSE_MOCK, TOKEN_MOCK } from '@/src/utils/tests/mock/api.mock'; import { bulkDeleteRunners, createRunner, + getConfigFileAppRunner, + getConfigFileAppRunners, getResolvedRunnerSchema, getRunner, getRunners, @@ -232,4 +235,23 @@ describe('Assets app runner :: server actions', () => { expect(result.success).toBe(false); expect(result.errorMessage).toEqual('Schema not found'); }); + + test('Should call getConfigFileAppRunners action', async () => { + (configFileApi.listNames as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileAppRunners(); + + expect(configFileApi.listNames).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Schemas); + expect(result).toBe(RESPONSE_MOCK); + }); + + test('Should call getConfigFileAppRunner action', async () => { + (configFileApi.getEntity as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileAppRunner('my-runner'); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.getEntity).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Schemas, 'my-runner'); + expect(result).toBe(RESPONSE_MOCK); + }); }); diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/actions.ts b/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/actions.ts index 1ebc797005..24dad3caef 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/actions.ts @@ -2,10 +2,12 @@ import { cookies, headers } from 'next/headers'; -import { appRunnerSchemaApi, assetApi } from '@/src/app/api/api'; +import { appRunnerSchemaApi, assetApi, configFileApi } from '@/src/app/api/api'; +import { DialApplicationScheme } from '@/src/models/dial/application'; import { DialAppRunnerResource } from '@/src/models/dial/resource'; import { ServerActionResponse } from '@/src/models/server-action'; import { bulkDeleteAssets } from '@/src/server/assets/bulk-delete'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { toCoreAppRoutes } from '@/src/utils/app-runners/core-app-routes'; @@ -117,3 +119,15 @@ export async function getResolvedRunnerSchema(name: string) { const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); return appRunnerSchemaApi.resolvedSchema(token, name); } + +/** `config-file-entity-views`: the App Runner names Core's config file declares (`schemas` type). */ +export async function getConfigFileAppRunners() { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.listNames(token, ConfigFileEntityType.Schemas); +} + +/** `config-file-entity-views`: reads an App Runner by id from Core's config-file `schemas` population directly. */ +export async function getConfigFileAppRunner(id: string) { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.getEntity(token, ConfigFileEntityType.Schemas, id); +} diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/page.tsx index 1428829a5c..38d0d831b4 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-app-runners/page.tsx @@ -1,4 +1,4 @@ -import AppRunnersList from '@/src/components/Assets/Platform/AppRunners/List'; +import PlatformAppRunnersPageList from '@/src/components/Assets/Platform/AppRunners/PageList'; import { SaveValidationContextProvider } from '@/src/context/SaveValidationContext'; export const dynamic = 'force-dynamic'; @@ -6,7 +6,7 @@ export const dynamic = 'force-dynamic'; export default async function Page() { return ( - + ); } diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/[id]/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/[id]/page.tsx index 5a617c06f5..06cae61edb 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/[id]/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/[id]/page.tsx @@ -5,21 +5,31 @@ import { DEFAULT_ETAG } from '@/src/constants/api-headers'; import { SaveValidationContextProvider } from '@/src/context/SaveValidationContext'; import { DialInterceptorResource } from '@/src/models/dial/resource'; import { errorObjLog } from '@/src/server/logger'; -import { getInterceptor } from '../actions'; +import { getConfigFileInterceptor, getInterceptor } from '../actions'; export const dynamic = 'force-dynamic'; -export default async function Page(params: { params: Promise<{ id: string }> }) { +export default async function Page(params: { + params: Promise<{ id: string }>; + searchParams: Promise<{ configFile?: string }>; +}) { + const isConfigFileMode = (await params.searchParams).configFile === 'true'; + let etag = DEFAULT_ETAG; let interceptor: DialInterceptorResource | null = null; try { const path = (await params.params).id; - interceptor = await getInterceptor(path, etag).then((res) => { - etag = res?.etag || DEFAULT_ETAG; - return res?.response as DialInterceptorResource | null; - }); + if (isConfigFileMode) { + const result = await getConfigFileInterceptor(path); + interceptor = result.success ? (result.data as unknown as DialInterceptorResource) : null; + } else { + interceptor = await getInterceptor(path, etag).then((res) => { + etag = res?.etag || DEFAULT_ETAG; + return res?.response as DialInterceptorResource | null; + }); + } } catch (e) { errorObjLog(e, 'Failed to fetch interceptor asset data'); } @@ -30,7 +40,7 @@ export default async function Page(params: { params: Promise<{ id: string }> }) return ( - + ); } diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/actions.spec.ts b/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/actions.spec.ts index 469a55b354..63aee9fa52 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/actions.spec.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/actions.spec.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { assetApi, deploymentConfigurationApi } from '@/src/app/api/api'; +import { assetApi, configFileApi, deploymentConfigurationApi } from '@/src/app/api/api'; import { DialModelResourceStatus } from '@/src/models/dial/resource'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; @@ -9,6 +10,8 @@ import { RESPONSE_MOCK, TOKEN_MOCK } from '@/src/utils/tests/mock/api.mock'; import { bulkDeleteInterceptors, createInterceptor, + getConfigFileInterceptor, + getConfigFileInterceptors, getInterceptor, getInterceptorConfigurationSchema, getInterceptors, @@ -122,4 +125,28 @@ describe('Assets interceptor :: server actions', () => { expect(assetApi.delete).toHaveBeenCalledWith(TOKEN_MOCK, ResourceType.INTERCEPTOR, 'platform/redactor'); expect(result).toEqual({ success: true }); }); + + test('Should call getConfigFileInterceptors action', async () => { + (configFileApi.listNames as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileInterceptors(); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.listNames).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Interceptors); + expect(result).toBe(RESPONSE_MOCK); + }); + + test('Should call getConfigFileInterceptor action', async () => { + (configFileApi.getEntity as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileInterceptor('my-interceptor'); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.getEntity).toHaveBeenCalledWith( + TOKEN_MOCK, + ConfigFileEntityType.Interceptors, + 'my-interceptor', + ); + expect(result).toBe(RESPONSE_MOCK); + }); }); diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/actions.ts b/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/actions.ts index a06482132b..ae0574b39e 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/actions.ts @@ -2,9 +2,11 @@ import { cookies, headers } from 'next/headers'; -import { assetApi, deploymentConfigurationApi } from '@/src/app/api/api'; +import { assetApi, configFileApi, deploymentConfigurationApi } from '@/src/app/api/api'; +import { DialInterceptor } from '@/src/models/dial/interceptor'; import { DialInterceptorResource } from '@/src/models/dial/resource'; import { bulkDeleteAssets } from '@/src/server/assets/bulk-delete'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; @@ -59,3 +61,15 @@ export async function getInterceptorConfigurationSchema(name: string) { const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); return deploymentConfigurationApi.getConfigurationSchema(token, name); } + +/** `config-file-entity-views`: the interceptor names Core's config file declares. */ +export async function getConfigFileInterceptors() { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.listNames(token, ConfigFileEntityType.Interceptors); +} + +/** `config-file-entity-views`: reads an interceptor by name from Core's config-file population directly. */ +export async function getConfigFileInterceptor(name: string) { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.getEntity(token, ConfigFileEntityType.Interceptors, name); +} diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/page.tsx index 56dac8a59e..ee6fde5453 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-interceptors/page.tsx @@ -1,4 +1,4 @@ -import InterceptorsList from '@/src/components/Assets/Platform/Interceptors/List'; +import PlatformInterceptorsPageList from '@/src/components/Assets/Platform/Interceptors/PageList'; import { SaveValidationContextProvider } from '@/src/context/SaveValidationContext'; export const dynamic = 'force-dynamic'; @@ -6,7 +6,7 @@ export const dynamic = 'force-dynamic'; export default async function Page() { return ( - + ); } diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-keys/[id]/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-keys/[id]/page.tsx index adbae4a634..074798d10b 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-keys/[id]/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-keys/[id]/page.tsx @@ -34,7 +34,7 @@ export default async function Page(params: { params: Promise<{ id: string }> }) // Deliberately outside the resource fetch's try, resolved after: an option-list problem must not // prevent the key from loading. Core-direct list matches the `Assets > Roles` surface. - const roles = await readConfigEntities(token, ConfigFileEntityType.Roles, []); + const roles = await readConfigEntities(token, ConfigFileEntityType.Roles, [], false); if (key == null) { notFound(); diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-keys/actions.ts b/apps/ai-dial-admin/src/app/[lang]/platform-keys/actions.ts index 2e8f9e3840..a42736afee 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-keys/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-keys/actions.ts @@ -92,5 +92,5 @@ export async function bulkDeleteKeys(paths: { path: string }[]) { */ export async function getKeyRolesOptions() { const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); - return readConfigEntities(token, ConfigFileEntityType.Roles, []); + return readConfigEntities(token, ConfigFileEntityType.Roles, [], false); } diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-models/[id]/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-models/[id]/page.tsx index dfc177018d..eeb136d559 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-models/[id]/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-models/[id]/page.tsx @@ -15,11 +15,15 @@ import { errorObjLog } from '@/src/server/logger'; import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; -import { getModel } from '../actions'; +import { getConfigFileModel, getModel } from '../actions'; export const dynamic = 'force-dynamic'; -export default async function Page(params: { params: Promise<{ id: string }> }) { +export default async function Page(params: { + params: Promise<{ id: string }>; + searchParams: Promise<{ configFile?: string }>; +}) { + const isConfigFileMode = (await params.searchParams).configFile === 'true'; const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); let etag = DEFAULT_ETAG; @@ -31,11 +35,16 @@ export default async function Page(params: { params: Promise<{ id: string }> }) try { const path = (await params.params).id; - model = await getModel(path, etag).then((res) => { - etag = res?.etag || DEFAULT_ETAG; - return res?.response as AssetModel | null; - }); - translators = (await getTranslators('')) || []; + if (isConfigFileMode) { + const result = await getConfigFileModel(path); + model = result.success ? (result.data as unknown as AssetModel) : null; + } else { + model = await getModel(path, etag).then((res) => { + etag = res?.etag || DEFAULT_ETAG; + return res?.response as AssetModel | null; + }); + translators = (await getTranslators('')) || []; + } } catch (e) { errorObjLog(e, 'Failed to fetch model view data'); } @@ -46,8 +55,8 @@ export default async function Page(params: { params: Promise<{ id: string }> }) // declared in Core's configuration file, and which is a different population from `Assets > Roles`/ // `Assets > Interceptors`' own API-written one. const [roles, interceptors, globalInterceptors] = await Promise.all([ - readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings), - readConfigEntities(token, ConfigFileEntityType.Interceptors, optionWarnings), + readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings, false), + readConfigEntities(token, ConfigFileEntityType.Interceptors, optionWarnings, false), readGlobalInterceptors(token, optionWarnings), ]); @@ -65,6 +74,7 @@ export default async function Page(params: { params: Promise<{ id: string }> }) globalInterceptors={globalInterceptors} optionWarnings={optionWarnings} translators={translators} + isConfigFileSource={isConfigFileMode} /> ); diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-models/actions.spec.ts b/apps/ai-dial-admin/src/app/[lang]/platform-models/actions.spec.ts index bf27dd5dfd..667dcbe01e 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-models/actions.spec.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-models/actions.spec.ts @@ -1,12 +1,22 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { assetApi } from '@/src/app/api/api'; +import { assetApi, configFileApi } from '@/src/app/api/api'; import { DialModelResourceStatus } from '@/src/models/dial/resource'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; import { RESPONSE_MOCK, TOKEN_MOCK } from '@/src/utils/tests/mock/api.mock'; -import { bulkDeleteModels, createModel, getModel, getModels, removeModel, updateModel } from './actions'; +import { + bulkDeleteModels, + createModel, + getConfigFileModel, + getConfigFileModels, + getModel, + getModels, + removeModel, + updateModel, +} from './actions'; vi.mock('@/src/utils/auth/auth-request'); vi.mock('@/src/utils/env/get-auth-toggle'); @@ -232,4 +242,24 @@ describe('Assets model :: upstream secrets are never written as empty strings', expect(payload).not.toHaveProperty('status'); expect(payload).not.toHaveProperty('validationWarnings'); }); + + test('Should call getConfigFileModels action', async () => { + (configFileApi.listNames as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileModels(); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.listNames).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Models); + expect(result).toBe(RESPONSE_MOCK); + }); + + test('Should call getConfigFileModel action', async () => { + (configFileApi.getEntity as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileModel('my-model'); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.getEntity).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Models, 'my-model'); + expect(result).toBe(RESPONSE_MOCK); + }); }); diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-models/actions.ts b/apps/ai-dial-admin/src/app/[lang]/platform-models/actions.ts index 4207aa7313..8deb54635f 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-models/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-models/actions.ts @@ -2,10 +2,12 @@ import { cookies, headers } from 'next/headers'; -import { assetApi } from '@/src/app/api/api'; +import { assetApi, configFileApi } from '@/src/app/api/api'; import { AssetModel } from '@/src/models/dial/deployment-asset'; +import { DialModel } from '@/src/models/dial/model'; import { DialModelResource } from '@/src/models/dial/resource'; import { bulkDeleteAssets } from '@/src/server/assets/bulk-delete'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; @@ -54,3 +56,15 @@ export async function bulkDeleteModels(paths: { path: string }[]) { const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); return bulkDeleteAssets(assetApi, token, ResourceType.MODEL, paths); } + +/** `config-file-entity-views`: the model names Core's config file declares, for the toggled-on list. */ +export async function getConfigFileModels() { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.listNames(token, ConfigFileEntityType.Models); +} + +/** `config-file-entity-views`: reads a model by name from Core's config-file population directly. */ +export async function getConfigFileModel(name: string) { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.getEntity(token, ConfigFileEntityType.Models, name); +} diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-models/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-models/page.tsx index 58ac73f312..9c3bdfcad9 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-models/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-models/page.tsx @@ -1,4 +1,4 @@ -import ModelsList from '@/src/components/Assets/Platform/Models/List'; +import PlatformModelsPageList from '@/src/components/Assets/Platform/Models/PageList'; import { SaveValidationContextProvider } from '@/src/context/SaveValidationContext'; export const dynamic = 'force-dynamic'; @@ -6,7 +6,7 @@ export const dynamic = 'force-dynamic'; export default async function Page() { return ( - + ); } diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-roles/[id]/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-roles/[id]/page.tsx index fc281b36ac..e904287ac2 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-roles/[id]/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-roles/[id]/page.tsx @@ -5,21 +5,31 @@ import { DEFAULT_ETAG } from '@/src/constants/api-headers'; import { SaveValidationContextProvider } from '@/src/context/SaveValidationContext'; import { DialRoleResource } from '@/src/models/dial/resource'; import { errorObjLog } from '@/src/server/logger'; -import { getRole } from '../actions'; +import { getConfigFileRole, getRole } from '../actions'; export const dynamic = 'force-dynamic'; -export default async function Page(params: { params: Promise<{ id: string }> }) { +export default async function Page(params: { + params: Promise<{ id: string }>; + searchParams: Promise<{ configFile?: string }>; +}) { + const isConfigFileMode = (await params.searchParams).configFile === 'true'; + let etag = DEFAULT_ETAG; let role: DialRoleResource | null = null; try { const path = (await params.params).id; - role = await getRole(path, etag).then((res) => { - etag = res?.etag || DEFAULT_ETAG; - return res?.response as DialRoleResource | null; - }); + if (isConfigFileMode) { + const result = await getConfigFileRole(path); + role = result.success ? (result.data as unknown as DialRoleResource) : null; + } else { + role = await getRole(path, etag).then((res) => { + etag = res?.etag || DEFAULT_ETAG; + return res?.response as DialRoleResource | null; + }); + } } catch (e) { errorObjLog(e, 'Failed to fetch role asset data'); } @@ -30,7 +40,7 @@ export default async function Page(params: { params: Promise<{ id: string }> }) return ( - + ); } diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-roles/actions.spec.ts b/apps/ai-dial-admin/src/app/[lang]/platform-roles/actions.spec.ts index f474d5e2cd..18fd72eaba 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-roles/actions.spec.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-roles/actions.spec.ts @@ -1,12 +1,22 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { assetApi } from '@/src/app/api/api'; +import { assetApi, configFileApi } from '@/src/app/api/api'; import { DialModelResourceStatus } from '@/src/models/dial/resource'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; import { RESPONSE_MOCK, TOKEN_MOCK } from '@/src/utils/tests/mock/api.mock'; -import { bulkDeleteRoles, createRole, getRole, getRoles, removeRole, updateRole } from './actions'; +import { + bulkDeleteRoles, + createRole, + getConfigFileRole, + getConfigFileRoles, + getRole, + getRoles, + removeRole, + updateRole, +} from './actions'; vi.mock('@/src/utils/auth/auth-request'); vi.mock('@/src/utils/env/get-auth-toggle'); @@ -165,4 +175,24 @@ describe('Assets role :: server actions', () => { expect(assetApi.delete).toHaveBeenCalledWith(TOKEN_MOCK, ResourceType.ROLE, 'platform/my-role'); expect(result).toEqual({ success: true }); }); + + test('Should call getConfigFileRoles action', async () => { + (configFileApi.listNames as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileRoles(); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.listNames).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Roles); + expect(result).toBe(RESPONSE_MOCK); + }); + + test('Should call getConfigFileRole action', async () => { + (configFileApi.getEntity as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileRole('my-role'); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.getEntity).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Roles, 'my-role'); + expect(result).toBe(RESPONSE_MOCK); + }); }); diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-roles/actions.ts b/apps/ai-dial-admin/src/app/[lang]/platform-roles/actions.ts index 9f48769b21..068edd8bd1 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-roles/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-roles/actions.ts @@ -2,9 +2,11 @@ import { cookies, headers } from 'next/headers'; -import { assetApi } from '@/src/app/api/api'; +import { assetApi, configFileApi } from '@/src/app/api/api'; +import { DialRole } from '@/src/models/dial/role'; import { DialRoleResource } from '@/src/models/dial/resource'; import { bulkDeleteAssets } from '@/src/server/assets/bulk-delete'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; @@ -81,3 +83,15 @@ export async function bulkDeleteRoles(paths: { path: string }[]) { const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); return bulkDeleteAssets(assetApi, token, ResourceType.ROLE, paths); } + +/** `config-file-entity-views`: the role names Core's config file declares. */ +export async function getConfigFileRoles() { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.listNames(token, ConfigFileEntityType.Roles); +} + +/** `config-file-entity-views`: reads a role by name from Core's config-file population directly. */ +export async function getConfigFileRole(name: string) { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.getEntity(token, ConfigFileEntityType.Roles, name); +} diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-roles/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-roles/page.tsx index 74055f984d..7161ef99bb 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-roles/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-roles/page.tsx @@ -1,4 +1,4 @@ -import RolesList from '@/src/components/Assets/Platform/Roles/List'; +import PlatformRolesPageList from '@/src/components/Assets/Platform/Roles/PageList'; import { SaveValidationContextProvider } from '@/src/context/SaveValidationContext'; export const dynamic = 'force-dynamic'; @@ -6,7 +6,7 @@ export const dynamic = 'force-dynamic'; export default async function Page() { return ( - + ); } diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-routes/[id]/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-routes/[id]/page.tsx index b7d816eef8..7d346d38f9 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-routes/[id]/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-routes/[id]/page.tsx @@ -12,11 +12,15 @@ import { errorObjLog } from '@/src/server/logger'; import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; -import { getRoute } from '../actions'; +import { getConfigFileRoute, getRoute } from '../actions'; export const dynamic = 'force-dynamic'; -export default async function Page(params: { params: Promise<{ id: string }> }) { +export default async function Page(params: { + params: Promise<{ id: string }>; + searchParams: Promise<{ configFile?: string }>; +}) { + const isConfigFileMode = (await params.searchParams).configFile === 'true'; const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); let etag = DEFAULT_ETAG; @@ -26,10 +30,15 @@ export default async function Page(params: { params: Promise<{ id: string }> }) try { const path = (await params.params).id; - route = await getRoute(path, etag).then((res) => { - etag = res?.etag || DEFAULT_ETAG; - return res?.response as DialRouteResource | null; - }); + if (isConfigFileMode) { + const result = await getConfigFileRoute(path); + route = result.success ? (result.data as unknown as DialRouteResource) : null; + } else { + route = await getRoute(path, etag).then((res) => { + etag = res?.etag || DEFAULT_ETAG; + return res?.response as DialRouteResource | null; + }); + } } catch (e) { errorObjLog(e, 'Failed to fetch route asset data'); } @@ -38,7 +47,7 @@ export default async function Page(params: { params: Promise<{ id: string }> }) // not prevent the route from loading. Core-direct — matching Assets > Models/App Runners — rather // than the admin-BE list, which cannot see roles declared in Core's configuration file, and which // is a different population from `Assets > Roles`' own API-written one. - const roles = await readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings); + const roles = await readConfigEntities(token, ConfigFileEntityType.Roles, optionWarnings, false); if (route == null) { notFound(); @@ -46,7 +55,13 @@ export default async function Page(params: { params: Promise<{ id: string }> }) return ( - + ); } diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-routes/actions.spec.ts b/apps/ai-dial-admin/src/app/[lang]/platform-routes/actions.spec.ts index 2b6fd32e86..6b3d923c2c 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-routes/actions.spec.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-routes/actions.spec.ts @@ -1,12 +1,22 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { assetApi } from '@/src/app/api/api'; +import { assetApi, configFileApi } from '@/src/app/api/api'; import { DialModelResourceStatus } from '@/src/models/dial/resource'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; import { RESPONSE_MOCK, TOKEN_MOCK } from '@/src/utils/tests/mock/api.mock'; -import { bulkDeleteRoutes, createRoute, getRoute, getRoutes, removeRoute, updateRoute } from './actions'; +import { + bulkDeleteRoutes, + createRoute, + getConfigFileRoute, + getConfigFileRoutes, + getRoute, + getRoutes, + removeRoute, + updateRoute, +} from './actions'; vi.mock('@/src/utils/auth/auth-request'); vi.mock('@/src/utils/env/get-auth-toggle'); @@ -155,4 +165,24 @@ describe('Assets route :: server actions', () => { expect(assetApi.delete).toHaveBeenCalledWith(TOKEN_MOCK, ResourceType.ROUTE, 'platform/my-route'); expect(result).toEqual({ success: true }); }); + + test('Should call getConfigFileRoutes action', async () => { + (configFileApi.listNames as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileRoutes(); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.listNames).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Routes); + expect(result).toBe(RESPONSE_MOCK); + }); + + test('Should call getConfigFileRoute action', async () => { + (configFileApi.getEntity as any).mockResolvedValue(RESPONSE_MOCK); + + const result = await getConfigFileRoute('my-route'); + + expect(getUserToken).toHaveBeenCalled(); + expect(configFileApi.getEntity).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Routes, 'my-route'); + expect(result).toBe(RESPONSE_MOCK); + }); }); diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-routes/actions.ts b/apps/ai-dial-admin/src/app/[lang]/platform-routes/actions.ts index 2a09cadee7..d6da471f10 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-routes/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/platform-routes/actions.ts @@ -2,9 +2,11 @@ import { cookies, headers } from 'next/headers'; -import { assetApi } from '@/src/app/api/api'; +import { assetApi, configFileApi } from '@/src/app/api/api'; +import { DialRoute } from '@/src/models/dial/route'; import { DialRouteResource } from '@/src/models/dial/resource'; import { bulkDeleteAssets } from '@/src/server/assets/bulk-delete'; +import { ConfigFileEntityType } from '@/src/types/config-file-entity'; import { ResourceType } from '@/src/types/resource-type'; import { getUserToken } from '@/src/utils/auth/auth-request'; import { getIsEnableAuthToggle } from '@/src/utils/env/get-auth-toggle'; @@ -70,3 +72,15 @@ export async function bulkDeleteRoutes(paths: { path: string }[]) { const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); return bulkDeleteAssets(assetApi, token, ResourceType.ROUTE, paths); } + +/** `config-file-entity-views`: the route names Core's config file declares. */ +export async function getConfigFileRoutes() { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.listNames(token, ConfigFileEntityType.Routes); +} + +/** `config-file-entity-views`: reads a route by name from Core's config-file population directly. */ +export async function getConfigFileRoute(name: string) { + const token = await getUserToken(getIsEnableAuthToggle(), headers(), cookies()); + return configFileApi.getEntity(token, ConfigFileEntityType.Routes, name); +} diff --git a/apps/ai-dial-admin/src/app/[lang]/platform-routes/page.tsx b/apps/ai-dial-admin/src/app/[lang]/platform-routes/page.tsx index 7cc669d8af..fb8823ed76 100644 --- a/apps/ai-dial-admin/src/app/[lang]/platform-routes/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/platform-routes/page.tsx @@ -1,4 +1,4 @@ -import RoutesList from '@/src/components/Assets/Platform/Routes/List'; +import PlatformRoutesPageList from '@/src/components/Assets/Platform/Routes/PageList'; import { SaveValidationContextProvider } from '@/src/context/SaveValidationContext'; export const dynamic = 'force-dynamic'; @@ -6,7 +6,7 @@ export const dynamic = 'force-dynamic'; export default async function Page() { return ( - + ); } diff --git a/apps/ai-dial-admin/src/app/[lang]/system-properties/page.tsx b/apps/ai-dial-admin/src/app/[lang]/system-properties/page.tsx index e1f4dcad49..27952b0afd 100644 --- a/apps/ai-dial-admin/src/app/[lang]/system-properties/page.tsx +++ b/apps/ai-dial-admin/src/app/[lang]/system-properties/page.tsx @@ -48,6 +48,7 @@ export default async function Page() { token, ConfigFileEntityType.Interceptors, optionWarnings, + false, ); return ( diff --git a/apps/ai-dial-admin/src/app/[lang]/tables/actions.ts b/apps/ai-dial-admin/src/app/[lang]/tables/actions.ts index 25584acb9b..54b750a462 100644 --- a/apps/ai-dial-admin/src/app/[lang]/tables/actions.ts +++ b/apps/ai-dial-admin/src/app/[lang]/tables/actions.ts @@ -65,7 +65,7 @@ export async function replaceTableAccess(name: string, access: TableAccess): Pro export async function getRoles(): Promise { const warnings: EntitiesI18nKey[] = []; - const roles = await readConfigEntities(await token(), ConfigFileEntityType.Roles, warnings); + const roles = await readConfigEntities(await token(), ConfigFileEntityType.Roles, warnings, false); return { roles, warnings }; } diff --git a/apps/ai-dial-admin/src/app/[lang]/tables/tests/actions.spec.ts b/apps/ai-dial-admin/src/app/[lang]/tables/tests/actions.spec.ts index 2136c9344d..8d7837579a 100644 --- a/apps/ai-dial-admin/src/app/[lang]/tables/tests/actions.spec.ts +++ b/apps/ai-dial-admin/src/app/[lang]/tables/tests/actions.spec.ts @@ -128,7 +128,7 @@ describe('Tables server actions', () => { const catalog = await getRoles(); - expect(readConfigEntities).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Roles, []); + expect(readConfigEntities).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Roles, [], false); expect(rolesApi.getRolesList).not.toHaveBeenCalled(); expect(catalog.roles).toEqual([ { name: 'analytics-writer', displayName: 'analytics-writer', origin: ConfigEntityOrigin.Api }, diff --git a/apps/ai-dial-admin/src/components/Adapter/View/View.tsx b/apps/ai-dial-admin/src/components/Adapter/View/View.tsx index cd2b0aadc9..233216b10a 100644 --- a/apps/ai-dial-admin/src/components/Adapter/View/View.tsx +++ b/apps/ai-dial-admin/src/components/Adapter/View/View.tsx @@ -17,6 +17,7 @@ import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor' import { SOURCE_TYPE } from '@/src/components/SourceField/types'; import { ButtonsI18nKey, CreateI18nKey } from '@/src/constants/i18n'; import { BASE_BUTTON_ICON_PROPS } from '@/src/constants/main-layout'; +import { useAppContext } from '@/src/context/AppContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -42,10 +43,11 @@ const AdapterView: FC = ({ originalAdapter, modelsNames, etag }) => { const router = useRouter(); const { showNotification } = useNotification(); const { dispatch } = useSaveValidationContext(); + const { featureFlags } = useAppContext(); const getReqRef = useRef(useProtectedRequest()); - const tabs = getAdapterTabs(t); + const tabs = getAdapterTabs(t, featureFlags); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [selectedAdapter, setSelectedAdapter] = useState(cloneDeep(originalAdapter)); diff --git a/apps/ai-dial-admin/src/components/ApplicationRunners/View/View.tsx b/apps/ai-dial-admin/src/components/ApplicationRunners/View/View.tsx index e77c5b68c2..8ca46541b6 100644 --- a/apps/ai-dial-admin/src/components/ApplicationRunners/View/View.tsx +++ b/apps/ai-dial-admin/src/components/ApplicationRunners/View/View.tsx @@ -23,6 +23,7 @@ import SimpleEntityHeader from '@/src/components/EntityHeaderControls/SimpleHead import CreateEntity from '@/src/components/EntityListView/CreateEntity/CreateEntity'; import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor'; import { ButtonsI18nKey, CreateI18nKey } from '@/src/constants/i18n'; +import { useAppContext } from '@/src/context/AppContext'; import { useAppsFolder } from '@/src/context/assets/AppsFolderContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext'; @@ -55,9 +56,10 @@ const ApplicationRunnersView: FC = ({ etag, originalScheme, names, ...pro const router = useRouter(); const { showNotification } = useNotification(); const { dispatch } = useSaveValidationContext(); + const { featureFlags } = useAppContext(); const getReqRef = useRef(useProtectedRequest()); - const tabs = getAppRunnerTabs(t); + const tabs = getAppRunnerTabs(t, featureFlags); const items: DropdownItem[] = [ { key: 'Application', label: t(CreateI18nKey.Application), onClick: () => setIsCreateAppModalOpen(true) }, diff --git a/apps/ai-dial-admin/src/components/Applications/List/List.tsx b/apps/ai-dial-admin/src/components/Applications/List/List.tsx index d793522d4b..bc84cbf973 100644 --- a/apps/ai-dial-admin/src/components/Applications/List/List.tsx +++ b/apps/ai-dial-admin/src/components/Applications/List/List.tsx @@ -1,5 +1,5 @@ 'use client'; -import { FC, useMemo } from 'react'; +import { FC, ReactNode, useMemo } from 'react'; import { createApplication, removeApplication } from '@/src/app/[lang]/applications/actions'; import { APPLICATIONS_COLUMNS } from '@/src/constants/grid-columns/grid-columns'; @@ -13,9 +13,12 @@ import { filterDisplayNamesWithVersions } from '@/src/utils/entities/filter-name interface Props { data: DialApplication[]; runners: DialApplicationScheme[]; + /** True when `data` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; + headerExtra?: ReactNode; } -const ApplicationsList: FC = ({ data, runners }) => { +const ApplicationsList: FC = ({ data, runners, isConfigFileSource, headerExtra }) => { const names = filterDisplayNamesWithVersions(data); const t = useI18n(); const { codeAppEditorUrl } = useAppContext(); @@ -32,6 +35,8 @@ const ApplicationsList: FC = ({ data, runners }) => { onCreateEntity={createApplication} onRemoveEntity={removeApplication} showColumnsButton={true} + isConfigFileSource={isConfigFileSource} + headerExtra={headerExtra} /> ); }; diff --git a/apps/ai-dial-admin/src/components/Applications/View/View.tsx b/apps/ai-dial-admin/src/components/Applications/View/View.tsx index 8728e70990..246e970a7a 100644 --- a/apps/ai-dial-admin/src/components/Applications/View/View.tsx +++ b/apps/ai-dial-admin/src/components/Applications/View/View.tsx @@ -53,8 +53,9 @@ const ApplicationView: FC = ({ etag, originalApplication, ...props }) => const { showNotification } = useNotification(); const { dispatch } = useSaveValidationContext(); const getReqRef = useRef(useProtectedRequest()); + const { visualizerConnector, featureFlags } = useAppContext(); - const [tabs, setTabs] = useState(getApplicationTabs(t)); + const [tabs, setTabs] = useState(getApplicationTabs(t, featureFlags)); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [isSkipRefresh, setIsSkipRefresh] = useState(true); @@ -65,8 +66,6 @@ const ApplicationView: FC = ({ etag, originalApplication, ...props }) => const [coreApplication, setCoreApplication] = useState(null); const [discardKey, setDiscardKey] = useState(0); - const { visualizerConnector } = useAppContext(); - const jsonConfiguration = useMemo( () => ({ isEditorEnabled, @@ -85,9 +84,9 @@ const ApplicationView: FC = ({ etag, originalApplication, ...props }) => const appRunner = getAppRunner(originalApplication, props.applicationSchemes); if (originalApplication.mcp?.endpoint || (appRunner && appRunner?.['dial:applicationTypeMcp'])) { - setTabs(getApplicationTabs(t).toSpliced(1, 0, toolsTab(t))); + setTabs(getApplicationTabs(t, featureFlags).toSpliced(1, 0, toolsTab(t))); } else { - setTabs(getApplicationTabs(t)); + setTabs(getApplicationTabs(t, featureFlags)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [originalApplication.mcp?.endpoint]); diff --git a/apps/ai-dial-admin/src/components/Assets/Apps/PageList.tsx b/apps/ai-dial-admin/src/components/Assets/Apps/PageList.tsx new file mode 100644 index 0000000000..f29fe35f4d --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Apps/PageList.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { FC } from 'react'; + +import { getConfigFileApplications } from '@/src/app/[lang]/assets-applications/actions'; +import ConfigFileEntityList from '@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList'; +import ConfigFileListSwap from '@/src/components/Common/ConfigFileListSwap/ConfigFileListSwap'; +import ConfigFilesToggle from '@/src/components/Common/ConfigFilesToggle/ConfigFilesToggle'; +import { DialApplicationScheme } from '@/src/models/dial/application'; +import { ApplicationRoute } from '@/src/types/routes'; +import AssetAppsList from './List'; + +interface Props { + runners: DialApplicationScheme[]; +} + +/** + * What `assets-applications/page.tsx` renders: the existing asset browser by default, swapped for + * the config-file-backed, names-only list when `showConfigFiles` is on — see `config-file-entity-views`. + */ +const AssetsApplicationsPageList: FC = ({ runners }) => ( + } + fetchConfigFileList={getConfigFileApplications} + renderConfigFileList={(names) => ( + } + /> + )} + /> +); + +export default AssetsApplicationsPageList; diff --git a/apps/ai-dial-admin/src/components/Assets/Apps/tests/PageList.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Apps/tests/PageList.spec.tsx new file mode 100644 index 0000000000..2f56fde74e --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Apps/tests/PageList.spec.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { getConfigFileApplications } from '@/src/app/[lang]/assets-applications/actions'; +import AssetsApplicationsPageList from '../PageList'; + +vi.mock('@/src/app/[lang]/assets-applications/actions', () => ({ getConfigFileApplications: vi.fn() })); +vi.mock('@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList', () => ({ + default: ({ names, route }: { names: string[]; route: string }) => ( +
+ config-file-applications:{names.length}:{route} +
+ ), +})); +vi.mock('../List', () => ({ + default: ({ runners }: { runners: unknown[] }) =>
asset-apps-list:{runners.length}
, +})); + +const mockContext = { showConfigFiles: false }; +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ showConfigFiles: mockContext.showConfigFiles }), +})); + +describe('AssetsApplicationsPageList', () => { + test('renders the asset list, passing runners through, and issues no config-file fetch by default', () => { + mockContext.showConfigFiles = false; + + render(); + + expect(screen.getByText('asset-apps-list:1')).toBeTruthy(); + expect(getConfigFileApplications).not.toHaveBeenCalled(); + }); + + test('renders the shared config-file list, for the Applications route, when the toggle is on', async () => { + mockContext.showConfigFiles = true; + vi.mocked(getConfigFileApplications).mockResolvedValue({ + success: true, + data: ['a1'], + }); + + render(); + + expect(await screen.findByText('config-file-applications:1:/assets-applications')).toBeTruthy(); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Assets/BaseAssetList/BaseAssetList.tsx b/apps/ai-dial-admin/src/components/Assets/BaseAssetList/BaseAssetList.tsx index 10975c53a6..bebbaf90ee 100644 --- a/apps/ai-dial-admin/src/components/Assets/BaseAssetList/BaseAssetList.tsx +++ b/apps/ai-dial-admin/src/components/Assets/BaseAssetList/BaseAssetList.tsx @@ -18,7 +18,9 @@ import { getImportNotificationContent, getVersionsPerName, } from '@/src/components/Assets/utils'; +import ConfigFilesToggle from '@/src/components/Common/ConfigFilesToggle/ConfigFilesToggle'; import FileManager from '@/src/components/Common/FileManager/FileManager'; +import { CONFIG_FILE_ENTITY_VIEWS } from '@/src/constants/config-file-entity-views'; import { isItemOpenable } from '@/src/components/Common/FileManager/utils'; import { navigateEntityUrl } from '@/src/components/EntityListView/utils/on-cell-clicked'; import { getFormDataForImport } from '@/src/components/EntityListView/HeaderButtons/utils'; @@ -636,6 +638,7 @@ const BaseAssetList: FC = ({ view, runners }) => { <> : undefined} columnDefs={columnDefs} getContext={getContext} view={view} diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/PageList.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/PageList.tsx new file mode 100644 index 0000000000..a464d062f6 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/PageList.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { FC } from 'react'; + +import { getConfigFileAppRunners } from '@/src/app/[lang]/platform-app-runners/actions'; +import ConfigFileEntityList from '@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList'; +import ConfigFileListSwap from '@/src/components/Common/ConfigFileListSwap/ConfigFileListSwap'; +import ConfigFilesToggle from '@/src/components/Common/ConfigFilesToggle/ConfigFilesToggle'; +import { ApplicationRoute } from '@/src/types/routes'; +import AssetAppRunnersList from './List'; + +/** + * What `platform-app-runners/page.tsx` renders: the existing asset browser by default, swapped for + * the config-file-backed, names-only list when `showConfigFiles` is on — see `config-file-entity-views`. + */ +const PlatformAppRunnersPageList: FC = () => ( + } + fetchConfigFileList={getConfigFileAppRunners} + renderConfigFileList={(names) => ( + } + /> + )} + /> +); + +export default PlatformAppRunnersPageList; diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/View.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/View.tsx index 2758f54a42..26c9f78c25 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/View.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/View.tsx @@ -14,6 +14,7 @@ import { JsonConfiguration } from '@/src/components/EntityHeaderControls/models' import SimpleEntityHeader from '@/src/components/EntityHeaderControls/SimpleHeader'; import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor'; import { ButtonsI18nKey, CreateI18nKey, EntitiesI18nKey } from '@/src/constants/i18n'; +import { useAppContext } from '@/src/context/AppContext'; import { useAppRunnersFolder } from '@/src/context/assets/AppRunnersFolderContext'; import { useAppsFolder } from '@/src/context/assets/AppsFolderContext'; import { useNotification } from '@/src/context/NotificationContext'; @@ -42,6 +43,8 @@ interface Props { globalInterceptors: string[]; /** i18n keys for non-fatal problems from the server-side option reads, resolved here. */ optionWarnings?: EntitiesI18nKey[]; + /** True when `originalRunner` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; } const AppRunnerAssetView: FC = ({ @@ -51,6 +54,7 @@ const AppRunnerAssetView: FC = ({ interceptors, globalInterceptors, optionWarnings, + isConfigFileSource, }) => { const t = useI18n(); const tabs = getTabsForAsset(t, ApplicationRoute.PlatformAppRunners); @@ -59,6 +63,14 @@ const AppRunnerAssetView: FC = ({ const { showNotification } = useNotification(); const { dispatch } = useSaveValidationContext(); const getReqRef = useRef(useProtectedRequest()); + const { setEntityReadOnly } = useAppContext(); + + // Config-file entities have no write endpoint and no admin-backend "compare with Core" projection + // of their own (they already *are* Core's view) — see `config-file-entity-views`. + useEffect(() => { + setEntityReadOnly(!!isConfigFileSource); + return () => setEntityReadOnly(false); + }, [isConfigFileSource, setEntityReadOnly]); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [selectedRunner, setSelectedRunner] = useState(structuredClone(originalRunner)); @@ -81,8 +93,11 @@ const AppRunnerAssetView: FC = ({ () => ({ isEditorEnabled, onToggleEditor: () => setIsEditorEnabled((prev) => !prev), + // A config-file-sourced entity has no admin-backend "compare with Core" projection of its own — + // it already is Core's own view — so the ADMIN|CORE format selector has nothing to switch to. + onHideFormatSelector: () => !!isConfigFileSource, }), - [isEditorEnabled], + [isEditorEnabled, isConfigFileSource], ); useEffect(() => { diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/tests/PageList.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/tests/PageList.spec.tsx new file mode 100644 index 0000000000..6e9eec4c65 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/tests/PageList.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { getConfigFileAppRunners } from '@/src/app/[lang]/platform-app-runners/actions'; +import PlatformAppRunnersPageList from '../PageList'; + +vi.mock('@/src/app/[lang]/platform-app-runners/actions', () => ({ getConfigFileAppRunners: vi.fn() })); +vi.mock('@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList', () => ({ + default: ({ names, route }: { names: string[]; route: string }) => ( +
+ config-file-app-runners:{names.length}:{route} +
+ ), +})); +vi.mock('../List', () => ({ default: () =>
asset-app-runners-list
})); + +const mockContext = { showConfigFiles: false }; +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ showConfigFiles: mockContext.showConfigFiles }), +})); + +describe('PlatformAppRunnersPageList', () => { + test('renders the asset list and issues no config-file fetch by default', () => { + mockContext.showConfigFiles = false; + + render(); + + expect(screen.getByText('asset-app-runners-list')).toBeTruthy(); + expect(getConfigFileAppRunners).not.toHaveBeenCalled(); + }); + + test('renders the shared config-file list, for the ApplicationRunners route, when the toggle is on', async () => { + mockContext.showConfigFiles = true; + vi.mocked(getConfigFileAppRunners).mockResolvedValue({ + success: true, + data: ['runner-1'], + }); + + render(); + + expect(await screen.findByText('config-file-app-runners:1:/platform-app-runners')).toBeTruthy(); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/tests/View.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/tests/View.spec.tsx index 50b61667fa..a30185ac8f 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/tests/View.spec.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/AppRunners/tests/View.spec.tsx @@ -15,18 +15,27 @@ vi.mock('@/src/app/[lang]/platform-app-runners/actions', () => ({ // Always-enabled save so validation is the only thing that can stop the request — the real header // also disables on `isChanged`, which would otherwise mask whether validation ran at all. +let capturedJsonConfiguration: any; vi.mock('@/src/components/EntityHeaderControls/SimpleHeader', () => ({ - default: ({ onSave }: any) => ( - - ), + default: ({ onSave, jsonConfiguration }: any) => { + capturedJsonConfiguration = jsonConfiguration; + return ( + + ); + }, })); vi.mock('../TabsContent', () => ({ default: () =>
tabs-content
})); vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) })); +const setEntityReadOnly = vi.fn(); +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ setEntityReadOnly }), +})); + const runner = (overrides: Partial = {}): DialAppRunnerResource => ({ $id: 'https://host/runner', @@ -99,3 +108,37 @@ describe('AppRunnerAssetView :: save validation', () => { expect(updateRunner).not.toHaveBeenCalled(); }); }); + +describe('AppRunnerAssetView :: config-file source', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('Should mark the entity read-only and hide the format selector when config-file-sourced', () => { + const { unmount } = render( + , + ); + + expect(setEntityReadOnly).toHaveBeenCalledWith(true); + expect(capturedJsonConfiguration?.onHideFormatSelector?.()).toBe(true); + + unmount(); + + expect(setEntityReadOnly).toHaveBeenLastCalledWith(false); + }); + + test('Should not mark the entity read-only for an admin-backed runner', () => { + render( + , + ); + + expect(setEntityReadOnly).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Applications/View.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Applications/View.tsx index 92ba42d5dd..270a24f69c 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Applications/View.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Applications/View.tsx @@ -45,6 +45,8 @@ interface Props { translators?: ResourceInfo[]; /** i18n keys for non-fatal problems from the server-side option reads, resolved here. */ optionWarnings?: EntitiesI18nKey[]; + /** True when `originalApp` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; } /** @@ -86,13 +88,21 @@ const PlatformApplicationView: FC = ({ globalInterceptors, translators, optionWarnings, + isConfigFileSource, }) => { const t = useI18n(); const router = useRouter(); const { fetchFiles } = useAppsFolder(); const { showNotification } = useNotification(); const getReqRef = useRef(useProtectedRequest()); - const { visualizerConnector } = useAppContext(); + const { visualizerConnector, setEntityReadOnly } = useAppContext(); + + // Config-file entities have no write endpoint and no admin-backend "compare with Core" projection + // of their own (they already *are* Core's view) — see `config-file-entity-views`. + useEffect(() => { + setEntityReadOnly(!!isConfigFileSource); + return () => setEntityReadOnly(false); + }, [isConfigFileSource, setEntityReadOnly]); const [etag, setEtag] = useState(initialEtag); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Applications/tests/View.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Applications/tests/View.spec.tsx index fd1c158021..66a1996e3e 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Applications/tests/View.spec.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Applications/tests/View.spec.tsx @@ -19,6 +19,11 @@ vi.mock('@/src/app/[lang]/assets-applications/actions', async (importOriginal) = updatePlatformApplication: vi.fn(), })); +const setEntityReadOnly = vi.fn(); +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ setEntityReadOnly, featureFlags: {} }), +})); + /** * Matches `Assets/Apps/tests/View.spec.tsx`'s scope: a render smoke test, not deep interaction * coverage — the component's real dependency surface (`TabsContent`, `SimpleEntityHeader`, tab @@ -123,4 +128,41 @@ describe('PlatformApplicationView', () => { const rolesTab = screen.getByRole('tab', { name: TabsI18nKey.Roles }); expect(!!rolesTab.querySelector('svg.tabler-icon-alert-triangle')).toBe(expectedWarning); }); + + test('Should mark the entity read-only when config-file-sourced, and clear it on unmount', () => { + const { unmount } = render( + , + ); + + expect(setEntityReadOnly).toHaveBeenCalledWith(true); + + unmount(); + + expect(setEntityReadOnly).toHaveBeenLastCalledWith(false); + }); + + test('Should not mark the entity read-only for an admin-backed application', () => { + render( + , + ); + + expect(setEntityReadOnly).toHaveBeenCalledWith(false); + }); }); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/PageList.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/PageList.tsx new file mode 100644 index 0000000000..063c014393 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/PageList.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { FC } from 'react'; + +import { getConfigFileInterceptors } from '@/src/app/[lang]/platform-interceptors/actions'; +import ConfigFileEntityList from '@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList'; +import ConfigFileListSwap from '@/src/components/Common/ConfigFileListSwap/ConfigFileListSwap'; +import ConfigFilesToggle from '@/src/components/Common/ConfigFilesToggle/ConfigFilesToggle'; +import { ApplicationRoute } from '@/src/types/routes'; +import AssetInterceptorsList from './List'; + +/** + * What `platform-interceptors/page.tsx` renders: the existing asset browser by default, swapped for + * the config-file-backed, names-only list when `showConfigFiles` is on — see `config-file-entity-views`. + */ +const PlatformInterceptorsPageList: FC = () => ( + } + fetchConfigFileList={getConfigFileInterceptors} + renderConfigFileList={(names) => ( + } + /> + )} + /> +); + +export default PlatformInterceptorsPageList; diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/View.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/View.tsx index c7b83175d0..e902534d9b 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/View.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/View.tsx @@ -7,6 +7,7 @@ import { removeInterceptor, updateInterceptor } from '@/src/app/[lang]/platform- import { JsonConfiguration } from '@/src/components/EntityHeaderControls/models'; import SimpleEntityHeader from '@/src/components/EntityHeaderControls/SimpleHeader'; import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor'; +import { useAppContext } from '@/src/context/AppContext'; import { useInterceptorsFolder } from '@/src/context/assets/InterceptorsFolderContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -22,15 +23,25 @@ import TabsContent from './TabsContent'; interface Props { etag: string; originalInterceptor: DialInterceptorResource; + /** True when `originalInterceptor` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; } -const InterceptorAssetView: FC = ({ etag, originalInterceptor }) => { +const InterceptorAssetView: FC = ({ etag, originalInterceptor, isConfigFileSource }) => { const t = useI18n(); const tabs = getTabsForAsset(t, ApplicationRoute.PlatformInterceptors); const router = useRouter(); const { fetchFiles } = useInterceptorsFolder(); const { showNotification } = useNotification(); const getReqRef = useRef(useProtectedRequest()); + const { setEntityReadOnly } = useAppContext(); + + // Config-file entities have no write endpoint and no admin-backend "compare with Core" projection + // of their own (they already *are* Core's view) — see `config-file-entity-views`. + useEffect(() => { + setEntityReadOnly(!!isConfigFileSource); + return () => setEntityReadOnly(false); + }, [isConfigFileSource, setEntityReadOnly]); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [selectedInterceptor, setSelectedInterceptor] = useState(structuredClone(originalInterceptor)); @@ -42,8 +53,11 @@ const InterceptorAssetView: FC = ({ etag, originalInterceptor }) => { () => ({ isEditorEnabled, onToggleEditor: () => setIsEditorEnabled((prev) => !prev), + // A config-file-sourced entity has no admin-backend "compare with Core" projection of its own — + // it already is Core's own view — so the ADMIN|CORE format selector has nothing to switch to. + onHideFormatSelector: () => !!isConfigFileSource, }), - [isEditorEnabled], + [isEditorEnabled, isConfigFileSource], ); useEffect(() => { diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/tests/PageList.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/tests/PageList.spec.tsx new file mode 100644 index 0000000000..8b7a4de932 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/tests/PageList.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { getConfigFileInterceptors } from '@/src/app/[lang]/platform-interceptors/actions'; +import PlatformInterceptorsPageList from '../PageList'; + +vi.mock('@/src/app/[lang]/platform-interceptors/actions', () => ({ getConfigFileInterceptors: vi.fn() })); +vi.mock('@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList', () => ({ + default: ({ names, route }: { names: string[]; route: string }) => ( +
+ config-file-interceptors:{names.length}:{route} +
+ ), +})); +vi.mock('../List', () => ({ default: () =>
asset-interceptors-list
})); + +const mockContext = { showConfigFiles: false }; +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ showConfigFiles: mockContext.showConfigFiles }), +})); + +describe('PlatformInterceptorsPageList', () => { + test('renders the asset list and issues no config-file fetch by default', () => { + mockContext.showConfigFiles = false; + + render(); + + expect(screen.getByText('asset-interceptors-list')).toBeTruthy(); + expect(getConfigFileInterceptors).not.toHaveBeenCalled(); + }); + + test('renders the shared config-file list, for the Interceptors route, when the toggle is on', async () => { + mockContext.showConfigFiles = true; + vi.mocked(getConfigFileInterceptors).mockResolvedValue({ + success: true, + data: ['i1'], + }); + + render(); + + expect(await screen.findByText('config-file-interceptors:1:/platform-interceptors')).toBeTruthy(); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/tests/View.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/tests/View.spec.tsx index dc154aa469..55c8348868 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/tests/View.spec.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Interceptors/tests/View.spec.tsx @@ -12,18 +12,27 @@ vi.mock('@/src/app/[lang]/platform-interceptors/actions', () => ({ getInterceptors: vi.fn().mockResolvedValue([]), })); +let capturedJsonConfiguration: any; vi.mock('@/src/components/EntityHeaderControls/SimpleHeader', () => ({ - default: ({ onSave }: any) => ( - - ), + default: ({ onSave, jsonConfiguration }: any) => { + capturedJsonConfiguration = jsonConfiguration; + return ( + + ); + }, })); vi.mock('../TabsContent', () => ({ default: () =>
tabs-content
})); vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) })); +const setEntityReadOnly = vi.fn(); +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ setEntityReadOnly }), +})); + const interceptor = (overrides: Partial = {}): DialInterceptorResource => ({ name: 'redactor', @@ -55,4 +64,23 @@ describe('InterceptorAssetView', () => { expect(screen.getByText('tabs-content')).toBeInTheDocument(); }); + + test('Should mark the entity read-only and hide the format selector when config-file-sourced', () => { + const { unmount } = render( + , + ); + + expect(setEntityReadOnly).toHaveBeenCalledWith(true); + expect(capturedJsonConfiguration?.onHideFormatSelector?.()).toBe(true); + + unmount(); + + expect(setEntityReadOnly).toHaveBeenLastCalledWith(false); + }); + + test('Should not mark the entity read-only for an admin-backed interceptor', () => { + render(); + + expect(setEntityReadOnly).toHaveBeenCalledWith(false); + }); }); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Models/PageList.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Models/PageList.tsx new file mode 100644 index 0000000000..6ae55728b8 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Models/PageList.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FC } from 'react'; + +import { getConfigFileModels } from '@/src/app/[lang]/platform-models/actions'; +import ConfigFileEntityList from '@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList'; +import ConfigFileListSwap from '@/src/components/Common/ConfigFileListSwap/ConfigFileListSwap'; +import ConfigFilesToggle from '@/src/components/Common/ConfigFilesToggle/ConfigFilesToggle'; +import { ApplicationRoute } from '@/src/types/routes'; +import AssetModelsList from './List'; + +/** + * What `platform-models/page.tsx` renders: the existing asset browser by default, swapped for the + * config-file-backed, names-only list when `showConfigFiles` is on — see `config-file-entity-views`. + */ +const PlatformModelsPageList: FC = () => ( + } + fetchConfigFileList={getConfigFileModels} + renderConfigFileList={(names) => ( + } /> + )} + /> +); + +export default PlatformModelsPageList; diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Models/View.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Models/View.tsx index 9c5ffab5ff..a3240c7c37 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Models/View.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Models/View.tsx @@ -34,6 +34,8 @@ interface Props { /** i18n keys for non-fatal problems from the server-side option reads, resolved here. */ optionWarnings?: EntitiesI18nKey[]; translators?: ResourceInfo[]; + /** True when `originalModel` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; } const ModelView: FC = ({ @@ -44,14 +46,22 @@ const ModelView: FC = ({ globalInterceptors, optionWarnings, translators, + isConfigFileSource, }) => { const t = useI18n(); - const { featureFlags } = useAppContext(); + const { featureFlags, setEntityReadOnly } = useAppContext(); const router = useRouter(); const { fetchFiles } = useModelsFolder(); const { showNotification } = useNotification(); const getReqRef = useRef(useProtectedRequest()); + // Config-file entities have no write endpoint and no admin-backend "compare with Core" projection + // of their own (they already *are* Core's view) — see `config-file-entity-views`. + useEffect(() => { + setEntityReadOnly(!!isConfigFileSource); + return () => setEntityReadOnly(false); + }, [isConfigFileSource, setEntityReadOnly]); + const [etag, setEtag] = useState(initialEtag); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [selectedModel, setSelectedModel] = useState(structuredClone(originalModel)); @@ -63,8 +73,11 @@ const ModelView: FC = ({ () => ({ isEditorEnabled, onToggleEditor: () => setIsEditorEnabled((prev) => !prev), + // A config-file-sourced entity has no admin-backend "compare with Core" projection of its own — + // it already is Core's own view — so the ADMIN|CORE format selector has nothing to switch to. + onHideFormatSelector: () => !!isConfigFileSource, }), - [isEditorEnabled], + [isEditorEnabled, isConfigFileSource], ); const tabs = useMemo( diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/PageList.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/PageList.spec.tsx new file mode 100644 index 0000000000..af238ef743 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/PageList.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { getConfigFileModels } from '@/src/app/[lang]/platform-models/actions'; +import PlatformModelsPageList from '../PageList'; + +vi.mock('@/src/app/[lang]/platform-models/actions', () => ({ getConfigFileModels: vi.fn() })); +vi.mock('@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList', () => ({ + default: ({ names, route }: { names: string[]; route: string }) => ( +
+ config-file-models:{names.length}:{route} +
+ ), +})); +vi.mock('../List', () => ({ default: () =>
asset-models-list
})); + +const mockContext = { showConfigFiles: false }; +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ showConfigFiles: mockContext.showConfigFiles }), +})); + +describe('PlatformModelsPageList', () => { + test('renders the asset list and issues no config-file fetch by default', () => { + mockContext.showConfigFiles = false; + + render(); + + expect(screen.getByText('asset-models-list')).toBeTruthy(); + expect(getConfigFileModels).not.toHaveBeenCalled(); + }); + + test('renders the shared config-file list, for the Models route, when the toggle is on', async () => { + mockContext.showConfigFiles = true; + vi.mocked(getConfigFileModels).mockResolvedValue({ + success: true, + data: ['m1'], + }); + + render(); + + expect(await screen.findByText('config-file-models:1:/platform-models')).toBeTruthy(); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/View-etag.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/View-etag.spec.tsx index fdb0bc8c42..20658f8cd1 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/View-etag.spec.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/View-etag.spec.tsx @@ -32,7 +32,7 @@ vi.mock('@/src/context/assets/ModelsFolderContext', () => ({ })); vi.mock('@/src/context/AppContext', () => ({ - useAppContext: () => ({ featureFlags: {} }), + useAppContext: () => ({ featureFlags: {}, setEntityReadOnly: vi.fn() }), })); // Bypasses the session-validity retry logic — irrelevant to the etag behavior under test. diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/View.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/View.spec.tsx new file mode 100644 index 0000000000..7f392014b1 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/View.spec.tsx @@ -0,0 +1,72 @@ +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { AssetModel } from '@/src/models/dial/deployment-asset'; +import ModelView from '../View'; + +vi.mock('@/src/app/[lang]/platform-models/actions', () => ({ + updateModel: vi.fn(), + removeModel: vi.fn(), +})); + +vi.mock('@/src/context/assets/ModelsFolderContext', () => ({ + useModelsFolder: () => ({ fetchFiles: vi.fn() }), +})); + +vi.mock('../TabsContent', () => ({ default: () =>
tabs-content
})); + +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) })); + +let capturedJsonConfiguration: any; +vi.mock('@/src/components/EntityHeaderControls/SimpleHeader', () => ({ + default: ({ jsonConfiguration }: any) => { + capturedJsonConfiguration = jsonConfiguration; + return
header
; + }, +})); + +const setEntityReadOnly = vi.fn(); +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ + featureFlags: { deploymentsEnabled: true, adminApiEnabled: true }, + setEntityReadOnly, + }), +})); + +const model = { name: 'model-1', path: 'model-1', folderId: '' } as AssetModel; + +describe('ModelView — config-file source', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('marks the entity read-only on mount when config-file-sourced, and clears it on unmount', () => { + const { unmount } = render( + , + ); + + expect(setEntityReadOnly).toHaveBeenCalledWith(true); + + unmount(); + + expect(setEntityReadOnly).toHaveBeenLastCalledWith(false); + }); + + test('does not mark the entity read-only for an admin-backed model', () => { + render(); + + expect(setEntityReadOnly).toHaveBeenCalledWith(false); + }); + + test('hides the ADMIN|CORE format selector for a config-file-sourced model', () => { + render(); + + expect(capturedJsonConfiguration?.onHideFormatSelector?.()).toBe(true); + }); + + test('does not hide the format selector for an admin-backed model', () => { + render(); + + expect(capturedJsonConfiguration?.onHideFormatSelector?.()).toBe(false); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/tabs.spec.ts b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/tabs.spec.ts index 0482e523b6..57a139576a 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/tabs.spec.ts +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/tabs.spec.ts @@ -7,6 +7,7 @@ import { EntityViewTab, getTabsForAsset } from '@/src/utils/tabs/utils'; const t = (key: string) => key; const flags = (overrides: Partial = {}): FeatureFlags => ({ + adminApiEnabled: false, dashboardEnabled: false, deploymentsEnabled: false, evaluationEnabled: false, @@ -19,7 +20,7 @@ const flags = (overrides: Partial = {}): FeatureFlags => ({ ...overrides, }); -const dashboardFlags = flags({ dashboardEnabled: true }); +const dashboardFlags = flags({ dashboardEnabled: true, adminApiEnabled: true }); const tabIds = (featureFlags?: FeatureFlags) => getTabsForAsset(t, ApplicationRoute.PlatformModels, featureFlags).map((tab) => tab.id); @@ -34,7 +35,7 @@ describe('Model asset :: detail view tab set', () => { ]); }); - test('Should append Audit as the fifth and last tab when the dashboard feature is enabled', () => { + test('Should append Audit as the fifth and last tab when the dashboard feature and admin API are enabled', () => { expect(tabIds(dashboardFlags)).toEqual([ EntityViewTab.Properties, EntityViewTab.Features, @@ -44,6 +45,15 @@ describe('Model asset :: detail view tab set', () => { ]); }); + test('Should not append Audit when the dashboard feature is enabled but the admin API is not', () => { + expect(tabIds(flags({ dashboardEnabled: true }))).toEqual([ + EntityViewTab.Properties, + EntityViewTab.Features, + EntityViewTab.Roles, + EntityViewTab.Interceptors, + ]); + }); + test.each([EntityViewTab.Audit, EntityViewTab.Parameters, EntityViewTab.AppRoutes, EntityViewTab.Dependencies])( 'Should not offer the %s tab, which has no Core counterpart for a config resource', (tab) => { diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/view-tabs.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/view-tabs.spec.tsx index 4e4ab58048..790ba1e7dd 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/view-tabs.spec.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Models/tests/view-tabs.spec.tsx @@ -14,7 +14,7 @@ interface SimpleHeaderMockProps { } const { useAppContextMock, simpleHeaderSpy } = vi.hoisted(() => ({ - useAppContextMock: vi.fn<() => Pick>(), + useAppContextMock: vi.fn<() => Pick>(), simpleHeaderSpy: vi.fn<(props: SimpleHeaderMockProps) => void>(), })); @@ -47,8 +47,14 @@ describe('ModelView — Audit tab wiring', () => { vi.clearAllMocks(); }); - const renderTabIds = (isDashboardEnabled: boolean): EntityViewTab[] => { - useAppContextMock.mockReturnValue({ featureFlags: { dashboardEnabled: isDashboardEnabled } as FeatureFlags }); + const renderTabIds = (isDashboardEnabled: boolean, isAdminApiEnabled = true): EntityViewTab[] => { + useAppContextMock.mockReturnValue({ + featureFlags: { + dashboardEnabled: isDashboardEnabled, + adminApiEnabled: isAdminApiEnabled, + } as FeatureFlags, + setEntityReadOnly: vi.fn(), + }); render(); expect(simpleHeaderSpy).toHaveBeenCalled(); @@ -57,11 +63,15 @@ describe('ModelView — Audit tab wiring', () => { return (tabs ?? []).map((tab) => tab.id as EntityViewTab); }; - test('Should pass the Audit tab to the header, last, when the dashboard feature is enabled', () => { + test('Should pass the Audit tab to the header, last, when the dashboard feature and admin API are enabled', () => { expect(renderTabIds(true)).toEqual([...MODEL_TABS, EntityViewTab.Audit]); }); test('Should pass the header the model tabs without Audit when the dashboard feature is disabled', () => { expect(renderTabIds(false)).toEqual(MODEL_TABS); }); + + test('Should pass the header the model tabs without Audit when the dashboard feature is enabled but the admin API is not', () => { + expect(renderTabIds(true, false)).toEqual(MODEL_TABS); + }); }); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Roles/PageList.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Roles/PageList.tsx new file mode 100644 index 0000000000..844c9da77e --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Roles/PageList.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FC } from 'react'; + +import { getConfigFileRoles } from '@/src/app/[lang]/platform-roles/actions'; +import ConfigFileEntityList from '@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList'; +import ConfigFileListSwap from '@/src/components/Common/ConfigFileListSwap/ConfigFileListSwap'; +import ConfigFilesToggle from '@/src/components/Common/ConfigFilesToggle/ConfigFilesToggle'; +import { ApplicationRoute } from '@/src/types/routes'; +import AssetRolesList from './List'; + +/** + * What `platform-roles/page.tsx` renders: the existing asset browser by default, swapped for the + * config-file-backed, names-only list when `showConfigFiles` is on — see `config-file-entity-views`. + */ +const PlatformRolesPageList: FC = () => ( + } + fetchConfigFileList={getConfigFileRoles} + renderConfigFileList={(names) => ( + } /> + )} + /> +); + +export default PlatformRolesPageList; diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Roles/View.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Roles/View.tsx index a27fe359d5..6424f27246 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Roles/View.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Roles/View.tsx @@ -7,6 +7,7 @@ import { removeRole, updateRole } from '@/src/app/[lang]/platform-roles/actions' import { JsonConfiguration } from '@/src/components/EntityHeaderControls/models'; import SimpleEntityHeader from '@/src/components/EntityHeaderControls/SimpleHeader'; import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor'; +import { useAppContext } from '@/src/context/AppContext'; import { useRolesFolder } from '@/src/context/assets/RolesFolderContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -22,15 +23,25 @@ import TabsContent from './TabsContent'; interface Props { etag: string; originalRole: DialRoleResource; + /** True when `originalRole` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; } -const RoleAssetView: FC = ({ etag, originalRole }) => { +const RoleAssetView: FC = ({ etag, originalRole, isConfigFileSource }) => { const t = useI18n(); const tabs = getTabsForAsset(t, ApplicationRoute.PlatformRoles); const router = useRouter(); const { fetchFiles } = useRolesFolder(); const { showNotification } = useNotification(); const getReqRef = useRef(useProtectedRequest()); + const { setEntityReadOnly } = useAppContext(); + + // Config-file entities have no write endpoint and no admin-backend "compare with Core" projection + // of their own (they already *are* Core's view) — see `config-file-entity-views`. + useEffect(() => { + setEntityReadOnly(!!isConfigFileSource); + return () => setEntityReadOnly(false); + }, [isConfigFileSource, setEntityReadOnly]); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [selectedRole, setSelectedRole] = useState(structuredClone(originalRole)); @@ -43,8 +54,11 @@ const RoleAssetView: FC = ({ etag, originalRole }) => { () => ({ isEditorEnabled, onToggleEditor: () => setIsEditorEnabled((prev) => !prev), + // A config-file-sourced entity has no admin-backend "compare with Core" projection of its own — + // it already is Core's own view — so the ADMIN|CORE format selector has nothing to switch to. + onHideFormatSelector: () => !!isConfigFileSource, }), - [isEditorEnabled], + [isEditorEnabled, isConfigFileSource], ); useEffect(() => { diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Roles/tests/PageList.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Roles/tests/PageList.spec.tsx new file mode 100644 index 0000000000..33e44ef022 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Roles/tests/PageList.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { getConfigFileRoles } from '@/src/app/[lang]/platform-roles/actions'; +import PlatformRolesPageList from '../PageList'; + +vi.mock('@/src/app/[lang]/platform-roles/actions', () => ({ getConfigFileRoles: vi.fn() })); +vi.mock('@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList', () => ({ + default: ({ names, route }: { names: string[]; route: string }) => ( +
+ config-file-roles:{names.length}:{route} +
+ ), +})); +vi.mock('../List', () => ({ default: () =>
asset-roles-list
})); + +const mockContext = { showConfigFiles: false }; +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ showConfigFiles: mockContext.showConfigFiles }), +})); + +describe('PlatformRolesPageList', () => { + test('renders the asset list and issues no config-file fetch by default', () => { + mockContext.showConfigFiles = false; + + render(); + + expect(screen.getByText('asset-roles-list')).toBeTruthy(); + expect(getConfigFileRoles).not.toHaveBeenCalled(); + }); + + test('renders the shared config-file list, for the Roles route, when the toggle is on', async () => { + mockContext.showConfigFiles = true; + vi.mocked(getConfigFileRoles).mockResolvedValue({ + success: true, + data: ['r1'], + }); + + render(); + + expect(await screen.findByText('config-file-roles:1:/platform-roles')).toBeTruthy(); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Roles/tests/View.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Roles/tests/View.spec.tsx index b36e840023..907f8d0483 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Roles/tests/View.spec.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Roles/tests/View.spec.tsx @@ -12,18 +12,27 @@ vi.mock('@/src/app/[lang]/platform-roles/actions', () => ({ getRoles: vi.fn().mockResolvedValue([]), })); +let capturedJsonConfiguration: any; vi.mock('@/src/components/EntityHeaderControls/SimpleHeader', () => ({ - default: ({ onSave }: any) => ( - - ), + default: ({ onSave, jsonConfiguration }: any) => { + capturedJsonConfiguration = jsonConfiguration; + return ( + + ); + }, })); vi.mock('../TabsContent', () => ({ default: () =>
tabs-content
})); vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) })); +const setEntityReadOnly = vi.fn(); +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ setEntityReadOnly }), +})); + const role = (overrides: Partial = {}): DialRoleResource => ({ name: 'my-role', @@ -54,4 +63,21 @@ describe('RoleAssetView', () => { expect(screen.getByText('tabs-content')).toBeInTheDocument(); }); + + test('Should mark the entity read-only and hide the format selector when config-file-sourced', () => { + const { unmount } = render(); + + expect(setEntityReadOnly).toHaveBeenCalledWith(true); + expect(capturedJsonConfiguration?.onHideFormatSelector?.()).toBe(true); + + unmount(); + + expect(setEntityReadOnly).toHaveBeenLastCalledWith(false); + }); + + test('Should not mark the entity read-only for an admin-backed role', () => { + render(); + + expect(setEntityReadOnly).toHaveBeenCalledWith(false); + }); }); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Routes/PageList.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Routes/PageList.tsx new file mode 100644 index 0000000000..1af57c6447 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Routes/PageList.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FC } from 'react'; + +import { getConfigFileRoutes } from '@/src/app/[lang]/platform-routes/actions'; +import ConfigFileEntityList from '@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList'; +import ConfigFileListSwap from '@/src/components/Common/ConfigFileListSwap/ConfigFileListSwap'; +import ConfigFilesToggle from '@/src/components/Common/ConfigFilesToggle/ConfigFilesToggle'; +import { ApplicationRoute } from '@/src/types/routes'; +import AssetRoutesList from './List'; + +/** + * What `platform-routes/page.tsx` renders: the existing asset browser by default, swapped for the + * config-file-backed, names-only list when `showConfigFiles` is on — see `config-file-entity-views`. + */ +const PlatformRoutesPageList: FC = () => ( + } + fetchConfigFileList={getConfigFileRoutes} + renderConfigFileList={(names) => ( + } /> + )} + /> +); + +export default PlatformRoutesPageList; diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Routes/View.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Routes/View.tsx index 2449f89972..6809608f64 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Routes/View.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Routes/View.tsx @@ -9,6 +9,7 @@ import SimpleEntityHeader from '@/src/components/EntityHeaderControls/SimpleHead import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor'; import { isAssetUnavailable } from '@/src/components/EntityView/Roles/utils'; import { EntitiesI18nKey } from '@/src/constants/i18n'; +import { useAppContext } from '@/src/context/AppContext'; import { useRoutesFolder } from '@/src/context/assets/RoutesFolderContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -28,14 +29,24 @@ interface Props { roles: DialRole[]; /** i18n keys for non-fatal problems from the server-side option reads, resolved here. */ optionWarnings?: EntitiesI18nKey[]; + /** True when `originalRoute` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; } -const RouteAssetView: FC = ({ etag, originalRoute, roles, optionWarnings }) => { +const RouteAssetView: FC = ({ etag, originalRoute, roles, optionWarnings, isConfigFileSource }) => { const t = useI18n(); const router = useRouter(); const { fetchFiles } = useRoutesFolder(); const { showNotification } = useNotification(); const getReqRef = useRef(useProtectedRequest()); + const { setEntityReadOnly } = useAppContext(); + + // Config-file entities have no write endpoint and no admin-backend "compare with Core" projection + // of their own (they already *are* Core's view) — see `config-file-entity-views`. + useEffect(() => { + setEntityReadOnly(!!isConfigFileSource); + return () => setEntityReadOnly(false); + }, [isConfigFileSource, setEntityReadOnly]); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [selectedRoute, setSelectedRoute] = useState(structuredClone(originalRoute)); @@ -47,8 +58,11 @@ const RouteAssetView: FC = ({ etag, originalRoute, roles, optionWarnings () => ({ isEditorEnabled, onToggleEditor: () => setIsEditorEnabled((prev) => !prev), + // A config-file-sourced entity has no admin-backend "compare with Core" projection of its own — + // it already is Core's own view — so the ADMIN|CORE format selector has nothing to switch to. + onHideFormatSelector: () => !!isConfigFileSource, }), - [isEditorEnabled], + [isEditorEnabled, isConfigFileSource], ); const tabs = useMemo( diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Routes/tests/PageList.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Routes/tests/PageList.spec.tsx new file mode 100644 index 0000000000..9b63207e5b --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Routes/tests/PageList.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { getConfigFileRoutes } from '@/src/app/[lang]/platform-routes/actions'; +import PlatformRoutesPageList from '../PageList'; + +vi.mock('@/src/app/[lang]/platform-routes/actions', () => ({ getConfigFileRoutes: vi.fn() })); +vi.mock('@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList', () => ({ + default: ({ names, route }: { names: string[]; route: string }) => ( +
+ config-file-routes:{names.length}:{route} +
+ ), +})); +vi.mock('../List', () => ({ default: () =>
asset-routes-list
})); + +const mockContext = { showConfigFiles: false }; +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ showConfigFiles: mockContext.showConfigFiles }), +})); + +describe('PlatformRoutesPageList', () => { + test('renders the asset list and issues no config-file fetch by default', () => { + mockContext.showConfigFiles = false; + + render(); + + expect(screen.getByText('asset-routes-list')).toBeTruthy(); + expect(getConfigFileRoutes).not.toHaveBeenCalled(); + }); + + test('renders the shared config-file list, for the Routes route, when the toggle is on', async () => { + mockContext.showConfigFiles = true; + vi.mocked(getConfigFileRoutes).mockResolvedValue({ + success: true, + data: ['r1'], + }); + + render(); + + expect(await screen.findByText('config-file-routes:1:/platform-routes')).toBeTruthy(); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Routes/tests/View.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Routes/tests/View.spec.tsx index e4b5b4e8ee..17168c372a 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Routes/tests/View.spec.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Routes/tests/View.spec.tsx @@ -13,21 +13,30 @@ vi.mock('@/src/app/[lang]/platform-routes/actions', () => ({ getRoutes: vi.fn().mockResolvedValue([]), })); +let capturedJsonConfiguration: any; vi.mock('@/src/components/EntityHeaderControls/SimpleHeader', () => ({ - default: ({ onSave, tabs }: any) => ( - <> - -
- - ), + default: ({ onSave, tabs, jsonConfiguration }: any) => { + capturedJsonConfiguration = jsonConfiguration; + return ( + <> + +
+ + ); + }, })); vi.mock('../TabsContent', () => ({ default: () =>
tabs-content
})); vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn() }) })); +const setEntityReadOnly = vi.fn(); +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ setEntityReadOnly }), +})); + const route = (overrides: Partial = {}): DialRouteResource => ({ name: 'my-route', @@ -70,4 +79,21 @@ describe('RouteAssetView', () => { const tabs = JSON.parse(container.querySelector('[data-tabs]')!.getAttribute('data-tabs')!); expect(tabs.find((tab: { id: string }) => tab.id === EntityViewTab.Roles).warning).toBe(expectedWarning); }); + + test('Should mark the entity read-only and hide the format selector when config-file-sourced', () => { + const { unmount } = render(); + + expect(setEntityReadOnly).toHaveBeenCalledWith(true); + expect(capturedJsonConfiguration?.onHideFormatSelector?.()).toBe(true); + + unmount(); + + expect(setEntityReadOnly).toHaveBeenLastCalledWith(false); + }); + + test('Should not mark the entity read-only for an admin-backed route', () => { + render(); + + expect(setEntityReadOnly).toHaveBeenCalledWith(false); + }); }); diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Toolsets/View.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Toolsets/View.tsx index 588fc602b5..1974eb9086 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Toolsets/View.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Toolsets/View.tsx @@ -39,6 +39,8 @@ interface Props { roles: DialRole[]; /** i18n keys for non-fatal problems from the server-side role-population read, resolved here. */ optionWarnings?: EntitiesI18nKey[]; + /** True when `originalToolset` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; } /** @@ -52,14 +54,28 @@ interface Props { * selector, no publish, no move) and which server actions get called differ here — mirrors * `Assets/Platform/Applications/View.tsx`. */ -const PlatformToolsetView: FC = ({ etag, oAuthCode, originalToolset, roles, optionWarnings }) => { +const PlatformToolsetView: FC = ({ + etag, + oAuthCode, + originalToolset, + roles, + optionWarnings, + isConfigFileSource, +}) => { const t = useI18n(); const router = useRouter(); - const { featureFlags } = useAppContext(); + const { featureFlags, setEntityReadOnly } = useAppContext(); const { fetchFiles } = useToolsetFolder(); const { showNotification } = useNotification(); const getReqRef = useRef(useProtectedRequest()); + // Config-file entities have no write endpoint and no admin-backend "compare with Core" projection + // of their own (they already *are* Core's view) — see `config-file-entity-views`. + useEffect(() => { + setEntityReadOnly(!!isConfigFileSource); + return () => setEntityReadOnly(false); + }, [isConfigFileSource, setEntityReadOnly]); + // An option list read from only one of Core's two populations is shown rather than withheld, so the // user has to be told the list is incomplete — otherwise a missing role reads as deleted. Mirrors // `PlatformApplicationView`'s identical handling of its own Interceptors/Roles option-list warnings. diff --git a/apps/ai-dial-admin/src/components/Assets/Platform/Toolsets/tests/View.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Platform/Toolsets/tests/View.spec.tsx index f6c3375c70..c7bc2f27eb 100644 --- a/apps/ai-dial-admin/src/components/Assets/Platform/Toolsets/tests/View.spec.tsx +++ b/apps/ai-dial-admin/src/components/Assets/Platform/Toolsets/tests/View.spec.tsx @@ -18,6 +18,14 @@ vi.mock('@/src/app/[lang]/assets-toolsets/actions', async (importOriginal) => ({ signOutToolset: vi.fn(), })); +const setEntityReadOnly = vi.fn(); +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ + setEntityReadOnly, + featureFlags: { deploymentsEnabled: true, adminApiEnabled: true }, + }), +})); + /** * Matches `Assets/Platform/Applications/tests/View.spec.tsx`'s scope: a render smoke test, not deep * interaction coverage — the component's real dependency surface (`TabsContent`, `SimpleEntityHeader`, @@ -75,4 +83,27 @@ describe('PlatformToolsetView', () => { const rolesTab = screen.getByRole('tab', { name: TabsI18nKey.Roles }); expect(!!rolesTab.querySelector('svg.tabler-icon-alert-triangle')).toBe(expectedWarning); }); + + test('Should mark the entity read-only when config-file-sourced, and clear it on unmount', () => { + const { unmount } = render( + , + ); + + expect(setEntityReadOnly).toHaveBeenCalledWith(true); + + unmount(); + + expect(setEntityReadOnly).toHaveBeenLastCalledWith(false); + }); + + test('Should not mark the entity read-only for an admin-backed toolset', () => { + render(); + + expect(setEntityReadOnly).toHaveBeenCalledWith(false); + }); }); diff --git a/apps/ai-dial-admin/src/components/Assets/Toolsets/PageList.tsx b/apps/ai-dial-admin/src/components/Assets/Toolsets/PageList.tsx new file mode 100644 index 0000000000..0207180daa --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Toolsets/PageList.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FC } from 'react'; + +import { getConfigFileToolsets } from '@/src/app/[lang]/assets-toolsets/actions'; +import ConfigFileEntityList from '@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList'; +import ConfigFileListSwap from '@/src/components/Common/ConfigFileListSwap/ConfigFileListSwap'; +import ConfigFilesToggle from '@/src/components/Common/ConfigFilesToggle/ConfigFilesToggle'; +import { ApplicationRoute } from '@/src/types/routes'; +import AssetToolsetsList from './List'; + +/** + * What `assets-toolsets/page.tsx` renders: the existing asset browser by default, swapped for the + * config-file-backed, names-only list when `showConfigFiles` is on — see `config-file-entity-views`. + */ +const AssetsToolsetsPageList: FC = () => ( + } + fetchConfigFileList={getConfigFileToolsets} + renderConfigFileList={(names) => ( + } /> + )} + /> +); + +export default AssetsToolsetsPageList; diff --git a/apps/ai-dial-admin/src/components/Assets/Toolsets/tests/PageList.spec.tsx b/apps/ai-dial-admin/src/components/Assets/Toolsets/tests/PageList.spec.tsx new file mode 100644 index 0000000000..9fb1cf6a63 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Assets/Toolsets/tests/PageList.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { getConfigFileToolsets } from '@/src/app/[lang]/assets-toolsets/actions'; +import AssetsToolsetsPageList from '../PageList'; + +vi.mock('@/src/app/[lang]/assets-toolsets/actions', () => ({ getConfigFileToolsets: vi.fn() })); +vi.mock('@/src/components/Common/ConfigFileEntityList/ConfigFileEntityList', () => ({ + default: ({ names, route }: { names: string[]; route: string }) => ( +
+ config-file-toolsets:{names.length}:{route} +
+ ), +})); +vi.mock('../List', () => ({ default: () =>
asset-toolsets-list
})); + +const mockContext = { showConfigFiles: false }; +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ showConfigFiles: mockContext.showConfigFiles }), +})); + +describe('AssetsToolsetsPageList', () => { + test('renders the asset list and issues no config-file fetch by default', () => { + mockContext.showConfigFiles = false; + + render(); + + expect(screen.getByText('asset-toolsets-list')).toBeTruthy(); + expect(getConfigFileToolsets).not.toHaveBeenCalled(); + }); + + test('renders the shared config-file list, for the Toolsets route, when the toggle is on', async () => { + mockContext.showConfigFiles = true; + vi.mocked(getConfigFileToolsets).mockResolvedValue({ + success: true, + data: ['t1'], + }); + + render(); + + expect(await screen.findByText('config-file-toolsets:1:/assets-toolsets')).toBeTruthy(); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Breadcrumbs/tests/utils.spec.ts b/apps/ai-dial-admin/src/components/Breadcrumbs/tests/utils.spec.ts index 103346fa5b..0ae1307a2b 100644 --- a/apps/ai-dial-admin/src/components/Breadcrumbs/tests/utils.spec.ts +++ b/apps/ai-dial-admin/src/components/Breadcrumbs/tests/utils.spec.ts @@ -30,6 +30,11 @@ describe('Breadcrumbs :: getBreadcrumbConfig with language in path', () => { expect(config.length).toEqual(0); }); + test('An App Runner detail route keeps its own list segment', () => { + const config = getBreadcrumbs('/application-runners/runner-1', 'en'); + expect(config[0].href).toEqual('/application-runners'); + }); + test('Should return empty array for home page', () => { const config = getBreadcrumbs('/home', 'en'); expect(config.length).toEqual(0); @@ -67,6 +72,11 @@ describe('Breadcrumbs :: getBreadcrumbConfig with language in path', () => { expect(shouldEnrichWithFolderBreadcrumbs('/en/platform-models/modelId', 'en')).toBeFalsy(); }); + test("Should keep the list breadcrumb pointed at the entity type's own route", () => { + const config = getBreadcrumbs('/en/models/modelId', 'en'); + expect(config[0].href).toEqual('/en/models'); + }); + test('Should translate runs compare segment via breadcrumb config', () => { const config = getBreadcrumbs('/en/runs/compare', 'en'); expect(config.length).toEqual(2); diff --git a/apps/ai-dial-admin/src/components/Breadcrumbs/utils.ts b/apps/ai-dial-admin/src/components/Breadcrumbs/utils.ts index 5efcffce87..0e8b58c0e3 100644 --- a/apps/ai-dial-admin/src/components/Breadcrumbs/utils.ts +++ b/apps/ai-dial-admin/src/components/Breadcrumbs/utils.ts @@ -41,13 +41,13 @@ export function getBreadcrumbs(pathname: string, currentLocale: string): Breadcr return pathSegments.map((pathSegment, index) => { const configSegment = config.segments[index]; const translated = TRANSLATE_BREADCRUMBS[pathSegment as keyof typeof TRANSLATE_BREADCRUMBS]; + const defaultHref = + configSegment.href !== false ? `/${[locale, ...pathSegments.slice(0, index + 1)].filter(Boolean).join('/')}` : ''; + return { key: translated ? (translated as unknown as MenuI18nKey) : configSegment.i18nKey, name: decodePathSegment(pathSegment), - href: - configSegment.href !== false - ? `/${[locale, ...pathSegments.slice(0, index + 1)].filter(Boolean).join('/')}` - : '', + href: defaultHref, }; }); } diff --git a/apps/ai-dial-admin/src/components/Common/ConfigFileEntityList/ConfigFileEntityList.tsx b/apps/ai-dial-admin/src/components/Common/ConfigFileEntityList/ConfigFileEntityList.tsx new file mode 100644 index 0000000000..b2f79e99a5 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Common/ConfigFileEntityList/ConfigFileEntityList.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { FC, ReactNode, useMemo } from 'react'; + +import BaseEntityList from '@/src/components/EntityListView/EntityListView'; +import { NAME_COLUMN_WITH_SORT } from '@/src/constants/grid-columns/base-columns'; +import { ApplicationRoute } from '@/src/types/routes'; + +interface Props { + names: string[]; + route: ApplicationRoute; + headerExtra?: ReactNode; +} + +/** + * The `config-file-entity-views` list swap's shared, name-only list — one component for all seven + * covered entity types instead of each rendering its own full-columns admin-grid list. Config-file + * entities have no write endpoint, so `onRemoveEntity` is unreachable: `isConfigFileSource` marks the + * entity read-only, which hides the remove/duplicate/move actions `BaseEntityList` would otherwise + * wire it to. + */ +const ConfigFileEntityList: FC = ({ names, route, headerExtra }) => { + const data = useMemo(() => names.map((name) => ({ name })), [names]); + + return ( + ({ success: false })} + isConfigFileSource + headerExtra={headerExtra} + /> + ); +}; + +export default ConfigFileEntityList; diff --git a/apps/ai-dial-admin/src/components/Common/ConfigFileEntityList/tests/ConfigFileEntityList.spec.tsx b/apps/ai-dial-admin/src/components/Common/ConfigFileEntityList/tests/ConfigFileEntityList.spec.tsx new file mode 100644 index 0000000000..87b3bc227c --- /dev/null +++ b/apps/ai-dial-admin/src/components/Common/ConfigFileEntityList/tests/ConfigFileEntityList.spec.tsx @@ -0,0 +1,40 @@ +import { render } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { NAME_COLUMN_WITH_SORT } from '@/src/constants/grid-columns/base-columns'; +import { ApplicationRoute } from '@/src/types/routes'; +import ConfigFileEntityList from '../ConfigFileEntityList'; + +let capturedProps: Record | undefined; +vi.mock('@/src/components/EntityListView/EntityListView', () => ({ + default: (props: Record) => { + capturedProps = props; + return
base-entity-list
; + }, +})); + +describe('ConfigFileEntityList', () => { + test('renders one row per name', () => { + render(); + + expect(capturedProps?.data).toEqual([{ name: 'first' }, { name: 'second' }]); + }); + + test('passes only the name column as base columns', () => { + render(); + + expect(capturedProps?.baseColumns).toEqual([NAME_COLUMN_WITH_SORT]); + }); + + test('marks the data as config-file-sourced, and forwards the route and headerExtra', () => { + const headerExtra =
toggle
; + render(); + + // BaseEntityList itself (EntityListView.spec.tsx) proves that isConfigFileSource=true threads + // CONFIG_FILE_URL_SUFFIX into the open-in-new-tab row action; this component's own responsibility + // is only to always pass that flag, asserted here. + expect(capturedProps?.isConfigFileSource).toBe(true); + expect(capturedProps?.route).toBe(ApplicationRoute.PlatformModels); + expect(capturedProps?.headerExtra).toBe(headerExtra); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Common/ConfigFileListSwap/ConfigFileListSwap.tsx b/apps/ai-dial-admin/src/components/Common/ConfigFileListSwap/ConfigFileListSwap.tsx new file mode 100644 index 0000000000..128ddfe5bf --- /dev/null +++ b/apps/ai-dial-admin/src/components/Common/ConfigFileListSwap/ConfigFileListSwap.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { ReactNode } from 'react'; + +import { useAppContext } from '@/src/context/AppContext'; +import { useConfigFileEntityList } from '@/src/hooks/use-config-file-entity-list'; +import { ConfigFileReadResult } from '@/src/models/dial/config-file'; +import { DialLoader } from '@epam/ai-dial-ui-kit'; + +interface Props { + /** The page's existing asset/platform list, rendered when `showConfigFiles` is off. */ + assetList: ReactNode; + fetchConfigFileList: () => Promise>; + /** Renders the shared config-file list (`ConfigFileEntityList`) with the fetched entity names. */ + renderConfigFileList: (names: string[]) => ReactNode; +} + +/** + * The `config-file-entity-views` list swap: one component shared by every covered view, so the + * toggle/fetch/render wiring is written once rather than re-derived per entity type. + */ +const ConfigFileListSwap = ({ assetList, fetchConfigFileList, renderConfigFileList }: Props) => { + const { showConfigFiles } = useAppContext(); + const { data, isLoading } = useConfigFileEntityList(showConfigFiles, fetchConfigFileList); + + return <>{showConfigFiles ? isLoading ? : renderConfigFileList(data) : assetList}; +}; + +export default ConfigFileListSwap; diff --git a/apps/ai-dial-admin/src/components/Common/ConfigFileListSwap/tests/ConfigFileListSwap.spec.tsx b/apps/ai-dial-admin/src/components/Common/ConfigFileListSwap/tests/ConfigFileListSwap.spec.tsx new file mode 100644 index 0000000000..9272e4437a --- /dev/null +++ b/apps/ai-dial-admin/src/components/Common/ConfigFileListSwap/tests/ConfigFileListSwap.spec.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import ConfigFileListSwap from '../ConfigFileListSwap'; + +const mockContext = { showConfigFiles: false }; + +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ showConfigFiles: mockContext.showConfigFiles }), +})); + +describe('ConfigFileListSwap', () => { + test('renders the asset list and issues no fetch when showConfigFiles is off', () => { + mockContext.showConfigFiles = false; + const fetchConfigFileList = vi.fn(); + + render( + asset-list
} + fetchConfigFileList={fetchConfigFileList} + renderConfigFileList={(data) =>
config-file-list:{data.length}
} + />, + ); + + expect(screen.getByText('asset-list')).toBeTruthy(); + expect(fetchConfigFileList).not.toHaveBeenCalled(); + }); + + test('fetches and renders the config-file list when showConfigFiles is on', async () => { + mockContext.showConfigFiles = true; + const fetchConfigFileList = vi.fn().mockResolvedValue({ + success: true, + data: ['a', 'b'], + }); + + render( + asset-list
} + fetchConfigFileList={fetchConfigFileList} + renderConfigFileList={(data) =>
config-file-list:{data.length}
} + />, + ); + + expect(await screen.findByText('config-file-list:2')).toBeTruthy(); + expect(fetchConfigFileList).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Common/ConfigFilesToggle/ConfigFilesToggle.tsx b/apps/ai-dial-admin/src/components/Common/ConfigFilesToggle/ConfigFilesToggle.tsx new file mode 100644 index 0000000000..501f9069c2 --- /dev/null +++ b/apps/ai-dial-admin/src/components/Common/ConfigFilesToggle/ConfigFilesToggle.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { FC } from 'react'; + +import { Switch } from '@epam/ai-dial-ui-kit'; + +import { BasicI18nKey } from '@/src/constants/i18n'; +import { useAppContext } from '@/src/context/AppContext'; +import { useI18n } from '@/src/locales/client'; + +/** + * Toggles `showConfigFiles`, swapping a covered view's asset/platform list for its config-file-backed + * admin-grid list. Renders only without the admin backend — with it, the admin-grid list is already + * the page's only list, so there is nothing to toggle to. + */ +const ConfigFilesToggle: FC = () => { + const t = useI18n(); + const { featureFlags, showConfigFiles, toggleShowConfigFiles } = useAppContext(); + + if (featureFlags.adminApiEnabled) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default ConfigFilesToggle; diff --git a/apps/ai-dial-admin/src/components/Common/ConfigFilesToggle/tests/ConfigFilesToggle.spec.tsx b/apps/ai-dial-admin/src/components/Common/ConfigFilesToggle/tests/ConfigFilesToggle.spec.tsx new file mode 100644 index 0000000000..53a53eb4fc --- /dev/null +++ b/apps/ai-dial-admin/src/components/Common/ConfigFilesToggle/tests/ConfigFilesToggle.spec.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, test, vi } from 'vitest'; + +import ConfigFilesToggle from '../ConfigFilesToggle'; + +const mockContext = { + adminApiEnabled: false, + showConfigFiles: false, + toggleShowConfigFiles: vi.fn(), +}; + +vi.mock('@/src/context/AppContext', () => ({ + useAppContext: () => ({ + featureFlags: { deploymentsEnabled: true, adminApiEnabled: mockContext.adminApiEnabled }, + showConfigFiles: mockContext.showConfigFiles, + toggleShowConfigFiles: mockContext.toggleShowConfigFiles, + }), +})); + +describe('ConfigFilesToggle', () => { + test('renders when the admin API is disabled', () => { + mockContext.adminApiEnabled = false; + + render(); + + expect(screen.getByRole('switch')).toBeTruthy(); + }); + + test('is absent when the admin API is enabled', () => { + mockContext.adminApiEnabled = true; + + render(); + + expect(screen.queryByRole('switch')).toBeNull(); + }); + + test('clicking it calls toggleShowConfigFiles', async () => { + mockContext.adminApiEnabled = false; + mockContext.toggleShowConfigFiles = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('switch')); + + expect(mockContext.toggleShowConfigFiles).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/ai-dial-admin/src/components/Common/FileManager/FileManager.tsx b/apps/ai-dial-admin/src/components/Common/FileManager/FileManager.tsx index 3fb30685b1..fb1e3cd840 100644 --- a/apps/ai-dial-admin/src/components/Common/FileManager/FileManager.tsx +++ b/apps/ai-dial-admin/src/components/Common/FileManager/FileManager.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FC, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DialCopiedItem, @@ -50,6 +50,8 @@ import { interface Props { view: ApplicationRoute; label: string; + /** Rendered alongside `label` in the manager's header — the `config-file-entity-views` toggle. */ + headerExtra?: ReactNode; columnDefs: ColDef[]; getContext: () => AssetsFolderContext; onCreateFolder?: ( @@ -86,6 +88,7 @@ interface Props { const FileManager: FC = ({ label, + headerExtra, columnDefs, view, getContext, @@ -145,8 +148,13 @@ const FileManager: FC = ({ }, [files]); const managerLabel = useMemo( - () =>

{label}

, - [label], + () => ( +
+

{label}

+ {headerExtra} +
+ ), + [label, headerExtra], ); // Applications is the one view with two top-level buckets (`platform`/`public` — see diff --git a/apps/ai-dial-admin/src/components/Containers/View/ContainerView.tsx b/apps/ai-dial-admin/src/components/Containers/View/ContainerView.tsx index 7014da8760..dac837c6bc 100644 --- a/apps/ai-dial-admin/src/components/Containers/View/ContainerView.tsx +++ b/apps/ai-dial-admin/src/components/Containers/View/ContainerView.tsx @@ -61,12 +61,12 @@ const ContainerView: FC = ({ const t = useI18n(); const router = useRouter(); const { showNotification } = useNotification(); - const { disableDeploymentsJSONEditor } = useAppContext(); + const { disableDeploymentsJSONEditor, featureFlags } = useAppContext(); const imageNotInstalled = isImageNotInstalled(image); const [tabs, setTabs] = useState( - getDeploymentsViewTabs(route, t, container.status, container.allowedDomains, imageNotInstalled), + getDeploymentsViewTabs(route, t, container.status, container.allowedDomains, imageNotInstalled, featureFlags), ); const [selectedContainer, setSelectedContainer] = useState(cloneDeep(container)); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); @@ -79,8 +79,10 @@ const ContainerView: FC = ({ const [pods, setPods] = useState([]); useEffect(() => { - setTabs(getDeploymentsViewTabs(route, t, container.status, container.allowedDomains, imageNotInstalled)); - }, [container.allowedDomains, container.status, imageNotInstalled, route, t]); + setTabs( + getDeploymentsViewTabs(route, t, container.status, container.allowedDomains, imageNotInstalled, featureFlags), + ); + }, [container.allowedDomains, container.status, imageNotInstalled, route, t, featureFlags]); const jsonConfiguration = useMemo( () => ({ diff --git a/apps/ai-dial-admin/src/components/EntityHeaderControls/SimpleHeader.tsx b/apps/ai-dial-admin/src/components/EntityHeaderControls/SimpleHeader.tsx index ba75082127..5577bf8fc5 100644 --- a/apps/ai-dial-admin/src/components/EntityHeaderControls/SimpleHeader.tsx +++ b/apps/ai-dial-admin/src/components/EntityHeaderControls/SimpleHeader.tsx @@ -7,6 +7,7 @@ import { TabModel } from '@epam/ai-dial-ui-kit'; import ReadonlyId from '@/src/components/BaseControls/Id/ReadonlyId'; import CoreSyncEntityStatus from '@/src/components/Common/SyncCoreStatus/SyncCoreStatus'; import Tabs from '@/src/components/EntityHeaderControls/Tabs/HeaderTabs'; +import { useAppContext } from '@/src/context/AppContext'; import { ApplicationRoute } from '@/src/types/routes'; import { getCoreSyncStatusUrl } from '@/src/utils/core-sync/get-core-sync-status-url'; import { getHeaderClassName } from '@/src/utils/entities/view'; @@ -40,12 +41,13 @@ const SimpleEntityHeader = ({ tabsTrailing, ...props }: Props) => { + const { featureFlags } = useAppContext(); const isEditorEnabled = jsonConfiguration?.isEditorEnabled; const readonlyId = props.view === ApplicationRoute.TestSuites || props.view === ApplicationRoute.Datasets ? props.entity.name || '' : props.entity.id || props.entity.$id || props.entity.name || ''; - const hasCoreSyncStatus = getCoreSyncStatusUrl(props.view, readonlyId) !== null; + const hasCoreSyncStatus = featureFlags.adminApiEnabled && getCoreSyncStatusUrl(props.view, readonlyId) !== null; return (
diff --git a/apps/ai-dial-admin/src/components/EntityListView/EntityListView.tsx b/apps/ai-dial-admin/src/components/EntityListView/EntityListView.tsx index f215a8d3d3..3dc3820944 100644 --- a/apps/ai-dial-admin/src/components/EntityListView/EntityListView.tsx +++ b/apps/ai-dial-admin/src/components/EntityListView/EntityListView.tsx @@ -1,13 +1,14 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useState } from 'react'; import { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'; import ListView from '@/src/components/ListView/ListView'; import { ENTITIES_COLUMNS } from '@/src/constants/grid-columns/grid-columns'; import { AssetsFolderContext } from '@/src/context/assets/AssetsFolderContext'; +import { useAppContext } from '@/src/context/AppContext'; import { useIsReadOnlyAdmin } from '@/src/hooks/use-is-read-only-admin'; import { useI18n } from '@/src/locales/client'; import { DialApplicationScheme } from '@/src/models/dial/application'; @@ -33,8 +34,15 @@ interface Props { onCreateEntity?: (entity: T) => Promise; onRemoveEntity: (entity: string) => Promise; getAssetContext?: () => AssetsFolderContext; + /** Rendered alongside the header buttons — the `config-file-entity-views` toggle. */ + headerExtra?: ReactNode; + /** True when `data` came from Core's config-file population rather than the admin backend. */ + isConfigFileSource?: boolean; } +/** `config-file-entity-views`: routes a config-file-sourced row to the same detail page, read-only. */ +const CONFIG_FILE_URL_SUFFIX = '?configFile=true'; + const BaseEntityList = ({ data, baseColumns, @@ -47,12 +55,24 @@ const BaseEntityList = ({ onRemoveEntity, showColumnsButton, getAssetContext, + headerExtra, + isConfigFileSource, }: Props) => { const t = useI18n(); const router = useRouter(); const isReadOnlyAdmin = useIsReadOnlyAdmin(); + const { setEntityReadOnly } = useAppContext(); + + // Config-file entities have no write endpoint — mirrors the `configFile=true` detail page's own + // read-only wiring so this list's existing isReadOnlyAdmin-gated create/remove/duplicate/move + // affordances disappear for free, with no separate read-only prop to check at each call site. + useEffect(() => { + setEntityReadOnly(!!isConfigFileSource); + return () => setEntityReadOnly(false); + }, [isConfigFileSource, setEntityReadOnly]); + const gridOptions: GridOptions = { - onCellClicked: (e) => onCellClicked(e, route, router.push), + onCellClicked: (e) => onCellClicked(e, route, router.push, isConfigFileSource ? CONFIG_FILE_URL_SUFFIX : undefined), }; // entity for which the modals (delete and duplicate) is open const [currentEntity, setCurrentEntity] = useState(void 0); @@ -99,9 +119,9 @@ const BaseEntityList = ({ const openInNewTab = useCallback( (entity?: T) => { - onOpenInNewTab(route, entity); + onOpenInNewTab(route, entity, isConfigFileSource ? CONFIG_FILE_URL_SUFFIX : undefined); }, - [route], + [route, isConfigFileSource], ); const closeColumnsPanel = useCallback(() => setShowColumnsPanel(false), [setShowColumnsPanel]); @@ -139,7 +159,8 @@ const BaseEntityList = ({ toggleColumnsPanel={toggleColumnsPanel} view={route} onGridReady={onGridReady} - getHref={(data) => getUrnForEntity(route, data)} + getHref={(data) => `${getUrnForEntity(route, data)}${isConfigFileSource ? CONFIG_FILE_URL_SUFFIX : ''}`} + headerExtra={headerExtra} > void) | undefined; + +vi.mock('@/src/components/Grid/GridView/GridView', () => ({ + default: () => null, +})); + +vi.mock('@/src/constants/grid-columns/grid-columns', () => ({ + ENTITIES_COLUMNS: (columns: unknown[], _remove: unknown, _duplicate: unknown, open?: (entity?: unknown) => void) => { + capturedOpen = open; + return columns; + }, +})); + +vi.mock('@/src/components/EntityListView/Components/Actions', () => ({ + default: () => null, +})); + +vi.mock('@/src/components/EntityListView/HeaderButtons/HeaderButtons', () => ({ + default: () => null, +})); + +describe('BaseEntityList — open in new tab', () => { + beforeEach(() => { + vi.mocked(useRouter).mockReturnValue({ push: vi.fn() } as unknown as ReturnType); + capturedOpen = undefined; + }); + + const renderList = (isConfigFileSource?: boolean) => + render( + , + ); + + test('opens the config-file query param when the row is config-file-sourced', () => { + const onOpenInNewTabSpy = vi.spyOn(openInNewTabUtils, 'onOpenInNewTab').mockImplementation(() => {}); + renderList(true); + + capturedOpen?.({ name: 'my-model' }); + + expect(onOpenInNewTabSpy).toHaveBeenCalledWith(ApplicationRoute.Models, { name: 'my-model' }, '?configFile=true'); + }); + + test('opens the bare route when the row is not config-file-sourced', () => { + const onOpenInNewTabSpy = vi.spyOn(openInNewTabUtils, 'onOpenInNewTab').mockImplementation(() => {}); + renderList(false); + + capturedOpen?.({ name: 'my-model' }); + + expect(onOpenInNewTabSpy).toHaveBeenCalledWith(ApplicationRoute.Models, { name: 'my-model' }, undefined); + }); +}); diff --git a/apps/ai-dial-admin/src/components/EntityListView/utils/on-cell-clicked.ts b/apps/ai-dial-admin/src/components/EntityListView/utils/on-cell-clicked.ts index bce4b2363b..cb5ec5818d 100644 --- a/apps/ai-dial-admin/src/components/EntityListView/utils/on-cell-clicked.ts +++ b/apps/ai-dial-admin/src/components/EntityListView/utils/on-cell-clicked.ts @@ -18,8 +18,13 @@ export const navigateEntityUrl = (url: string, push: (url: string) => void, even push(url); }; -export const onCellClicked = (e: CellClickedEvent, route: ApplicationRoute, push: (url: string) => void): void => { +export const onCellClicked = ( + e: CellClickedEvent, + route: ApplicationRoute, + push: (url: string) => void, + urlSuffix?: string, +): void => { if (e.colDef.field === ACTIONS_COLUMN_CEL_ID) return; const event = e.event as MouseEvent | undefined; - navigateEntityUrl(getUrnForEntity(route, e.data), push, event); + navigateEntityUrl(`${getUrnForEntity(route, e.data)}${urlSuffix ?? ''}`, push, event); }; diff --git a/apps/ai-dial-admin/src/components/EntityListView/utils/tests/on-cell-clicked.spec.ts b/apps/ai-dial-admin/src/components/EntityListView/utils/tests/on-cell-clicked.spec.ts index 6798248b94..3f56211a2a 100644 --- a/apps/ai-dial-admin/src/components/EntityListView/utils/tests/on-cell-clicked.spec.ts +++ b/apps/ai-dial-admin/src/components/EntityListView/utils/tests/on-cell-clicked.spec.ts @@ -54,4 +54,9 @@ describe('onCellClicked', () => { expect(push).not.toHaveBeenCalled(); expect(window.open).not.toHaveBeenCalled(); }); + + test('appends the url suffix when provided', () => { + onCellClicked(makeEvent(), route, push, '?configFile=true'); + expect(push).toHaveBeenCalledWith('/adapters/entity-1?configFile=true'); + }); }); diff --git a/apps/ai-dial-admin/src/components/Header/Header.spec.tsx b/apps/ai-dial-admin/src/components/Header/Header.spec.tsx index c902b0944b..127bc4170e 100644 --- a/apps/ai-dial-admin/src/components/Header/Header.spec.tsx +++ b/apps/ai-dial-admin/src/components/Header/Header.spec.tsx @@ -17,6 +17,7 @@ vi.mock('next-auth/react', () => ({ vi.mock('next/navigation', () => ({ usePathname: vi.fn(() => '/en/models'), + useSearchParams: vi.fn(() => new URLSearchParams()), })); describe('Header', () => { diff --git a/apps/ai-dial-admin/src/components/Images/View/ImageView.tsx b/apps/ai-dial-admin/src/components/Images/View/ImageView.tsx index f16eeff481..636b73c49d 100644 --- a/apps/ai-dial-admin/src/components/Images/View/ImageView.tsx +++ b/apps/ai-dial-admin/src/components/Images/View/ImageView.tsx @@ -34,7 +34,7 @@ const ImageView: FC = ({ image, containerNames, versions }) => { const t = useI18n(); const router = useRouter(); const { showNotification } = useNotification(); - const { disableDeploymentsJSONEditor } = useAppContext(); + const { disableDeploymentsJSONEditor, featureFlags } = useAppContext(); const [selectedImage, setSelectedImage] = useState(cloneDeep(image)); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); @@ -61,6 +61,8 @@ const ImageView: FC = ({ image, containerNames, versions }) => { t, selectedImage.buildStatus, selectedImage.allowedDomains, + undefined, + featureFlags, ); useEffect(() => { diff --git a/apps/ai-dial-admin/src/components/InterceptorTemplates/View/View.tsx b/apps/ai-dial-admin/src/components/InterceptorTemplates/View/View.tsx index 9037e4f187..f7deea8569 100644 --- a/apps/ai-dial-admin/src/components/InterceptorTemplates/View/View.tsx +++ b/apps/ai-dial-admin/src/components/InterceptorTemplates/View/View.tsx @@ -17,6 +17,7 @@ import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor' import { SOURCE_TYPE } from '@/src/components/SourceField/types'; import { ButtonsI18nKey, CreateI18nKey } from '@/src/constants/i18n'; import { BASE_BUTTON_ICON_PROPS } from '@/src/constants/main-layout'; +import { useAppContext } from '@/src/context/AppContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -48,8 +49,9 @@ const View: FC = ({ etag, template, names }) => { const [isModalOpen, setIsModalOpen] = useState(false); const [isEditorEnabled, setIsEditorEnabled] = useState(false); const [discardKey, setDiscardKey] = useState(0); + const { featureFlags } = useAppContext(); - const tabs = getInterceptorTemplateTabs(t); + const tabs = getInterceptorTemplateTabs(t, featureFlags); const jsonConfiguration = useMemo( () => ({ diff --git a/apps/ai-dial-admin/src/components/Interceptors/List/List.tsx b/apps/ai-dial-admin/src/components/Interceptors/List/List.tsx index 16601e8f62..80f69545ef 100644 --- a/apps/ai-dial-admin/src/components/Interceptors/List/List.tsx +++ b/apps/ai-dial-admin/src/components/Interceptors/List/List.tsx @@ -1,5 +1,5 @@ 'use client'; -import { FC, useMemo } from 'react'; +import { FC, ReactNode, useMemo } from 'react'; import { createInterceptor, removeInterceptor } from '@/src/app/[lang]/interceptors/actions'; import BaseEntityList from '@/src/components/EntityListView/EntityListView'; @@ -9,9 +9,12 @@ import { DialInterceptor } from '@/src/models/dial/interceptor'; import { ApplicationRoute } from '@/src/types/routes'; interface Props { data: DialInterceptor[]; + /** True when `data` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; + headerExtra?: ReactNode; } -const InterceptorsList: FC = ({ data }) => { +const InterceptorsList: FC = ({ data, isConfigFileSource, headerExtra }) => { const t = useI18n(); const columns = useMemo(() => INTERCEPTORS_COLUMNS(t), [t]); @@ -24,6 +27,8 @@ const InterceptorsList: FC = ({ data }) => { onCreateEntity={createInterceptor} onRemoveEntity={removeInterceptor} showColumnsButton + isConfigFileSource={isConfigFileSource} + headerExtra={headerExtra} /> ); }; diff --git a/apps/ai-dial-admin/src/components/Interceptors/View/View.tsx b/apps/ai-dial-admin/src/components/Interceptors/View/View.tsx index 3ec02b37c3..1099df5a70 100644 --- a/apps/ai-dial-admin/src/components/Interceptors/View/View.tsx +++ b/apps/ai-dial-admin/src/components/Interceptors/View/View.tsx @@ -14,6 +14,7 @@ import { import { JsonConfiguration } from '@/src/components/EntityHeaderControls/models'; import SimpleEntityHeader from '@/src/components/EntityHeaderControls/SimpleHeader'; import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor'; +import { useAppContext } from '@/src/context/AppContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -46,8 +47,9 @@ const InterceptorView: FC = ({ originalInterceptor, names, etag, ...props const { showNotification } = useNotification(); const { dispatch } = useSaveValidationContext(); const getReqRef = useRef(useProtectedRequest()); + const { featureFlags } = useAppContext(); - const tabs: TabModel[] = getInterceptorTabs(t); + const tabs: TabModel[] = getInterceptorTabs(t, featureFlags); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [selectedInterceptor, setSelectedInterceptor] = useState(structuredClone(originalInterceptor)); diff --git a/apps/ai-dial-admin/src/components/Keys/View/View.tsx b/apps/ai-dial-admin/src/components/Keys/View/View.tsx index 5a9b5d4a77..e0c1991d97 100644 --- a/apps/ai-dial-admin/src/components/Keys/View/View.tsx +++ b/apps/ai-dial-admin/src/components/Keys/View/View.tsx @@ -13,6 +13,7 @@ import SimpleEntityHeader from '@/src/components/EntityHeaderControls/SimpleHead import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor'; import { ButtonsI18nKey, KeysI18nKey } from '@/src/constants/i18n'; import { BASE_BUTTON_ICON_PROPS } from '@/src/constants/main-layout'; +import { useAppContext } from '@/src/context/AppContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useIsReadOnlyAdmin } from '@/src/hooks/use-is-read-only-admin'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -43,7 +44,8 @@ const KeyView: FC = ({ originalKey, etag, ...props }) => { const router = useRouter(); const { showNotification } = useNotification(); const getReqRef = useRef(useProtectedRequest()); - const tabs = getKeyTabs(t); + const { featureFlags } = useAppContext(); + const tabs = getKeyTabs(t, featureFlags); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [isOpenConfirmModal, setIsOpenConfirmModal] = useState(false); diff --git a/apps/ai-dial-admin/src/components/ListView/ListView.tsx b/apps/ai-dial-admin/src/components/ListView/ListView.tsx index 15b9e71610..6e5da03f46 100644 --- a/apps/ai-dial-admin/src/components/ListView/ListView.tsx +++ b/apps/ai-dial-admin/src/components/ListView/ListView.tsx @@ -22,6 +22,7 @@ interface Props { onGridReady?: (gridApi: GridReadyEvent) => void; allowPadding?: boolean; getHref?: (data: unknown) => string | undefined; + headerExtra?: ReactNode; } const ListView = ({ @@ -39,11 +40,17 @@ const ListView = ({ onGridReady, allowPadding = true, getHref, + headerExtra, }: Props) => { return (
-
- {title &&

{title}

} +
+ {title && ( +
+

{title}

+ {headerExtra} +
+ )} {children}
diff --git a/apps/ai-dial-admin/src/components/Models/List/List.tsx b/apps/ai-dial-admin/src/components/Models/List/List.tsx index 934588ff1f..748f6ff534 100644 --- a/apps/ai-dial-admin/src/components/Models/List/List.tsx +++ b/apps/ai-dial-admin/src/components/Models/List/List.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FC, useMemo } from 'react'; +import { FC, ReactNode, useMemo } from 'react'; import { createModel, removeModel } from '@/src/app/[lang]/models/actions'; import BaseEntityList from '@/src/components/EntityListView/EntityListView'; @@ -12,9 +12,12 @@ import { filterDisplayNamesWithVersions } from '@/src/utils/entities/filter-name interface Props { data: DialModel[]; + /** True when `data` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; + headerExtra?: ReactNode; } -const ModelsList: FC = ({ data }) => { +const ModelsList: FC = ({ data, isConfigFileSource, headerExtra }) => { const names = filterDisplayNamesWithVersions(data); const t = useI18n(); @@ -29,6 +32,8 @@ const ModelsList: FC = ({ data }) => { onCreateEntity={createModel} onRemoveEntity={removeModel} showColumnsButton={true} + isConfigFileSource={isConfigFileSource} + headerExtra={headerExtra} /> ); }; diff --git a/apps/ai-dial-admin/src/components/Models/View/View.tsx b/apps/ai-dial-admin/src/components/Models/View/View.tsx index 1f6ef0f35a..e9482fffe2 100644 --- a/apps/ai-dial-admin/src/components/Models/View/View.tsx +++ b/apps/ai-dial-admin/src/components/Models/View/View.tsx @@ -12,6 +12,7 @@ import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor' import { ModalType } from '@/src/components/EntityView/Modals/constants'; import EntityViewModals from '@/src/components/EntityView/Modals/EntityViewModals'; import { isDisableRole } from '@/src/components/EntityView/Roles/utils'; +import { useAppContext } from '@/src/context/AppContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext'; import { useI18n } from '@/src/locales/client'; @@ -38,8 +39,9 @@ interface Props { const View: FC = ({ originalModel, etag, ...props }) => { const t = useI18n(); const { dispatch } = useSaveValidationContext(); + const { featureFlags } = useAppContext(); - const tabs = getModelsTabs(t); + const tabs = getModelsTabs(t, featureFlags); const router = useRouter(); const { showNotification } = useNotification(); diff --git a/apps/ai-dial-admin/src/components/Roles/List/List.tsx b/apps/ai-dial-admin/src/components/Roles/List/List.tsx index 3ee7ac6c14..d8c5ad8de8 100644 --- a/apps/ai-dial-admin/src/components/Roles/List/List.tsx +++ b/apps/ai-dial-admin/src/components/Roles/List/List.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FC } from 'react'; +import { FC, ReactNode } from 'react'; import { createRole, removeRole } from '@/src/app/[lang]/roles/actions'; import { BASE_COLUMNS_WITH_TOPICS } from '@/src/constants/grid-columns/grid-columns'; @@ -11,9 +11,12 @@ import { filterNames } from '@/src/utils/entities/filter-names'; interface Props { data: DialRole[]; + /** True when `data` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; + headerExtra?: ReactNode; } -const RolesList: FC = ({ data }) => { +const RolesList: FC = ({ data, isConfigFileSource, headerExtra }) => { const names = filterNames(data); return ( @@ -24,6 +27,8 @@ const RolesList: FC = ({ data }) => { route={ApplicationRoute.Roles} onCreateEntity={createRole} onRemoveEntity={removeRole} + isConfigFileSource={isConfigFileSource} + headerExtra={headerExtra} /> ); }; diff --git a/apps/ai-dial-admin/src/components/Roles/View/View.tsx b/apps/ai-dial-admin/src/components/Roles/View/View.tsx index 33cb02c3d8..ebbb5aea38 100644 --- a/apps/ai-dial-admin/src/components/Roles/View/View.tsx +++ b/apps/ai-dial-admin/src/components/Roles/View/View.tsx @@ -7,6 +7,7 @@ import { getCoreRole, removeRole, updateCoreRole, updateRole } from '@/src/app/[ import { JsonConfiguration } from '@/src/components/EntityHeaderControls/models'; import SimpleEntityHeader from '@/src/components/EntityHeaderControls/SimpleHeader'; import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor'; +import { useAppContext } from '@/src/context/AppContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -42,8 +43,9 @@ const RolesView: FC = ({ originalRole, etag, keys, ...props }) => { const getReqRef = useRef(useProtectedRequest()); const { showNotification } = useNotification(); const { dispatch } = useSaveValidationContext(); + const { featureFlags } = useAppContext(); - const tabs = getRoleTabs(t); + const tabs = getRoleTabs(t, featureFlags); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [selectedRole, setSelectedRole] = useState(structuredClone(originalRole)); diff --git a/apps/ai-dial-admin/src/components/Routes/List/RoutesList.tsx b/apps/ai-dial-admin/src/components/Routes/List/RoutesList.tsx index 75d84ee6a7..fd0719bd7f 100644 --- a/apps/ai-dial-admin/src/components/Routes/List/RoutesList.tsx +++ b/apps/ai-dial-admin/src/components/Routes/List/RoutesList.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FC } from 'react'; +import { FC, ReactNode } from 'react'; import { createRoute, removeRoute } from '@/src/app/[lang]/routes/actions'; import { ROUTES_COLUMNS } from '@/src/constants/grid-columns/grid-columns'; @@ -10,9 +10,12 @@ import { ApplicationRoute } from '@/src/types/routes'; interface Props { data: DialRoute[]; + /** True when `data` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; + headerExtra?: ReactNode; } -const RoutesList: FC = ({ data }) => { +const RoutesList: FC = ({ data, isConfigFileSource, headerExtra }) => { return ( = ({ data }) => { onCreateEntity={createRoute} onRemoveEntity={removeRoute} showColumnsButton + isConfigFileSource={isConfigFileSource} + headerExtra={headerExtra} /> ); }; diff --git a/apps/ai-dial-admin/src/components/Routes/View/View.tsx b/apps/ai-dial-admin/src/components/Routes/View/View.tsx index 7af9629092..8ed9d11016 100644 --- a/apps/ai-dial-admin/src/components/Routes/View/View.tsx +++ b/apps/ai-dial-admin/src/components/Routes/View/View.tsx @@ -9,6 +9,7 @@ import { getCoreRoute, removeRoute, updateCoreRoute, updateRoute } from '@/src/a import { JsonConfiguration } from '@/src/components/EntityHeaderControls/models'; import SimpleEntityHeader from '@/src/components/EntityHeaderControls/SimpleHeader'; import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor'; +import { useAppContext } from '@/src/context/AppContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -36,7 +37,8 @@ const RouteView: FC = ({ originalRoute, etag, names, roles }) => { const { dispatch } = useSaveValidationContext(); const { showNotification } = useNotification(); const getReqRef = useRef(useProtectedRequest()); - const tabs = getRouteTabs(t); + const { featureFlags } = useAppContext(); + const tabs = getRouteTabs(t, featureFlags); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [selectedRoute, setSelectedRoute] = useState(cloneDeep(originalRoute)); diff --git a/apps/ai-dial-admin/src/components/Toolsets/List.tsx b/apps/ai-dial-admin/src/components/Toolsets/List.tsx index 7873006f71..a71e88e39b 100644 --- a/apps/ai-dial-admin/src/components/Toolsets/List.tsx +++ b/apps/ai-dial-admin/src/components/Toolsets/List.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FC, useMemo } from 'react'; +import { FC, ReactNode, useMemo } from 'react'; import { createToolset, removeToolset } from '@/src/app/[lang]/toolsets/actions'; import BaseEntityList from '@/src/components/EntityListView/EntityListView'; @@ -12,9 +12,12 @@ import { filterDisplayNames } from '@/src/utils/entities/filter-names'; interface Props { data: Toolset[]; + /** True when `data` came from Core's config-file population (`config-file-entity-views`), not the admin backend. */ + isConfigFileSource?: boolean; + headerExtra?: ReactNode; } -const ToolsetsList: FC = ({ data }) => { +const ToolsetsList: FC = ({ data, isConfigFileSource, headerExtra }) => { const t = useI18n(); const names = filterDisplayNames(data); @@ -28,6 +31,8 @@ const ToolsetsList: FC = ({ data }) => { onCreateEntity={createToolset} onRemoveEntity={removeToolset} showColumnsButton={true} + isConfigFileSource={isConfigFileSource} + headerExtra={headerExtra} /> ); }; diff --git a/apps/ai-dial-admin/src/components/Toolsets/View/View.tsx b/apps/ai-dial-admin/src/components/Toolsets/View/View.tsx index 554ce9a7e9..da19072b67 100644 --- a/apps/ai-dial-admin/src/components/Toolsets/View/View.tsx +++ b/apps/ai-dial-admin/src/components/Toolsets/View/View.tsx @@ -17,6 +17,7 @@ import EntityJsonEditor from '@/src/components/EntityTabs/JsonEditor/JsonEditor' import { ModalType } from '@/src/components/EntityView/Modals/constants'; import EntityViewModals from '@/src/components/EntityView/Modals/EntityViewModals'; import { isDisableRole } from '@/src/components/EntityView/Roles/utils'; +import { useAppContext } from '@/src/context/AppContext'; import { useNotification } from '@/src/context/NotificationContext'; import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext'; import { useProtectedRequest } from '@/src/hooks/use-protected-request'; @@ -47,8 +48,9 @@ const ToolsetView: FC = ({ names, oAuthCode, etag, roles, originalToolset const { showNotification } = useNotification(); const { dispatch } = useSaveValidationContext(); const getReqRef = useRef(useProtectedRequest()); + const { featureFlags } = useAppContext(); - const tabs = getToolsetTabs(t); + const tabs = getToolsetTabs(t, featureFlags); const [activeTab, setActiveTab] = useState(EntityViewTab.Properties); const [isModalOpen, setIsModalOpen] = useState(false); diff --git a/apps/ai-dial-admin/src/constants/config-file-core.ts b/apps/ai-dial-admin/src/constants/config-file-core.ts index 2152476bf4..455759b879 100644 --- a/apps/ai-dial-admin/src/constants/config-file-core.ts +++ b/apps/ai-dial-admin/src/constants/config-file-core.ts @@ -11,13 +11,26 @@ export const CORE_CONFIG_FILE_URL = 'v1/admin/config/file'; * for every caller — the file map's keys are themselves the secrets. Deriving the supported set from * the route pattern instead would make the client appear to support it and fail only at runtime. * - * The remaining types are limited to the ones a picker on an asset surface actually needs; widening - * this set is a deliberate act, not a side effect of adding an enum member. + * `Models`, `Routes`, `Applications`, and `Toolsets` were added for `config-file-entity-views` — the + * config-file-backed admin-grid list/detail surface for those types. Widening this set is a + * deliberate act, not a side effect of adding an enum member. + * + * `Schemas` was added for the same surface's seventh covered type, App Runners: Core's `schemas` + * config-file type is that entity's file-sourced population (confirmed against Core's + * `FileConfigController` — `listFileConfigSchemas` is documented as "file-sourced application type + * schemas", the same term App Runners uses for itself, and `entitySource()` resolves it to + * `config.getApplicationTypeSchemas()`). `CatalogSchemas` remains excluded — it is a distinct, + * unrelated map (`config.getCatalogSchemas()`) with no admin-console surface of its own. */ export const READABLE_CONFIG_FILE_TYPES: ReadonlySet = new Set([ ConfigFileEntityType.Interceptors, ConfigFileEntityType.Roles, ConfigFileEntityType.Settings, + ConfigFileEntityType.Models, + ConfigFileEntityType.Routes, + ConfigFileEntityType.Applications, + ConfigFileEntityType.Toolsets, + ConfigFileEntityType.Schemas, ]); /** The single settings entry Core exposes — `settings` is a singleton, not a listable collection. */ diff --git a/apps/ai-dial-admin/src/constants/config-file-entity-views.ts b/apps/ai-dial-admin/src/constants/config-file-entity-views.ts new file mode 100644 index 0000000000..926b537389 --- /dev/null +++ b/apps/ai-dial-admin/src/constants/config-file-entity-views.ts @@ -0,0 +1,18 @@ +import { ApplicationRoute } from '@/src/types/routes'; + +/** + * The seven views `config-file-entity-views` covers. Keys is excluded because Core refuses the + * config-file `keys` route unconditionally. App Runners was excluded by the same reasoning until + * this was confirmed against Core's `FileConfigController` source: App Runners' config-file + * population is `ConfigFileEntityType.Schemas` (`schemas`) — Core's own term for the type + * (`listFileConfigSchemas`, "file-sourced application type schemas"), not a distinct absence. + */ +export const CONFIG_FILE_ENTITY_VIEWS: ReadonlySet = new Set([ + ApplicationRoute.PlatformModels, + ApplicationRoute.PlatformInterceptors, + ApplicationRoute.PlatformRoutes, + ApplicationRoute.PlatformRoles, + ApplicationRoute.PlatformAppRunners, + ApplicationRoute.AssetsApplications, + ApplicationRoute.AssetsToolsets, +]); diff --git a/apps/ai-dial-admin/src/constants/i18n.ts b/apps/ai-dial-admin/src/constants/i18n.ts index e5b1bf01a6..1aeec56133 100644 --- a/apps/ai-dial-admin/src/constants/i18n.ts +++ b/apps/ai-dial-admin/src/constants/i18n.ts @@ -142,6 +142,7 @@ export enum BasicI18nKey { NoParameters = 'Basic.NoParameters', NoVariables = 'Basic.NoVariables', NoHeaders = 'Basic.NoHeaders', + ShowConfigFiles = 'Basic.ShowConfigFiles', Key = 'Basic.Key', Value = 'Basic.Value', From = 'Basic.From', diff --git a/apps/ai-dial-admin/src/constants/main-layout.ts b/apps/ai-dial-admin/src/constants/main-layout.ts index a4915a63ae..179d8f0287 100644 --- a/apps/ai-dial-admin/src/constants/main-layout.ts +++ b/apps/ai-dial-admin/src/constants/main-layout.ts @@ -3,6 +3,7 @@ export const CENTRAL_WINDOW_MIN_WIDTH = 800; // local storage keys export const LOCAL_STORAGE_CENTRAL_WINDOW_KEY = 'central-window-width'; export const LOCAL_STORAGE_SIDEBAR_OPEN_KEY = 'sidebar-open'; +export const LOCAL_STORAGE_SHOW_CONFIG_FILES_KEY = 'show-config-files'; // icons export const BASE_BUTTON_ICON_SIZE = 20; diff --git a/apps/ai-dial-admin/src/context/AppContext.tsx b/apps/ai-dial-admin/src/context/AppContext.tsx index f0c702bee5..9c7279eef3 100644 --- a/apps/ai-dial-admin/src/context/AppContext.tsx +++ b/apps/ai-dial-admin/src/context/AppContext.tsx @@ -4,7 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode, MouseEvent, import { VisualizerConnector } from '@epam/ai-dial-visualizer-connector'; import { getFromLocalStorage, setToLocalStorage } from '@/src/utils/local-storage'; -import { LOCAL_STORAGE_SIDEBAR_OPEN_KEY } from '@/src/constants/main-layout'; +import { LOCAL_STORAGE_SHOW_CONFIG_FILES_KEY, LOCAL_STORAGE_SIDEBAR_OPEN_KEY } from '@/src/constants/main-layout'; import { ResourcesDefaults } from '@/src/models/deployments/containers'; import { UserInfo, UserRole } from '@/src/models/user-info'; import { FeatureFlags } from '@/src/models/feature-flags'; @@ -14,6 +14,9 @@ export interface AppContextType { themeUrl?: string; sidebarOpen: boolean; toggleSidebar: (e?: MouseEvent) => void; + /** Whether the config-file-backed admin-grid list/detail views are shown in place of the asset browser. */ + showConfigFiles: boolean; + toggleShowConfigFiles: () => void; userMenuOpen: boolean; toggleUserMenu: () => void; visualizerConnector?: VisualizerConnector | null; @@ -36,6 +39,11 @@ export interface AppContextType { isFullAdmin: boolean; /** Whether authentication is enabled (NEXTAUTH_URL set). Needed to tell "auth off" from "no role". */ isEnableAuth: boolean; + /** + * Set by a `configFile=true` detail view on mount (cleared on unmount) to make `isReadOnlyAdmin` + * true for the duration of viewing a config-file-sourced entity — see `config-file-entity-views`. + */ + setEntityReadOnly: (isReadOnly: boolean) => void; } interface AppContextSidebar { @@ -73,6 +81,8 @@ export const AppContextProvider = ({ isEnableAuth?: boolean; }) => { const [sidebarOpen, setSidebarOpen] = useState(true); + const [showConfigFiles, setShowConfigFiles] = useState(false); + const [isEntityReadOnly, setEntityReadOnly] = useState(false); useEffect(() => { const stored = getFromLocalStorage(LOCAL_STORAGE_SIDEBAR_OPEN_KEY); @@ -81,6 +91,13 @@ export const AppContextProvider = ({ } }, []); + useEffect(() => { + const stored = getFromLocalStorage(LOCAL_STORAGE_SHOW_CONFIG_FILES_KEY); + if (stored === 'true') { + setShowConfigFiles(true); + } + }, []); + const [userMenuOpen, setUserMenuOpen] = useState(false); const [visualizerConnector, setVisualizerConnector] = useState(null); const [show, setShow] = useState(false); @@ -96,6 +113,11 @@ export const AppContextProvider = ({ setSidebarOpen(!sidebarOpen); }; + const toggleShowConfigFiles = () => { + setToLocalStorage(LOCAL_STORAGE_SHOW_CONFIG_FILES_KEY, String(!showConfigFiles)); + setShowConfigFiles(!showConfigFiles); + }; + const toggleUserMenu = () => { setUserMenuOpen(!userMenuOpen); }; @@ -118,11 +140,13 @@ export const AppContextProvider = ({ }; // Without the admin backend there's no FULL_ADMIN/READ_ONLY_ADMIN to read — nothing is - // enforced, so treat every caller as a full admin. + // enforced, so treat every caller as a full admin. `isEntityReadOnly` folds in on top: a + // `configFile=true` detail view sets it for the entity it's viewing, regardless of role. const isReadOnlyAdmin = - featureFlags.adminApiEnabled && - !!userInfo?.roles?.includes(UserRole.READ_ONLY_ADMIN) && - !userInfo?.roles?.includes(UserRole.FULL_ADMIN); + isEntityReadOnly || + (featureFlags.adminApiEnabled && + !!userInfo?.roles?.includes(UserRole.READ_ONLY_ADMIN) && + !userInfo?.roles?.includes(UserRole.FULL_ADMIN)); // Auth off → nothing is enforced, so treat as full admin; otherwise only a mapped FULL_ADMIN. const isFullAdmin = @@ -131,6 +155,8 @@ export const AppContextProvider = ({ const value = { sidebarOpen, toggleSidebar, + showConfigFiles, + toggleShowConfigFiles, themeUrl, userMenuOpen, toggleUserMenu, @@ -155,6 +181,7 @@ export const AppContextProvider = ({ isReadOnlyAdmin, isFullAdmin, isEnableAuth, + setEntityReadOnly, }; return {children}; diff --git a/apps/ai-dial-admin/src/context/tests/AppContext.spec.tsx b/apps/ai-dial-admin/src/context/tests/AppContext.spec.tsx new file mode 100644 index 0000000000..5d114b20a8 --- /dev/null +++ b/apps/ai-dial-admin/src/context/tests/AppContext.spec.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FC } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { FeatureFlags } from '@/src/models/feature-flags'; + +const { AppContextProvider, useAppContext }: typeof import('@/src/context/AppContext') = + await vi.importActual('@/src/context/AppContext'); + +const FEATURE_FLAGS: FeatureFlags = { deploymentsEnabled: true, adminApiEnabled: true }; + +const Harness: FC = () => { + const { showConfigFiles, toggleShowConfigFiles, isReadOnlyAdmin, setEntityReadOnly } = useAppContext(); + + return ( + <> + {`showConfigFiles:${showConfigFiles}`} + {`isReadOnlyAdmin:${isReadOnlyAdmin}`} + + + + + ); +}; + +const renderHarness = () => + render( + + + , + ); + +describe('AppContextProvider — showConfigFiles', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('defaults to false with no prior stored value', () => { + renderHarness(); + + expect(screen.getByText('showConfigFiles:false')).toBeTruthy(); + }); + + test('toggling flips the value and persists it to localStorage', async () => { + const user = userEvent.setup(); + renderHarness(); + + await user.click(screen.getByRole('button', { name: 'toggle' })); + + expect(screen.getByText('showConfigFiles:true')).toBeTruthy(); + expect(localStorage.getItem('show-config-files')).toBe('true'); + }); + + test('reads a previously stored true value on mount', () => { + localStorage.setItem('show-config-files', 'true'); + + renderHarness(); + + expect(screen.getByText('showConfigFiles:true')).toBeTruthy(); + }); +}); + +describe('AppContextProvider — setEntityReadOnly', () => { + test('folds into isReadOnlyAdmin regardless of role/feature-flag state', async () => { + const user = userEvent.setup(); + renderHarness(); + + expect(screen.getByText('isReadOnlyAdmin:false')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'make-read-only' })); + + expect(screen.getByText('isReadOnlyAdmin:true')).toBeTruthy(); + }); + + test('clearing it restores the prior computation', async () => { + const user = userEvent.setup(); + renderHarness(); + + await user.click(screen.getByRole('button', { name: 'make-read-only' })); + await user.click(screen.getByRole('button', { name: 'clear-read-only' })); + + expect(screen.getByText('isReadOnlyAdmin:false')).toBeTruthy(); + }); +}); diff --git a/apps/ai-dial-admin/src/hooks/tests/use-config-file-entity-list.spec.ts b/apps/ai-dial-admin/src/hooks/tests/use-config-file-entity-list.spec.ts new file mode 100644 index 0000000000..73227d4538 --- /dev/null +++ b/apps/ai-dial-admin/src/hooks/tests/use-config-file-entity-list.spec.ts @@ -0,0 +1,59 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { ConfigFileFailureReason } from '@/src/types/config-file-entity'; +import { useConfigFileEntityList } from '@/src/hooks/use-config-file-entity-list'; + +describe('useConfigFileEntityList', () => { + test('issues no fetch while showConfigFiles is false', () => { + const fetchList = vi.fn(); + + renderHook(() => useConfigFileEntityList(false, fetchList)); + + expect(fetchList).not.toHaveBeenCalled(); + }); + + test('fetches once when showConfigFiles becomes true, and populates data', async () => { + const fetchList = vi.fn().mockResolvedValue({ success: true, data: ['a'] }); + + const { result, rerender } = renderHook(({ show }) => useConfigFileEntityList(show, fetchList), { + initialProps: { show: false }, + }); + + rerender({ show: true }); + + await waitFor(() => expect(result.current.data).toEqual(['a'])); + expect(fetchList).toHaveBeenCalledOnce(); + }); + + test('does not re-fetch on a later toggle-off/toggle-on', async () => { + const fetchList = vi.fn().mockResolvedValue({ success: true, data: [] }); + + const { rerender } = renderHook(({ show }) => useConfigFileEntityList(show, fetchList), { + initialProps: { show: false }, + }); + + rerender({ show: true }); + await waitFor(() => expect(fetchList).toHaveBeenCalledOnce()); + rerender({ show: false }); + rerender({ show: true }); + + expect(fetchList).toHaveBeenCalledOnce(); + }); + + test('leaves data empty on a failed read rather than throwing', async () => { + const fetchList = vi.fn().mockResolvedValue({ + success: false, + failure: { reason: ConfigFileFailureReason.RequestFailed }, + }); + + const { result, rerender } = renderHook(({ show }) => useConfigFileEntityList(show, fetchList), { + initialProps: { show: false }, + }); + + rerender({ show: true }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual([]); + }); +}); diff --git a/apps/ai-dial-admin/src/hooks/use-config-file-entity-list.ts b/apps/ai-dial-admin/src/hooks/use-config-file-entity-list.ts new file mode 100644 index 0000000000..7502ebbcd0 --- /dev/null +++ b/apps/ai-dial-admin/src/hooks/use-config-file-entity-list.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState } from 'react'; + +import { ConfigFileReadResult } from '@/src/models/dial/config-file'; + +interface ConfigFileEntityListState { + data: string[]; + isLoading: boolean; +} + +/** + * Fetches a config-file entity type's names the first time `showConfigFiles` becomes `true`, and + * never again for the lifetime of this hook instance — the `config-file-entity-views` list-swap is + * meant to cost nothing until a user actually opts into it, and re-fetching on every toggle-off/toggle-on + * would defeat that. A failed read leaves `data` empty rather than throwing; the caller's own list + * component renders its normal empty state. + */ +export const useConfigFileEntityList = ( + showConfigFiles: boolean, + fetchList: () => Promise>, +): ConfigFileEntityListState => { + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const hasFetchedRef = useRef(false); + + useEffect(() => { + if (!showConfigFiles || hasFetchedRef.current) { + return; + } + hasFetchedRef.current = true; + setIsLoading(true); + + fetchList() + .then((result) => { + if (result.success) { + setData(result.data); + } + }) + .finally(() => setIsLoading(false)); + // `fetchList` is a fresh closure per render from its caller — only `showConfigFiles` should + // re-trigger this, and `hasFetchedRef` already prevents a second run once it has. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showConfigFiles]); + + return { data, isLoading }; +}; diff --git a/apps/ai-dial-admin/src/locales/en.ts b/apps/ai-dial-admin/src/locales/en.ts index 5690fd4e8c..3153bdee76 100644 --- a/apps/ai-dial-admin/src/locales/en.ts +++ b/apps/ai-dial-admin/src/locales/en.ts @@ -66,6 +66,7 @@ export default { NoParameters: 'No Parameters', NoVariables: 'No Variables', NoHeaders: 'No Headers', + ShowConfigFiles: 'Show config files', Key: 'Key', Value: 'Value', From: 'From', diff --git a/apps/ai-dial-admin/src/server/config-entities/read-page-options.ts b/apps/ai-dial-admin/src/server/config-entities/read-page-options.ts index 62643a387a..9c67a04ad7 100644 --- a/apps/ai-dial-admin/src/server/config-entities/read-page-options.ts +++ b/apps/ai-dial-admin/src/server/config-entities/read-page-options.ts @@ -21,8 +21,9 @@ export async function readConfigEntities( token: Token, type: ConfigFileEntityType, warnings: EntitiesI18nKey[], + showOnlyConfigFiles = false, ): Promise { - const result = await getConfigEntityOptions(token, type); + const result = await getConfigEntityOptions(token, type, showOnlyConfigFiles); if (!result.success) { errorLog(`Failed to read ${type} options from Core: ${result.failure.reason} ${result.failure.errorMessage ?? ''}`); diff --git a/apps/ai-dial-admin/src/server/config-entities/read.ts b/apps/ai-dial-admin/src/server/config-entities/read.ts index 07cadca2de..a4813bb61e 100644 --- a/apps/ai-dial-admin/src/server/config-entities/read.ts +++ b/apps/ai-dial-admin/src/server/config-entities/read.ts @@ -15,17 +15,27 @@ const HTTP_NOT_FOUND = 404; * the surviving population plus a reported failure, which is why the metadata half is read through * `getMetadata` rather than `assetApi.list`: `list` returns `[]` for a failed read, making a refusal * indistinguishable from an empty population. + * + * `showOnlyConfigFiles` scopes the read to the config-file population alone: the asset-metadata + * (`apiWritten`) read is skipped, and the config-file read is always attempted regardless of + * `DIAL_ADMIN_API_URL` — the caller already knows it wants config-file entities specifically (the + * `config-file-entity-views` list/detail surface), not the union a picker wants. Defaults to `false`, + * which reproduces this function's behavior before the parameter existed. */ export const getConfigEntityOptions = async ( token: Token, type: ConfigFileEntityType, + showOnlyConfigFiles = false, ): Promise> => { const [apiWritten, configFile] = await Promise.all([ - toFailureOnThrow(listApiWrittenNames(token, type)), + showOnlyConfigFiles + ? Promise.resolve>({ success: true, data: [] }) + : toFailureOnThrow(listApiWrittenNames(token, type)), // Config-file entities are the admin console's own configuration surface — without the admin // backend there is nothing declaring them, so skip the read rather than reporting an empty - // population as a partial failure. - process.env.DIAL_ADMIN_API_URL + // population as a partial failure. `showOnlyConfigFiles` overrides that: the caller wants the + // config-file population specifically, so the read always runs. + process.env.DIAL_ADMIN_API_URL || showOnlyConfigFiles ? toFailureOnThrow(configFileApi.listNames(token, type)) : Promise.resolve>({ success: true, data: [] }), ]); diff --git a/apps/ai-dial-admin/src/server/config-entities/tests/read-page-options.spec.ts b/apps/ai-dial-admin/src/server/config-entities/tests/read-page-options.spec.ts index c8825a1215..8cae31f9f0 100644 --- a/apps/ai-dial-admin/src/server/config-entities/tests/read-page-options.spec.ts +++ b/apps/ai-dial-admin/src/server/config-entities/tests/read-page-options.spec.ts @@ -31,7 +31,7 @@ describe('readConfigEntities', () => { const result = await readConfigEntities(TOKEN_MOCK, ConfigFileEntityType.Interceptors, warnings); - expect(getConfigEntityOptions).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Interceptors); + expect(getConfigEntityOptions).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Interceptors, false); expect(result).toEqual([{ name: 'interceptor-1', displayName: 'interceptor-1', origin: ConfigEntityOrigin.Api }]); expect(warnings).toEqual([]); }); @@ -64,6 +64,30 @@ describe('readConfigEntities', () => { expect(result).toEqual([{ name: 'role-1', displayName: 'role-1', origin: ConfigEntityOrigin.ConfigFile }]); expect(warnings).toEqual([EntitiesI18nKey.OptionListPartial]); }); + + test('threads showOnlyConfigFiles through to getConfigEntityOptions', async () => { + getConfigEntityOptions.mockResolvedValue({ + success: true, + data: { options: [], failures: [] }, + }); + const warnings: EntitiesI18nKey[] = []; + + await readConfigEntities(TOKEN_MOCK, ConfigFileEntityType.Models, warnings, true); + + expect(getConfigEntityOptions).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Models, true); + }); + + test('defaults showOnlyConfigFiles to false when omitted', async () => { + getConfigEntityOptions.mockResolvedValue({ + success: true, + data: { options: [], failures: [] }, + }); + const warnings: EntitiesI18nKey[] = []; + + await readConfigEntities(TOKEN_MOCK, ConfigFileEntityType.Models, warnings); + + expect(getConfigEntityOptions).toHaveBeenCalledWith(TOKEN_MOCK, ConfigFileEntityType.Models, false); + }); }); describe('readGlobalInterceptors', () => { diff --git a/apps/ai-dial-admin/src/server/config-entities/tests/read.spec.ts b/apps/ai-dial-admin/src/server/config-entities/tests/read.spec.ts index 2bd7f00fa0..94b3ae6aab 100644 --- a/apps/ai-dial-admin/src/server/config-entities/tests/read.spec.ts +++ b/apps/ai-dial-admin/src/server/config-entities/tests/read.spec.ts @@ -128,6 +128,39 @@ describe('getConfigEntityOptions', () => { expect(result.success && result.data.options).toEqual([{ name: 'from-api', origin: ConfigEntityOrigin.Api }]); expect(result.success && result.data.failures).toEqual([]); }); + + test('showOnlyConfigFiles skips the asset-metadata read and returns config-file-origin options only', async () => { + listNames.mockResolvedValue({ success: true, data: ['from-file'] }); + + const result = await getConfigEntityOptions(TOKEN_MOCK, ConfigFileEntityType.Interceptors, true); + + expect(getMetadata).not.toHaveBeenCalled(); + expect(result.success && result.data.options).toEqual([ + { name: 'from-file', origin: ConfigEntityOrigin.ConfigFile }, + ]); + }); + + test('showOnlyConfigFiles issues the config-file read even without the admin backend', async () => { + vi.stubEnv('DIAL_ADMIN_API_URL', ''); + listNames.mockResolvedValue({ success: true, data: ['from-file'] }); + + const result = await getConfigEntityOptions(TOKEN_MOCK, ConfigFileEntityType.Interceptors, true); + + expect(listNames).toHaveBeenCalledOnce(); + expect(result.success && result.data.options).toEqual([ + { name: 'from-file', origin: ConfigEntityOrigin.ConfigFile }, + ]); + }); + + test('omitting showOnlyConfigFiles reproduces the pre-existing behavior', async () => { + getMetadata.mockResolvedValue(metadataPage(['from-api'])); + listNames.mockResolvedValue({ success: true, data: ['from-file'] }); + + const withDefault = await getConfigEntityOptions(TOKEN_MOCK, ConfigFileEntityType.Interceptors); + const withExplicitFalse = await getConfigEntityOptions(TOKEN_MOCK, ConfigFileEntityType.Interceptors, false); + + expect(withDefault).toEqual(withExplicitFalse); + }); }); describe('getGlobalInterceptors', () => { diff --git a/apps/ai-dial-admin/src/server/core/tests/config-file-api.spec.ts b/apps/ai-dial-admin/src/server/core/tests/config-file-api.spec.ts index 58e45e5b81..9d23c509b7 100644 --- a/apps/ai-dial-admin/src/server/core/tests/config-file-api.spec.ts +++ b/apps/ai-dial-admin/src/server/core/tests/config-file-api.spec.ts @@ -119,4 +119,26 @@ describe('Server :: Core :: ConfigFileApi', () => { const [calledUrl] = fetch.mock.calls[0]; expect(calledUrl).toContain('/v1/admin/config/file/interceptors/a%2Fb'); }); + + test.each([ + ConfigFileEntityType.Models, + ConfigFileEntityType.Routes, + ConfigFileEntityType.Applications, + ConfigFileEntityType.Toolsets, + ConfigFileEntityType.Schemas, + ])('listNames accepts the newly-widened type %s', async (type) => { + fetch.mockResponseOnce(JSON.stringify({ items: [] }), { headers: { 'content-type': 'application/json' } }); + + const result = await instance.listNames(TOKEN_MOCK, type); + + expect(fetch.mock.calls).toHaveLength(1); + expect(result.success).toBe(true); + }); + + test('listNames still refuses Keys', async () => { + const result = await instance.listNames(TOKEN_MOCK, ConfigFileEntityType.Keys); + + expect(fetch.mock.calls).toHaveLength(0); + expect(!result.success && result.failure.reason).toBe(ConfigFileFailureReason.TypeNotReadable); + }); }); diff --git a/apps/ai-dial-admin/src/utils/open-in-new-tab.ts b/apps/ai-dial-admin/src/utils/open-in-new-tab.ts index 1d3311b54d..82b2355996 100644 --- a/apps/ai-dial-admin/src/utils/open-in-new-tab.ts +++ b/apps/ai-dial-admin/src/utils/open-in-new-tab.ts @@ -11,8 +11,8 @@ export const escapePercentSign = (str: string): string => { return str.replace(/%/g, '%25'); }; -export const onOpenInNewTab = (route?: ApplicationRoute, entity?: unknown) => { - const url = getUrnForEntity(route, entity); +export const onOpenInNewTab = (route?: ApplicationRoute, entity?: unknown, urlSuffix?: string) => { + const url = `${getUrnForEntity(route, entity)}${urlSuffix ?? ''}`; window.open(url, '_blank'); }; diff --git a/apps/ai-dial-admin/src/utils/tabs/tests/utils.spec.ts b/apps/ai-dial-admin/src/utils/tabs/tests/utils.spec.ts index 46a8b59480..620ec28e45 100644 --- a/apps/ai-dial-admin/src/utils/tabs/tests/utils.spec.ts +++ b/apps/ai-dial-admin/src/utils/tabs/tests/utils.spec.ts @@ -91,6 +91,7 @@ import { IMAGE_STATUS } from '@/src/types/deployments/images'; const t = vi.fn((id) => id); const flags = (overrides: Partial = {}): FeatureFlags => ({ + adminApiEnabled: false, dashboardEnabled: false, deploymentsEnabled: false, evaluationEnabled: false, @@ -105,12 +106,17 @@ const flags = (overrides: Partial = {}): FeatureFlags => ({ describe('Entities :: tabs', () => { test('Should return tabs for models', () => { - const res = getModelsTabs(t); + const res = getModelsTabs(t, flags({ adminApiEnabled: true })); expect(res).toEqual([propertiesTab(t), featuresTab(t), rolesTab(t), interceptorsTab(t), auditTab(t)]); }); + test('omits the Audit tab for models when adminApiEnabled is false', () => { + const res = getModelsTabs(t, flags()); + expect(res).toEqual([propertiesTab(t), featuresTab(t), rolesTab(t), interceptorsTab(t)]); + }); + test('Should return tabs for application', () => { - const res = getApplicationTabs(t); + const res = getApplicationTabs(t, flags({ adminApiEnabled: true })); expect(res).toEqual([ propertiesTab(t), featuresTab(t), @@ -123,11 +129,29 @@ describe('Entities :: tabs', () => { ]); }); + test('omits the Audit tab for application when adminApiEnabled is false', () => { + const res = getApplicationTabs(t, flags()); + expect(res).toEqual([ + propertiesTab(t), + featuresTab(t), + parametersTab(t), + dependenciesTab(t), + appRouteTab(t), + rolesTab(t), + interceptorsTab(t), + ]); + }); + test('Should return tabs for routes', () => { - const res = getRouteTabs(t); + const res = getRouteTabs(t, flags({ adminApiEnabled: true })); expect(res).toEqual([propertiesTab(t), rolesTab(t), auditTab(t)]); }); + test('omits the Audit tab for routes when adminApiEnabled is false', () => { + const res = getRouteTabs(t, flags()); + expect(res).toEqual([propertiesTab(t), rolesTab(t)]); + }); + test('returns dashboard and activities tabs if dashboardEnabled and view is Models', () => { const tabs = getAuditTabs(t, flags({ dashboardEnabled: true }), ApplicationRoute.Models); expect(tabs).toEqual([ @@ -190,14 +214,19 @@ describe('Entities :: tabs', () => { expect(getTabsForAsset(t, ApplicationRoute.AssetsToolsets)).toEqual([propertiesTab(t), toolsTab(t)]); }); - test('returns correct tabs for AssetsToolsets with dashboardEnabled', () => { + test('returns correct tabs for AssetsToolsets with dashboardEnabled but without adminApiEnabled', () => { expect(getTabsForAsset(t, ApplicationRoute.AssetsToolsets, flags({ dashboardEnabled: true }))).toEqual([ propertiesTab(t), toolsTab(t), - auditTab(t), ]); }); + test('returns correct tabs for AssetsToolsets with dashboardEnabled and adminApiEnabled', () => { + expect( + getTabsForAsset(t, ApplicationRoute.AssetsToolsets, flags({ dashboardEnabled: true, adminApiEnabled: true })), + ).toEqual([propertiesTab(t), toolsTab(t), auditTab(t)]); + }); + test('returns only Dashboard and Traces tabs for PlatformModels when dashboardEnabled is true', () => { const tabs = getAuditTabs(t, flags({ dashboardEnabled: true }), ApplicationRoute.PlatformModels); expect(tabs).toEqual([ @@ -217,8 +246,17 @@ describe('Entities :: tabs', () => { ]); }); - test('appends Audit as the fifth and last tab for PlatformModels with dashboardEnabled', () => { + test('omits Audit for PlatformModels with dashboardEnabled but without adminApiEnabled', () => { const tabs = getTabsForAsset(t, ApplicationRoute.PlatformModels, flags({ dashboardEnabled: true })); + expect(tabs).toEqual([propertiesTab(t), featuresTab(t), rolesTab(t), interceptorsTab(t)]); + }); + + test('appends Audit as the fifth and last tab for PlatformModels with dashboardEnabled and adminApiEnabled', () => { + const tabs = getTabsForAsset( + t, + ApplicationRoute.PlatformModels, + flags({ dashboardEnabled: true, adminApiEnabled: true }), + ); expect(tabs).toEqual([propertiesTab(t), featuresTab(t), rolesTab(t), interceptorsTab(t), auditTab(t)]); }); @@ -259,7 +297,11 @@ describe('Entities :: tabs', () => { }); test('returns correct tabs for key', () => { - expect(getKeyTabs(t)).toEqual([propertiesTab(t), rolesTab(t), auditTab(t)]); + expect(getKeyTabs(t, flags({ adminApiEnabled: true }))).toEqual([propertiesTab(t), rolesTab(t), auditTab(t)]); + }); + + test('omits the Audit tab for key when adminApiEnabled is false', () => { + expect(getKeyTabs(t, flags())).toEqual([propertiesTab(t), rolesTab(t)]); }); test('returns correct tabs for publication', () => { @@ -271,7 +313,16 @@ describe('Entities :: tabs', () => { }); test('returns correct tabs for roles', () => { - expect(getRoleTabs(t)).toEqual([propertiesTab(t), entitiesTab(t), keysTab(t), auditTab(t)]); + expect(getRoleTabs(t, flags({ adminApiEnabled: true }))).toEqual([ + propertiesTab(t), + entitiesTab(t), + keysTab(t), + auditTab(t), + ]); + }); + + test('omits the Audit tab for roles when adminApiEnabled is false', () => { + expect(getRoleTabs(t, flags())).toEqual([propertiesTab(t), entitiesTab(t), keysTab(t)]); }); test('returns correct tabs for usage log', () => { @@ -279,19 +330,40 @@ describe('Entities :: tabs', () => { }); test('returns correct tabs for interceptor template', () => { - expect(getInterceptorTemplateTabs(t)).toEqual([propertiesTab(t), interceptorsTab(t), auditTab(t)]); + expect(getInterceptorTemplateTabs(t, flags({ adminApiEnabled: true }))).toEqual([ + propertiesTab(t), + interceptorsTab(t), + auditTab(t), + ]); + }); + + test('omits the Audit tab for interceptor template when adminApiEnabled is false', () => { + expect(getInterceptorTemplateTabs(t, flags())).toEqual([propertiesTab(t), interceptorsTab(t)]); }); test('returns correct tabs for toolsets', () => { - expect(getToolsetTabs(t)).toEqual([propertiesTab(t), toolsTab(t), rolesTab(t), auditTab(t)]); + expect(getToolsetTabs(t, flags({ adminApiEnabled: true }))).toEqual([ + propertiesTab(t), + toolsTab(t), + rolesTab(t), + auditTab(t), + ]); + }); + + test('omits the Audit tab for toolsets when adminApiEnabled is false', () => { + expect(getToolsetTabs(t, flags())).toEqual([propertiesTab(t), toolsTab(t), rolesTab(t)]); }); test('returns correct tabs for adapter', () => { - expect(getAdapterTabs(t)).toEqual([propertiesTab(t), modelsTab(t), auditTab(t)]); + expect(getAdapterTabs(t, flags({ adminApiEnabled: true }))).toEqual([propertiesTab(t), modelsTab(t), auditTab(t)]); + }); + + test('omits the Audit tab for adapter when adminApiEnabled is false', () => { + expect(getAdapterTabs(t, flags())).toEqual([propertiesTab(t), modelsTab(t)]); }); test('returns correct tabs for app runner', () => { - expect(getAppRunnerTabs(t)).toEqual([ + expect(getAppRunnerTabs(t, flags({ adminApiEnabled: true }))).toEqual([ propertiesTab(t), featuresTab(t), parametersTab(t), @@ -302,12 +374,23 @@ describe('Entities :: tabs', () => { ]); }); + test('omits the Audit tab for app runner when adminApiEnabled is false', () => { + expect(getAppRunnerTabs(t, flags())).toEqual([ + propertiesTab(t), + featuresTab(t), + parametersTab(t), + interceptorsTab(t), + applicationsTab(t), + appRouteTab(t), + ]); + }); + test('returns correct tabs for system properties', () => { expect(getSystemPropertiesTabs(t)).toEqual([globalInterceptorsTab(t)]); }); test('returns correct tabs for interceptor', () => { - expect(getInterceptorTabs(t)).toEqual([ + expect(getInterceptorTabs(t, flags({ adminApiEnabled: true }))).toEqual([ propertiesTab(t), parameterSchemaTab(t), entitiesTab(t), @@ -316,10 +399,21 @@ describe('Entities :: tabs', () => { ]); }); + test('omits the Audit tab for interceptor when adminApiEnabled is false', () => { + expect(getInterceptorTabs(t, flags())).toEqual([ + propertiesTab(t), + parameterSchemaTab(t), + entitiesTab(t), + applicationRunnersTab(t), + ]); + }); + test('returns correct tabs for deployment images', () => { const status = IMAGE_STATUS.BUILT; - expect(getDeploymentsViewTabs(ApplicationRoute.Images, t, status, [])).toEqual([ + expect( + getDeploymentsViewTabs(ApplicationRoute.Images, t, status, [], undefined, flags({ adminApiEnabled: true })), + ).toEqual([ propertiesTab(t), firewallTab(t, false), relatedContainersTab(t, status), @@ -327,10 +421,31 @@ describe('Entities :: tabs', () => { auditTab(t), ]); }); + + test('omits the Audit tab for deployment images when adminApiEnabled is false', () => { + const status = IMAGE_STATUS.BUILT; + + expect(getDeploymentsViewTabs(ApplicationRoute.Images, t, status, [])).toEqual([ + propertiesTab(t), + firewallTab(t, false), + relatedContainersTab(t, status), + installationLogTab(t, status), + ]); + }); + test('returns correct tabs for deployment mcp containers', () => { const status = CONTAINER_STATUS.RUNNING; - expect(getDeploymentsViewTabs(ApplicationRoute.McpContainers, t, status, ['*'])).toEqual([ + expect( + getDeploymentsViewTabs( + ApplicationRoute.McpContainers, + t, + status, + ['*'], + undefined, + flags({ adminApiEnabled: true }), + ), + ).toEqual([ propertiesTab(t), firewallTab(t, true), deploymentsToolsTab(t, status), @@ -343,6 +458,21 @@ describe('Entities :: tabs', () => { ]); }); + test('omits the Audit tab for deployment mcp containers when adminApiEnabled is false', () => { + const status = CONTAINER_STATUS.RUNNING; + + expect(getDeploymentsViewTabs(ApplicationRoute.McpContainers, t, status, ['*'])).toEqual([ + propertiesTab(t), + firewallTab(t, true), + deploymentsToolsTab(t, status), + resourcesTab(t, status), + promptsTab(t, status), + metricsTab(t, status), + executionLogTab(t), + eventsTab(t), + ]); + }); + test('does not include a metrics tab for deployment images', () => { const tabs = getDeploymentsViewTabs(ApplicationRoute.Images, t, IMAGE_STATUS.BUILT, []); expect(tabs).not.toContainEqual(metricsTab(t, undefined)); @@ -412,7 +542,16 @@ describe('Entities :: tabs', () => { test('returns correct tabs for model containers', () => { const status = CONTAINER_STATUS.RUNNING; - expect(getDeploymentsViewTabs(ApplicationRoute.ModelServings, t, status, [])).toEqual([ + expect( + getDeploymentsViewTabs( + ApplicationRoute.ModelServings, + t, + status, + [], + undefined, + flags({ adminApiEnabled: true }), + ), + ).toEqual([ propertiesTab(t), firewallTab(t, false), metricsTab(t, status), @@ -422,6 +561,18 @@ describe('Entities :: tabs', () => { ]); }); + test('omits the Audit tab for model containers when adminApiEnabled is false', () => { + const status = CONTAINER_STATUS.RUNNING; + + expect(getDeploymentsViewTabs(ApplicationRoute.ModelServings, t, status, [])).toEqual([ + propertiesTab(t), + firewallTab(t, false), + metricsTab(t, status), + executionLogTab(t), + eventsTab(t), + ]); + }); + test('appends audit tab and includes metrics tab for adapter / application / interceptor container routes', () => { const status = CONTAINER_STATUS.RUNNING; const routes = [ @@ -431,12 +582,26 @@ describe('Entities :: tabs', () => { ]; for (const route of routes) { - const tabs = getDeploymentsViewTabs(route, t, status, []); + const tabs = getDeploymentsViewTabs(route, t, status, [], undefined, flags({ adminApiEnabled: true })); expect(tabs[tabs.length - 1]).toEqual(auditTab(t)); expect(tabs).toContainEqual(metricsTab(t, status)); } }); + test('omits the Audit tab for adapter / application / interceptor container routes when adminApiEnabled is false', () => { + const status = CONTAINER_STATUS.RUNNING; + const routes = [ + ApplicationRoute.AdapterContainers, + ApplicationRoute.ApplicationContainers, + ApplicationRoute.InterceptorContainers, + ]; + + for (const route of routes) { + const tabs = getDeploymentsViewTabs(route, t, status, []); + expect(tabs).not.toContainEqual(auditTab(t)); + } + }); + test('returns correct tabs for test suite request template', () => { expect(getTestSuiteRequestTemplateTabs(t)).toEqual([bodyTab(t), parametersTab(t), headersTab(t)]); }); diff --git a/apps/ai-dial-admin/src/utils/tabs/utils.ts b/apps/ai-dial-admin/src/utils/tabs/utils.ts index 0312303d96..5f0d7eb7ff 100644 --- a/apps/ai-dial-admin/src/utils/tabs/utils.ts +++ b/apps/ai-dial-admin/src/utils/tabs/utils.ts @@ -353,11 +353,11 @@ export const schemaTab = (t: (key: string) => string) => ({ label: t(TabsI18nKey.Schema), }); -export const getRouteTabs = (t: (key: string) => string): TabModel[] => { - return [propertiesTab(t), rolesTab(t), auditTab(t)]; +export const getRouteTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { + return [propertiesTab(t), rolesTab(t), ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : [])]; }; -export const getApplicationTabs = (t: (key: string) => string): TabModel[] => { +export const getApplicationTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { return [ propertiesTab(t), featuresTab(t), @@ -366,19 +366,25 @@ export const getApplicationTabs = (t: (key: string) => string): TabModel[] => { appRouteTab(t), rolesTab(t), interceptorsTab(t), - auditTab(t), + ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : []), ]; }; -export const getModelsTabs = (t: (key: string) => string): TabModel[] => { - return [propertiesTab(t), featuresTab(t), rolesTab(t), interceptorsTab(t), auditTab(t)]; +export const getModelsTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { + return [ + propertiesTab(t), + featuresTab(t), + rolesTab(t), + interceptorsTab(t), + ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : []), + ]; }; -export const getAdapterTabs = (t: (key: string) => string): TabModel[] => { - return [propertiesTab(t), modelsTab(t), auditTab(t)]; +export const getAdapterTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { + return [propertiesTab(t), modelsTab(t), ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : [])]; }; -export const getAppRunnerTabs = (t: (key: string) => string): TabModel[] => { +export const getAppRunnerTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { return [ propertiesTab(t), featuresTab(t), @@ -386,7 +392,7 @@ export const getAppRunnerTabs = (t: (key: string) => string): TabModel[] => { interceptorsTab(t), applicationsTab(t), appRouteTab(t), - auditTab(t), + ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : []), ]; }; @@ -394,28 +400,34 @@ export const getAppRouteTabs = (t: (key: string) => string): TabModel[] => { return [propertiesTab(t), attachmentsTab(t), rolesTab(t)]; }; -export const getRoleTabs = (t: (key: string) => string): TabModel[] => { - return [propertiesTab(t), entitiesTab(t), keysTab(t), auditTab(t)]; +export const getRoleTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { + return [propertiesTab(t), entitiesTab(t), keysTab(t), ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : [])]; }; export const getEvaluatorTabs = (t: (key: string) => string): TabModel[] => { return [propertiesTab(t), rulesTab(t)]; }; -export const getInterceptorTabs = (t: (key: string) => string): TabModel[] => { - return [propertiesTab(t), parameterSchemaTab(t), entitiesTab(t), applicationRunnersTab(t), auditTab(t)]; +export const getInterceptorTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { + return [ + propertiesTab(t), + parameterSchemaTab(t), + entitiesTab(t), + applicationRunnersTab(t), + ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : []), + ]; }; -export const getToolsetTabs = (t: (key: string) => string): TabModel[] => { - return [propertiesTab(t), toolsTab(t), rolesTab(t), auditTab(t)]; +export const getToolsetTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { + return [propertiesTab(t), toolsTab(t), rolesTab(t), ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : [])]; }; -export const getInterceptorTemplateTabs = (t: (key: string) => string): TabModel[] => { - return [propertiesTab(t), interceptorsTab(t), auditTab(t)]; +export const getInterceptorTemplateTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { + return [propertiesTab(t), interceptorsTab(t), ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : [])]; }; -export const getKeyTabs = (t: (key: string) => string): TabModel[] => { - return [propertiesTab(t), rolesTab(t), auditTab(t)]; +export const getKeyTabs = (t: (key: string) => string, featureFlags?: FeatureFlags): TabModel[] => { + return [propertiesTab(t), rolesTab(t), ...(featureFlags?.adminApiEnabled ? [auditTab(t)] : [])]; }; export const getPublicationTabs = (t: (key: string) => string): TabModel[] => { @@ -437,7 +449,7 @@ export const getTabsForAsset = ( } if (view === ApplicationRoute.AssetsToolsets) { const tabs = [propertiesTab(t), toolsTab(t)]; - if (featureFlags?.dashboardEnabled) { + if (featureFlags?.dashboardEnabled && featureFlags?.adminApiEnabled) { tabs.push(auditTab(t)); } return tabs; @@ -447,7 +459,7 @@ export const getTabsForAsset = ( } if (view === ApplicationRoute.PlatformModels) { const tabs = [propertiesTab(t), featuresTab(t), rolesTab(t, rolesWarning), interceptorsTab(t)]; - if (featureFlags?.dashboardEnabled) { + if (featureFlags?.dashboardEnabled && featureFlags?.adminApiEnabled) { tabs.push(auditTab(t)); } return tabs; @@ -500,14 +512,17 @@ export const getDeploymentsViewTabs = ( status?: CONTAINER_STATUS | IMAGE_STATUS, allowedWhitelist?: string[], propertiesWarning?: boolean, + featureFlags?: FeatureFlags, ): TabModel[] => { + const maybeAuditTab = featureFlags?.adminApiEnabled ? [auditTab(t)] : []; + if (route === ApplicationRoute.Images) { return [ propertiesTab(t), firewallTab(t, !!allowedWhitelist?.includes(ALLOW_ALL_DOMAINS)), relatedContainersTab(t, status as IMAGE_STATUS), installationLogTab(t, status as IMAGE_STATUS), - auditTab(t), + ...maybeAuditTab, ]; } if (route === ApplicationRoute.McpContainers) { @@ -520,7 +535,7 @@ export const getDeploymentsViewTabs = ( metricsTab(t, status as CONTAINER_STATUS), executionLogTab(t), eventsTab(t), - auditTab(t), + ...maybeAuditTab, ]; } return [ @@ -529,7 +544,7 @@ export const getDeploymentsViewTabs = ( metricsTab(t, status as CONTAINER_STATUS), executionLogTab(t), eventsTab(t), - auditTab(t), + ...maybeAuditTab, ]; }; diff --git a/apps/ai-dial-admin/src/utils/tests/open-in-new-tab.spec.ts b/apps/ai-dial-admin/src/utils/tests/open-in-new-tab.spec.ts index c890c12d97..94b57eabff 100644 --- a/apps/ai-dial-admin/src/utils/tests/open-in-new-tab.spec.ts +++ b/apps/ai-dial-admin/src/utils/tests/open-in-new-tab.spec.ts @@ -314,6 +314,18 @@ describe('onOpenInNewTab', () => { onOpenInNewTab(ApplicationRoute.RunsCompare, { id: 'run-123', compareWithId: 'run-456' }); expect(windowOpenSpy).toHaveBeenCalledWith('/runs/compare?runs=run-123,run-456', '_blank'); }); + + test('appends the config-file url suffix when one is given', () => { + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + onOpenInNewTab(ApplicationRoute.Models, { name: 'entity' }, '?configFile=true'); + expect(windowOpenSpy).toHaveBeenCalledWith('/models/entity?configFile=true', '_blank'); + }); + + test('omits the suffix when none is given', () => { + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + onOpenInNewTab(ApplicationRoute.Models, { name: 'entity' }); + expect(windowOpenSpy).toHaveBeenCalledWith('/models/entity', '_blank'); + }); }); describe('getEntityAuditFilterId', () => { diff --git a/apps/ai-dial-admin/test-setup.tsx b/apps/ai-dial-admin/test-setup.tsx index b3e5f07754..f3517d296d 100644 --- a/apps/ai-dial-admin/test-setup.tsx +++ b/apps/ai-dial-admin/test-setup.tsx @@ -29,7 +29,11 @@ vi.mock('next-auth/react', () => ({ // ------------------ Next.js hooks ------------------ vi.mock('next/headers', () => ({ headers: vi.fn(), cookies: vi.fn() })); -vi.mock('next/navigation', () => ({ useRouter: vi.fn(), usePathname: vi.fn() })); +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(), + usePathname: vi.fn(), + useSearchParams: vi.fn(() => new URLSearchParams()), +})); // ------------------ Contexts ------------------ const createFnContext = () => vi.fn(); @@ -99,20 +103,28 @@ vi.mock('@/src/context/RuleFolderContext', () => ({ useRuleFolder: createFnConte import { SidebarPosition } from '@/src/components/Common/Sidebar/models'; +// Hoisted, not returned as a literal from the mock: a fresh object per call gives `featureFlags` +// (and the context value itself) a new identity every render, so any component listing one of them +// in a hook dependency array re-runs its effect forever and the worker dies on heap exhaustion. +const appContextValue = { + sidebar: { + show: false, + content: null, + showSidebar: vi.fn(), + closeSidebar: vi.fn(), + position: SidebarPosition.Right, + }, + featureFlags: { deploymentsEnabled: true, adminApiEnabled: true }, + isReadOnlyAdmin: false, + isFullAdmin: true, + isEnableAuth: false, + showConfigFiles: false, + toggleShowConfigFiles: vi.fn(), + setEntityReadOnly: vi.fn(), +}; + vi.mock('@/src/context/AppContext', () => ({ - useAppContext: () => ({ - sidebar: { - show: false, - content: null, - showSidebar: vi.fn(), - closeSidebar: vi.fn(), - position: SidebarPosition.Right, - }, - featureFlags: { deploymentsEnabled: true, adminApiEnabled: true }, - isReadOnlyAdmin: false, - isFullAdmin: true, - isEnableAuth: false, - }), + useAppContext: () => appContextValue, })); vi.mock('@/src/context/SaveValidationContext', () => { diff --git a/apps/ai-dial-admin/vitest.config.ts b/apps/ai-dial-admin/vitest.config.ts index 5bd88cd37d..ca85144289 100644 --- a/apps/ai-dial-admin/vitest.config.ts +++ b/apps/ai-dial-admin/vitest.config.ts @@ -1,5 +1,6 @@ import { configDefaults, coverageConfigDefaults, defineConfig } from 'vitest/config'; - +import { DotReporter } from 'vitest/node'; +import type { TestCase } from 'vitest/node'; import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; // `@vitejs/plugin-react` ships only an `exports` map — no `types`, no `main` — so the inherited @@ -9,6 +10,20 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; // @ts-expect-error -- unresolvable under node resolution only import react from '@vitejs/plugin-react'; +class InlineReporter extends DotReporter { + override onTestCaseResult(testCase: TestCase) { + const result = testCase.result(); + + if (result.state === 'failed') { + const error = result.errors[0]; + const message = error?.message?.split('\n')[0]?.trim(); + process.stdout.write(`\n❌ ${testCase.fullName}${message ? ` — ${message}` : ''}\n`); + } + + super.onTestCaseResult(testCase); + } +} + export default defineConfig(() => ({ root: __dirname, cacheDir: '../../node_modules/.vite/apps/ai-dial-admin', @@ -33,9 +48,7 @@ export default defineConfig(() => ({ threads: false, include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], exclude: [...configDefaults.exclude, '**/.next/**', '*.config.{ts,js}'], - // 'dot' prints one character per file and every failure in full; the default reporter prints a - // line per spec file (971 of them here), which is output nobody reads and agents pay for. - reporters: ['dot'], + reporters: [new InlineReporter()], coverage: { include: ['src/**/*.{ts,tsx}'], // 'text-summary' is six lines; 'text' is one row per source file (2 950 of them here). diff --git a/openspec/changes/archive/2026-09-13-add-config-file-entity-views/.openspec.yaml b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/.openspec.yaml new file mode 100644 index 0000000000..c238415496 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-13 diff --git a/openspec/changes/archive/2026-09-13-add-config-file-entity-views/design.md b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/design.md new file mode 100644 index 0000000000..539493d657 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/design.md @@ -0,0 +1,218 @@ +## Context + +Two surfaces already exist per entity type: + +- The **platform/asset** surface (`platform-models`, `platform-interceptors`, `platform-routes`, + `platform-roles`, `assets-applications`, `assets-toolsets`) — a `BaseAssetList` file/folder browser + over Core's resource-metadata route, Core-direct, works with no admin backend. +- The **admin-grid** surface (`models`, `interceptors`, `routes`, `roles`, `applications`, `toolsets`) + — a `ListView`/`GridView` grid backed by structured `DialModel`/`DialApplication`/etc. types, fetched + from the admin backend; its `page.tsx` unconditionally `redirect(ApplicationRoute.Home)`s when + `DIAL_ADMIN_API_URL` is unset (`admin-api-availability`). + +Neither surface reads Core's config-file population (`aidial.config.json`, exposed read-only via +`configFileApi`) as a source of full entities to browse — `core-config-file-client` only ever +flattens it to `{name, origin}` pairs for reference pickers. Without the admin backend, config-file +declarations may be the only place some of these entities exist, and there is currently no way to see +them in the admin console at all. + +Six entity types are in scope: **Models, Interceptors, Routes, Roles** (platform pages) and +**Applications, Toolsets** (asset pages). **Keys** and **App Runners** are out of scope — Core refuses +the config-file `keys` route unconditionally (`READABLE_CONFIG_FILE_TYPES` already documents this), +and App Runners have no config-file type at all (`ConfigFileEntityType` has no member for them; they +are their own resource kind, unrelated to `ConfigFileEntityType.Applications`). + +## Goals / Non-Goals + +**Goals:** +- Let an admin without `DIAL_ADMIN_API_URL` browse the six in-scope entity types as Core's config file + declares them, reusing the existing admin-grid list/detail components rather than building new ones. +- Keep the toggle off, and the cost of this feature at zero, for everyone who doesn't use it. +- Keep config-file entities read-only, consistent with `core-config-file-client`'s existing + read-only-by-design stance. + +**Non-Goals:** +- No write path for config-file entities. +- No change to the `BaseAssetList` editing experience. +- No support for Keys or App Runners. +- No guarantee, yet, that Core's raw config-file JSON is a drop-in `DialModel`/`DialApplication`/etc. + — see Open Questions. + +## Decisions + +### D1: One global `showConfigFiles` toggle in `AppContext`, not per-entity-type + +`AppContextType` gains `showConfigFiles: boolean` and a `toggleShowConfigFiles()` function, following +the exact `sidebarOpen`/`toggleSidebar` pattern: `useState` + `getFromLocalStorage`/`setToLocalStorage` +under a new `LOCAL_STORAGE_SHOW_CONFIG_FILES_KEY`, default `false`. + +A single flag is simpler to reason about and matches how the user described it ("this toggle should +appear... and list view should be changed", singular). The control itself is rendered only on the six +in-scope pages, and only when `!featureFlags.adminApiEnabled` — the context value exists globally, but +nothing reads or shows it when the admin backend is configured. + +**Alternative considered:** a `Record` per-type toggle. Rejected — +no requirement calls for viewing e.g. Models in config-file mode while Interceptors stays in +asset-browser mode, and it complicates persistence and the "same control renders in both headers" +requirement for no expressed benefit. + +### D2: Toggle placement reuses each surface's existing title-adjacent slot + +- `BaseAssetList` renders via ui-kit's `DialFileManager`, whose `managerLabel` prop is `ReactNode` (not + just a string) — the toggle is composed into the same node as the existing label text. +- The admin-grid `ListView` already renders `{title}` and `{children}` side by side in its header row + — the toggle is passed as `children`. + +No new header/title component is introduced; both surfaces already have a slot for this. + +### D3: List swap is a client-side component swap, not a route change + +Each of the six `page.tsx` files keeps rendering both possibilities from one route: `showConfigFiles` +false → today's `BaseAssetList`-backed list component; true → that entity's existing admin-grid list +component (`Models/List`, `ApplicationsList`, `InterceptorsList`, `RoutesList`, `RolesList`, +`ToolsetsList`), reused as-is. No new route, no new list component. + +### D4: Config-file entity data is fetched lazily, client-triggered, only on toggle-on + +The six `page.tsx` files do not eagerly fetch config-file data on every request. A new client-invoked +server action (per entity type) is called only when the user flips the toggle on; its result is held +in local component state and feeds the admin-grid list component in place of the props those +components normally receive from server-side data. This keeps the cost of the feature at exactly zero +for the default (toggle-off) case, and for every admin-backend-configured deployment where the toggle +is never shown. + +### D5: `ConfigFileApi` gains a full-entity `list` method — not a raw Core endpoint, a composite + +Core's config-file **list** route (`GET /v1/admin/config/file/{type}`) only ever returns bare names — +`FileConfigController.handleList` builds each item as `{name: key}` and nothing else; there is no +bulk-read-with-bodies route to call instead. The existing `listNames` is correct for what it does, but +a grid needs full entities, and building it via `listNames` + a per-name `getEntity` follow-up is +exactly the "picker" shape (`getConfigEntityOptions`/`toConfigEntityRows`) that deliberately drops +everything but name and origin — reusing it for a grid would mean re-fetching each entity a second +time just to get its columns. + +`ConfigFileApi.list(token, type)` is added as a composite: it calls `listNames`, then issues +`getEntity` for every name in parallel, and returns +`ConfigFileReadResult>`, where `ConfigFileListResult` is +`{ entities: T[]; failures: ConfigFileReadFailure[] }` — mirroring `ConfigEntityOptions`'s +partial-success shape (a plain `ConfigFileReadResult` has no room to carry both the entities that +did read and the failures for the ones that didn't, since its two branches are mutually exclusive). +The outer `ConfigFileReadResult` only fails when the type is unreadable or the name listing itself +fails; once names are in hand, every entity that read successfully is returned, and one failed name is +recorded in `failures` rather than discarded silently. + +**Cost:** N+1 requests per type (one `listNames` + one `getEntity` per name), same shape Core's route +family already forces on any full-entity read. Acceptable because D4 makes this lazy — it only runs +when a user actually opts into the config-file list for one entity type at a time. + +**Alternative considered:** teaching `listNames` itself to return full bodies. Rejected — Core's list +route cannot serve that regardless of client changes; the client would still need one `getEntity` per +name, and conflating "list of names" with "list of full entities" under one method obscures which is +which for the picker call sites that only ever wanted names. + +### D6: `READABLE_CONFIG_FILE_TYPES` widens to the six in-scope types + +Today: `{Interceptors, Roles, Settings}`. This change adds `Models`, `Routes`, `Applications`, +`Toolsets`. `Keys` stays excluded (Core's unconditional 403). `Settings` is retained (unrelated +singleton, already read elsewhere) and is not part of the six. + +### D7: `showOnlyConfigFiles` parameter on `getConfigEntityOptions`/`readConfigEntities` + +Both gain a `showOnlyConfigFiles: boolean` parameter (default `false`, threaded through every +existing call site) — named for what it does to the read (scope it to config-file entities only), +not for the `showConfigFiles` UI toggle (D1), which is a different concept driving a different layer. +When `true`: +- the asset-metadata (`apiWritten`) read is skipped — this call is happening because the caller + already knows it wants the config-file population specifically, not the union a picker wants. +- the config-file (`configFileApi.listNames`) read is always attempted, regardless of + `DIAL_ADMIN_API_URL` — today it resolves to an empty population whenever the admin backend is + unset; that is precisely the case this parameter exists to override. + +This keeps the default (`false`) behavior byte-for-byte identical to today for every call site that +doesn't pass it, including every existing picker on the admin-backend-configured path. + +### D8: Detail-page fallback and read-only rendering are driven by a `configFile=true` query param, not by context or a cookie + +A config-file list row's link carries `?configFile=true` to the existing "hidden" admin-grid detail +route (e.g. `/models/{id}?configFile=true`). That route's `page.tsx` (a server component) reads +`searchParams.configFile` directly — no cookie, no server-side read of `AppContext` (which is +client-only state and was never going to be visible to a server component at request time regardless +of how it's persisted). + +When `configFile === 'true'`: +- the entity itself is fetched via `configFileApi.getEntity` instead of the admin-backend get. +- the page's own embedded picker reads (Roles/Interceptors) go through + `readConfigEntities(..., showOnlyConfigFiles: true)` instead of direct `rolesApi`/`interceptorsApi` + list calls. +- the redirect guard (`admin-api-availability`) becomes: redirect home only when + `DIAL_ADMIN_API_URL` is unset **and** `configFile !== 'true'`. +- the view is marked read-only (D9). +- the breadcrumb's "back to list" segment points at the platform/asset route, not this hidden route's + own list (D10) — reaching that list directly would redirect home. + +### D9: Read-only rendering reuses `isReadOnlyAdmin`'s existing wiring via a context setter + +~140 leaf field components across this codebase already call `useIsReadOnlyAdmin()` (a thin wrapper +over `useAppContext().isReadOnlyAdmin`) directly, disabling themselves with +`disabled={... || isReadOnlyAdmin}`. None of them accept a read-only prop. + +Rather than threading a new prop through every one of those components (or duplicating the +computation with a second, separately-checked flag), `AppContext` gains a setter — +`setEntityReadOnly(boolean)` — following the same shape as its existing `setVisualizerConnector`. All +six detail `View` components (Models, Applications, Interceptors, Routes, Roles, Toolsets) call it on +mount when rendering a config-file entity, and clear it on unmount. `isReadOnlyAdmin`'s computation +folds this in: `isReadOnlyAdmin = || isEntityReadOnly`. Every existing call +site keeps working unmodified. + +**Alternative considered:** a separate `isConfigFileReadOnly` flag, checked alongside +`isReadOnlyAdmin` at each of the ~140 call sites. Rejected — far larger diff, for a distinction +(admin-role-driven vs config-file-driven read-only) that no requirement needs a reader to be able to +tell apart; both mean "this field cannot be edited here." + +**Risk:** a setter-on-mount pattern must reliably clear itself on unmount/navigation-away, or a config- +file detail view could leave the whole app read-only after the user navigates elsewhere. Mitigated by +the same cleanup-effect pattern `setVisualizerConnector`'s callers already use, and covered explicitly +in tasks/tests. + +### D10: Breadcrumb "back to list" is remapped to the platform/asset route in config-file mode + +`getBreadcrumbs` computes every segment's `href` purely from the URL path (via `usePathname`), so on +`/models/{id}?configFile=true` its first segment resolves to `/models` — the hidden route's *own* +list, which redirects home without the admin backend (D8's guard fires again, one level up). That is +never where the user came from in this mode; they reached the detail page from `/platform-models`'s +config-file-backed grid. + +`getBreadcrumbs` gains an `isConfigFileMode` parameter; `Breadcrumbs.tsx` supplies it from +`useSearchParams().get('configFile') === 'true'`. When true, the root segment's `href` is looked up in +a new `CONFIG_FILE_DETAIL_TO_LIST_ROUTE` map (hidden route → its platform/asset route) instead of +being derived from the path, for every one of the six covered types. A route with no entry in that +map (everything outside this feature) is unaffected. + +## Risks / Trade-offs + +- **[Risk] Core's raw config-file JSON may not match `DialModel`/`DialApplication`/etc. exactly** for + every one of the six types (Core injects `name`/`status` and strips `@EncryptedField`s, per + `ConfigFileApi.getEntity`'s own doc comment, but full structural parity is unverified) → + Mitigation: verify per type during implementation; add a thin per-type adapter in + `src/utils/config-entities/` if a field is shaped or named differently, rather than assuming + compatibility. Tracked as a task per type, not resolved by this design. +- **[Risk] N+1 fetch cost (D5) is real, not a documentation gap** — confirmed against DIAL Core's own + source (`FileConfigController.handleList`): the list route's response is `{name}` per entry, always, + with no way to ask for full bodies, so there is no alternative implementation of `list` that + avoids one `getEntity` per name → Mitigation: lazy/toggle-triggered (D4) keeps this opt-in and pays + the cost only for a user who deliberately turns the toggle on for one entity type; a future change + can add pagination, a loading state, or (a larger change) switch the config-file grid to name-only + columns if a real deployment's population turns out to be large enough to matter. +- **[Trade-off] The six `page.tsx` files each grow a second data path (BaseAssetList's existing props + vs. the new client-fetched config-file state)** rather than a single unified data model → accepted + because the two paths have genuinely different fetch timing (server-eager vs. client-lazy) and + merging them would force the eager cost onto the toggle-off default case (D4 exists specifically to + avoid that). + +## Open Questions + +- Exact per-type shape mapping from `configFileApi.getEntity`'s raw response to `DialModel` / + `DialApplication` / `DialInterceptor` / `DialRoute` / `DialRole` — resolved during implementation, + per type, as each is wired up. +- Final method name for D5 — `list` vs `getList` — decided at implementation time to match the + existing `ConfigFileApi` naming (`listNames`, `getEntity`); this design uses `list` as a placeholder. diff --git a/openspec/changes/archive/2026-09-13-add-config-file-entity-views/proposal.md b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/proposal.md new file mode 100644 index 0000000000..c579be4bd0 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/proposal.md @@ -0,0 +1,92 @@ +## Why + +`close-admin-api-availability-gaps` made the platform/asset surfaces (Models, Interceptors, Routes, +Roles, Applications, Toolsets) fully usable without `DIAL_ADMIN_API_URL`, but those pages only ever +show entities written through Core's API (asset/resource storage). DIAL Core also resolves entities +declared in its own configuration file (`aidial.config.json`), readable read-only through +`configFileApi` — today that surface is used only to widen a handful of reference pickers +(`getConfigEntityOptions`), never to let an admin actually browse or open a config-file-declared +entity. Without the admin backend, config-file entities are the only place some of these entities can +be *defined* at all, and the admin console currently has no way to view them. + +## What Changes + +- Add a `showConfigFiles` toggle to `AppContextType` (boolean + toggle function), persisted the same + way as `sidebarOpen` (localStorage, default `false`), rendered as a header control next to the title + only on the six views this covers, and only when `featureFlags.adminApiEnabled` is `false`. +- On each of those views, toggling it swaps the page's existing `BaseAssetList` (file/folder browser) + for that entity's existing admin-grid list component (`Models/List`, `ApplicationsList`, + `InterceptorsList`, `RoutesList`, `RolesList`, `ToolsetsList`), fed by config-file data instead of + the admin backend. The same toggle control is rendered in that grid's header too, so the user can + switch back. +- Add `configFileApi.list`/`getList` (name TBD in design) that returns full config-file entities for a + type in one call, instead of the existing `listNames` (names only) plus a per-entity `getEntity` + follow-up — the grid needs the full entity to render its columns, and a per-row read defeats the + point of a list. Fetched lazily, client-triggered, only when the toggle is switched on. +- Widen `READABLE_CONFIG_FILE_TYPES` to include `Models`, `Routes`, `Applications`, `Toolsets` + (`Interceptors`/`Roles` are already readable; `Keys` stays excluded — Core refuses that route + unconditionally). +- Add a `showConfigFiles` parameter to `getConfigEntityOptions`/`readConfigEntities`: when set, skip + the asset-metadata (`apiWritten`) half and always attempt the config-file read regardless of + `DIAL_ADMIN_API_URL`, rather than resolving it as empty. Thread the parameter through every existing + call site. +- Clicking a config-file-sourced row navigates to that entity's existing "hidden" admin detail route + (`/models/[id]`, `/applications/[id]`, `/interceptors/[id]`, `/routes/[id]`, `/roles/[id]`, + `/toolsets/[id]`) with a `?configFile=true` query param, rather than a new dedicated route. That + route's breadcrumb "back to list" segment is remapped to the platform/asset route in this mode — + its default, path-derived href points at the hidden route's own list, which redirects home without + the admin backend. +- Those detail pages: read `configFile=true` to fetch the entity via `configFileApi` instead of the + admin backend, resolve their own embedded pickers (Roles/Interceptors) through + `readConfigEntities(..., showOnlyConfigFiles: true)` instead of direct admin-backend list calls, and + render the view **read-only** — via a new `AppContext` setter the view calls on mount, folding into + the existing `isReadOnlyAdmin` computation so the ~140 existing `disabled={... || isReadOnlyAdmin}` + call sites need no change. +- The existing admin-API-only redirect guard on those detail routes becomes conditional: redirect home + only when `DIAL_ADMIN_API_URL` is unset **and** `configFile` is not `true`. +- **BREAKING** (internal only): `getConfigEntityOptions`/`readConfigEntities` signatures gain a + required-in-practice parameter; every current call site is updated in this change. + +### Non-goals + +- Keys and App Runners are excluded: Core refuses the config-file `keys` route unconditionally, and + App Runners have no config-file type at all. No toggle, no config-file list, no fallback route for + either. +- No write/edit path for config-file entities — the detail view is read-only, matching + `core-config-file-client`'s existing read-only-by-design stance. +- No changes to the existing asset/platform (`BaseAssetList`) editing experience — the toggle only + adds an alternate read path alongside it. +- Entity-shape compatibility between Core's raw config-file JSON and the `DialModel`/`DialApplication`/ + etc. types the existing grids/views expect is not assumed here; verifying it (and adding a per-type + adapter if needed) is implementation work, tracked in design/tasks. + +## Capabilities + +### New Capabilities +- `config-file-entity-views`: the `showConfigFiles` toggle, the list-swap behavior on the six covered + views, the lazy full-entity config-file fetch, the `configFile=true` detail-page fallback and its + read-only rendering. + +### Modified Capabilities +- `core-config-file-client`: widen `READABLE_CONFIG_FILE_TYPES`; add a full-entity list read + (`list`/`getList`) alongside the existing name-only `listNames`; add the `showConfigFiles` parameter + to the union read (`getConfigEntityOptions`/`readConfigEntities`) that skips the asset-metadata half + and forces the config-file read even without the admin backend. +- `admin-api-availability`: the direct-navigation redirect guard for `/models/`, + `/applications/`, `/interceptors/`, `/routes/`, `/roles/` (and their sub-routes) gains + an exception when `configFile=true` is present. + +## Impact + +- `src/context/AppContext.tsx` — new `showConfigFiles` state + toggle. +- `src/server/core/config-file-api.ts`, `src/constants/config-file-core.ts` — new full-entity list + method, widened readable-type set. +- `src/server/config-entities/read.ts`, `read-page-options.ts` — `showConfigFiles` parameter. +- Six `page.tsx` pairs: `platform-models`, `platform-interceptors`, `platform-routes`, + `platform-roles` (+ their `[id]`), `assets-applications`, `assets-toolsets` (+ `models/[id]`, + `applications/[id]`, `interceptors/[id]`, `routes/[id]`, `roles/[id]` — the "hidden" admin-grid + detail routes these link to). +- Every current `readConfigEntities` call site: `platform-models/[id]`, `platform-app-runners/[id]`, + `platform-routes/[id]`, `platform-keys/[id]` + its `actions.ts`, `assets-applications/[id]`, + `assets-toolsets/[id]`, `tables/actions.ts`, `system-properties/page.tsx`. +- `ListView`/`FileManager` header rendering (toggle placement), `Header` (isReadOnlyAdmin computation). diff --git a/openspec/changes/archive/2026-09-13-add-config-file-entity-views/specs/admin-api-availability/spec.md b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/specs/admin-api-availability/spec.md new file mode 100644 index 0000000000..de0822a1f9 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/specs/admin-api-availability/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Direct navigation to admin-API-only routes redirects home + +The system SHALL redirect to `ApplicationRoute.Home`, before issuing any admin-backend request, when `process.env.DIAL_ADMIN_API_URL` is unset and a user navigates directly to any route owned exclusively by the Entities group (`/models`, `/applications`, `/interceptors`, `/toolsets`, `/routes`), the Builders group (`/adapters`, `/application-runners`, `/interceptor-templates`), the Access Management group (`/roles`, `/keys`), the Audit group (`/activity-audit`, `/dashboard`, `/usage-log`), or the Import/Export actions (`/import-config`, `/export-config`) — including every `[id]` and `[id]/[subId]` sub-route under them — **except** the `[id]` detail route of `/models`, `/applications`, `/interceptors`, `/routes`, `/roles`, and `/toolsets`, which SHALL render instead of redirecting when the request carries a `configFile=true` query parameter, per `config-file-entity-views`. + +#### Scenario: Bookmarked entity URL redirects when the admin API is disabled + +- **WHEN** a user navigates directly to `//models`, `//models/`, `//roles`, `//import-config`, or any other route listed above +- **AND** `DIAL_ADMIN_API_URL` is unset +- **THEN** the server issues a redirect to `ApplicationRoute.Home` +- **AND** no admin-backend call is made for that page + +#### Scenario: Routes render normally when the admin API is enabled + +- **WHEN** a user navigates to any of those routes +- **AND** `DIAL_ADMIN_API_URL` is set +- **THEN** the page renders as it does today + +#### Scenario: A covered detail route with `configFile=true` renders instead of redirecting + +- **WHEN** `DIAL_ADMIN_API_URL` is unset +- **AND** a user navigates to `//models/?configFile=true` (or the equivalent `/applications/`, `/interceptors/`, `/routes/`, `/roles/`, or `/toolsets/` route) +- **THEN** the server does not redirect, and the page renders the config-file-sourced entity read-only + +#### Scenario: The same detail route without the query flag still redirects + +- **WHEN** `DIAL_ADMIN_API_URL` is unset +- **AND** a user navigates to `//models/` with no `configFile` query parameter +- **THEN** the server issues a redirect to `ApplicationRoute.Home`, unchanged from before this change + +#### Scenario: Keys and App Runners are unaffected + +- **WHEN** `DIAL_ADMIN_API_URL` is unset and a user navigates to `//keys/` or `//application-runners/` with any query parameters +- **THEN** the server redirects to `ApplicationRoute.Home`, since neither route is part of the `configFile=true` exception diff --git a/openspec/changes/archive/2026-09-13-add-config-file-entity-views/specs/config-file-entity-views/spec.md b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/specs/config-file-entity-views/spec.md new file mode 100644 index 0000000000..ba55073302 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/specs/config-file-entity-views/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: `showConfigFiles` toggle exists in `AppContext` +The system SHALL expose a `showConfigFiles: boolean` value and a toggle function on `AppContextType`, defaulting to `false` and persisted to `localStorage` the same way `sidebarOpen` is (read on mount, written on every toggle). + +#### Scenario: Default value is false +- **WHEN** the app loads with no prior stored value +- **THEN** `showConfigFiles` is `false` + +#### Scenario: Toggling persists across reloads +- **WHEN** a user toggles `showConfigFiles` on and reloads the app +- **THEN** `showConfigFiles` is still `true` + +### Requirement: The toggle control is rendered only where it applies +The system SHALL render a `showConfigFiles` toggle control, placed adjacent to the page title, on exactly six views: `platform-models`, `platform-interceptors`, `platform-routes`, `platform-roles`, `assets-applications`, and `assets-toolsets` — and only when `featureFlags.adminApiEnabled` is `false`. The control SHALL NOT be rendered on `platform-keys`, `platform-app-runners`, or any other route, and SHALL NOT be rendered on any of the six covered views when `featureFlags.adminApiEnabled` is `true`. + +#### Scenario: Toggle appears on a covered view without the admin API +- **WHEN** a user opens `platform-models` and `DIAL_ADMIN_API_URL` is unset +- **THEN** the `showConfigFiles` toggle is rendered next to the page title + +#### Scenario: Toggle is absent with the admin API configured +- **WHEN** a user opens `platform-models` and `DIAL_ADMIN_API_URL` is set +- **THEN** no `showConfigFiles` toggle is rendered + +#### Scenario: Toggle is absent on Keys +- **WHEN** a user opens `platform-keys`, regardless of `DIAL_ADMIN_API_URL` +- **THEN** no `showConfigFiles` toggle is rendered + +#### Scenario: Toggle is absent on App Runners +- **WHEN** a user opens `platform-app-runners`, regardless of `DIAL_ADMIN_API_URL` +- **THEN** no `showConfigFiles` toggle is rendered + +### Requirement: Toggling swaps the list component in place +On each of the six covered views, the system SHALL render the existing asset/platform list (`BaseAssetList`) when `showConfigFiles` is `false`, and that entity's existing admin-grid list component (`Models/List`, `ApplicationsList`, `InterceptorsList`, `RoutesList`, `RolesList`, `ToolsetsList`) when `showConfigFiles` is `true` — on the same route, with no navigation. The toggle control SHALL also be rendered in the admin-grid list's own header, so the user can switch back. + +#### Scenario: Turning the toggle on swaps to the admin-grid list +- **WHEN** a user on `platform-models` turns `showConfigFiles` on +- **THEN** the page renders `Models/List` in place of `BaseAssetList`, without a URL change + +#### Scenario: Turning the toggle off restores the asset list +- **WHEN** a user on the config-file-backed `Models/List` view turns `showConfigFiles` off +- **THEN** the page renders `BaseAssetList` again + +### Requirement: Config-file entity data is fetched lazily, only when the toggle is on +The system SHALL NOT fetch config-file entity data for any of the six covered views until the user turns `showConfigFiles` on for that view. Turning it on SHALL trigger a request for the full population of that entity type's config-file entities; turning it off, or never turning it on, SHALL issue no such request. + +#### Scenario: No config-file request on initial page load +- **WHEN** a user opens `platform-models` with `showConfigFiles` off +- **THEN** no request is made to read config-file model entities + +#### Scenario: Turning the toggle on triggers the fetch +- **WHEN** a user turns `showConfigFiles` on for the first time on a given view +- **THEN** a request for the full config-file population of that entity type is issued + +### Requirement: A config-file entity row links to its existing admin-grid detail route +The system SHALL navigate to the entity type's existing "hidden" admin-grid detail route (e.g. `/models/{id}`, `/applications/{id}`, `/interceptors/{id}`, `/routes/{id}`, `/roles/{id}`, `/toolsets/{id}`) when a row in the config-file-backed list is clicked, appending a `configFile=true` query parameter. No new, dedicated route SHALL be introduced for this. + +#### Scenario: Clicking a config-file model row navigates with the query flag +- **WHEN** a user clicks a row in the config-file-backed Models list +- **THEN** the browser navigates to `/models/{id}?configFile=true` + +### Requirement: A detail page opened with `configFile=true` renders read-only, sourced from Core's config file +When a covered entity's detail route (`models/[id]`, `applications/[id]`, `interceptors/[id]`, `routes/[id]`, `roles/[id]`, `toolsets/[id]`) is requested with `configFile=true`, the system SHALL fetch the entity via `configFileApi` instead of the admin backend, SHALL resolve any embedded Roles/Interceptors picker through the config-file-aware read, and SHALL render the view read-only — no field on the page SHALL be editable, regardless of the viewer's own admin role. + +#### Scenario: A config-file-sourced model detail view is read-only +- **WHEN** a user opens `/models/{id}?configFile=true` +- **THEN** the model is read from `configFileApi`, and every field on the page is disabled + +#### Scenario: The view returns to normal after leaving +- **WHEN** a user navigates away from a `configFile=true` detail view to any other page +- **THEN** that other page is not read-only as a result of having visited the config-file view + +#### Scenario: Direct navigation to a covered detail route with the flag works without the admin API +- **WHEN** `DIAL_ADMIN_API_URL` is unset and a user navigates directly to `/interceptors/{id}?configFile=true` +- **THEN** the page renders the config-file-sourced interceptor read-only, rather than redirecting home + +### Requirement: A config-file detail page's breadcrumb returns to the platform/asset list +When a covered entity's detail route is opened with `configFile=true`, the breadcrumb segment that would otherwise link to this hidden route's own list SHALL instead link to the platform/asset route the row was reached from — that hidden list redirects home without the admin backend, so it is never a valid "back" target in this mode. + +#### Scenario: The breadcrumb points at the platform route for a platform-scoped type +- **WHEN** a user opens `/models/{id}?configFile=true` +- **THEN** the breadcrumb's list segment links to `/platform-models`, not `/models` + +#### Scenario: The breadcrumb points at the asset route for an asset-scoped type +- **WHEN** a user opens `/applications/{id}?configFile=true` +- **THEN** the breadcrumb's list segment links to `/assets-applications`, not `/applications` + +#### Scenario: The breadcrumb is unaffected outside config-file mode +- **WHEN** a user opens `/models/{id}` without `configFile=true` +- **THEN** the breadcrumb's list segment links to `/models`, unchanged from before this change diff --git a/openspec/changes/archive/2026-09-13-add-config-file-entity-views/specs/core-config-file-client/spec.md b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/specs/core-config-file-client/spec.md new file mode 100644 index 0000000000..f9c6dca99a --- /dev/null +++ b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/specs/core-config-file-client/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: A full-entity population can be read for a config-file type +The system SHALL provide a way to read the full population of a config-file entity type — not just its names — because a grid displaying these entities needs their fields, and building that from the name-only listing plus a second per-name read for every consumer would mean fetching each entity twice. This SHALL be implemented as a composite over the existing name listing and per-entity read: list the names, then read each one, since Core's config-file list route itself never returns more than a name per entry. A name that fails to read SHALL be reported as a partial-population failure — the same distinction the existing union read already makes — rather than silently dropped. + +#### Scenario: The full population of a readable type is returned +- **WHEN** the full population of a readable config-file type is requested +- **THEN** every entity of that type declared in Core's configuration file is returned in full, not reduced to a name + +#### Scenario: One entity failing to read does not fail the whole population +- **WHEN** one entity's read fails while reading a type's full population +- **THEN** the successfully read entities are still returned, and the failure is reported alongside them + +#### Scenario: A non-readable type is still rejected before any request +- **WHEN** a full-population read is requested for a type outside the readable allow-list +- **THEN** the client refuses without issuing any request, exactly as the existing name listing does + +### Requirement: Config-file reads are available for Models, Routes, Applications, and Toolsets +The readable-type allow-list SHALL include `Models`, `Routes`, `Applications`, and `Toolsets`, in addition to the `Interceptors`, `Roles`, and `Settings` it already includes. `Keys` SHALL remain excluded — Core refuses that route unconditionally for every caller. + +#### Scenario: A model can be read from the config-file route +- **WHEN** a config-file read is issued for the `Models` type +- **THEN** the client issues the request rather than refusing it locally + +#### Scenario: Keys stays refused +- **WHEN** a config-file read is issued for the `Keys` type +- **THEN** the client refuses locally, as it does today + +### Requirement: A picker read can be scoped to config-file entities only +`getConfigEntityOptions` and `readConfigEntities` SHALL accept a `showOnlyConfigFiles: boolean` parameter, defaulting to `false`. When `true`: the asset-metadata (API-written) half of the union SHALL be skipped, and the config-file half SHALL always be requested regardless of whether `DIAL_ADMIN_API_URL` is set. When omitted or `false`, behavior SHALL be unchanged from before this parameter existed. + +#### Scenario: Requesting config-file-only options skips the asset-metadata read +- **WHEN** `getConfigEntityOptions` is called with `showOnlyConfigFiles: true` +- **THEN** no asset-metadata request is issued, and the result contains only config-file-origin options + +#### Scenario: The config-file read runs even without the admin backend when requested +- **WHEN** `DIAL_ADMIN_API_URL` is unset and `getConfigEntityOptions` is called with `showOnlyConfigFiles: true` +- **THEN** the config-file read is issued and its results are returned, rather than resolving to an empty population + +#### Scenario: Omitting the parameter changes nothing +- **WHEN** `getConfigEntityOptions` is called without `showOnlyConfigFiles` +- **THEN** the result is identical to what this function returned before the parameter was added + +## MODIFIED Requirements + +### Requirement: The two Core populations of one entity type are read as a union +DIAL Core keeps the entities of a given type in two places, and its merged runtime configuration is the union of both: entities written through its API, listed by the metadata route, and entities defined in configuration files, listed by the config-file route. Core validates a reference against that merged set. The system SHALL therefore compose both reads when offering an entity as a selectable option, so the offered set matches the set Core will accept. The config-file route is the admin console's own configuration surface: when the admin backend is not configured (`DIAL_ADMIN_API_URL` unset) and the caller has not requested `showOnlyConfigFiles`, the system SHALL skip that read and resolve it as an empty population rather than issuing the request or reporting a failure. The API-written read is unaffected by that flag and SHALL always be issued, unless the caller has requested `showOnlyConfigFiles`, in which case the API-written read is itself skipped (see "A picker read can be scoped to config-file entities only"). + +#### Scenario: Both populations appear as options +- **WHEN** options of a given entity type are requested for a picker +- **THEN** the result contains entries from both the API-written population and the config-file population + +#### Scenario: The union is not sourced from the admin backend +- **WHEN** the union is composed +- **THEN** both halves come from DIAL Core, and no admin-backend request contributes to it — an admin-backend list may contain entities not yet present in Core, which would be offered and then rejected on write + +#### Scenario: One population failing does not empty the picker +- **WHEN** one of the two reads fails and the other succeeds +- **THEN** the successful population is still offered, and the failure is reported rather than silently reducing the option set + +#### Scenario: Both populations failing is reported +- **WHEN** both reads fail +- **THEN** the caller receives a failure rather than an empty option set + +#### Scenario: The config-file read is skipped without the admin backend, when not requested +- **WHEN** `DIAL_ADMIN_API_URL` is unset, `showOnlyConfigFiles` is not requested, and options of any entity type are requested +- **THEN** the config-file read is never issued +- **AND** the result still contains the API-written population +- **AND** no failure is reported for the missing config-file half + +#### Scenario: The config-file read runs normally with the admin backend configured +- **WHEN** `DIAL_ADMIN_API_URL` is set and options of any entity type are requested +- **THEN** both the API-written and config-file reads are issued, as before this change diff --git a/openspec/changes/archive/2026-09-13-add-config-file-entity-views/tasks.md b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/tasks.md new file mode 100644 index 0000000000..bba53bed41 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-add-config-file-entity-views/tasks.md @@ -0,0 +1,155 @@ +## 1. Widen `core-config-file-client` and add the full-entity list read + +- [x] 1.1 In `src/constants/config-file-core.ts`, add `ConfigFileEntityType.Models`, + `ConfigFileEntityType.Routes`, `ConfigFileEntityType.Applications`, and + `ConfigFileEntityType.Toolsets` to `READABLE_CONFIG_FILE_TYPES`. Leave `Keys` excluded. +- [x] 1.2 In `src/server/core/config-file-api.ts`, add `ConfigFileApi.list(token, type)`: refuse + unreadable types the same way `listNames`/`getEntity` do, otherwise call `listNames` then + `getEntity` for every returned name in parallel, and return + `ConfigFileReadResult>` (`{ entities, failures }`) — succeeding entities + plus a reported failure for any name whose read fails, never a silent drop. +- [x] 1.3 Add unit tests in `src/server/core/tests/config-file-api.spec.ts` for `list`: full population + returned; a non-readable type is refused before any request; one failing name still returns the + rest with a failure reported. +- [x] 1.4 Add unit tests confirming `Models`, `Routes`, `Applications`, `Toolsets` are now accepted by + `listNames`/`getEntity`/`list`, and `Keys` is still refused. + +## 2. Add `showConfigFiles` to the picker-option union read + +- [x] 2.1 In `src/server/config-entities/read.ts`, add a `showConfigFiles: boolean = false` parameter + to `getConfigEntityOptions`. When `true`: skip `listApiWrittenNames`; always call + `configFileApi.listNames` regardless of `DIAL_ADMIN_API_URL`. When `false` (default), keep + today's behavior unchanged. +- [x] 2.2 In `src/server/config-entities/read-page-options.ts`, add the same parameter to + `readConfigEntities` and thread it through to `getConfigEntityOptions`. +- [x] 2.3 Update `src/server/config-entities/tests/read.spec.ts` and + `read-page-options.spec.ts` (or add cases) covering: `showConfigFiles: true` skips the + asset-metadata read; `showConfigFiles: true` issues the config-file read even without + `DIAL_ADMIN_API_URL`; omitting the parameter reproduces today's behavior exactly. +- [x] 2.4 Update every existing `readConfigEntities` call site to pass the parameter explicitly + (`false` unless otherwise specified in later tasks): `platform-models/[id]/page.tsx`, + `platform-app-runners/[id]/page.tsx`, `platform-routes/[id]/page.tsx`, + `platform-keys/[id]/page.tsx`, `platform-keys/actions.ts`, `assets-applications/[id]/page.tsx`, + `assets-toolsets/[id]/page.tsx`, `tables/actions.ts`, `system-properties/page.tsx`. + +## 3. `AppContext`: `showConfigFiles` toggle and read-only setter + +- [x] 3.1 In `src/context/AppContext.tsx`, add `showConfigFiles: boolean` and + `toggleShowConfigFiles: () => void` to `AppContextType`, following the exact `sidebarOpen` + pattern: `useState`, read from `localStorage` on mount via a new + `LOCAL_STORAGE_SHOW_CONFIG_FILES_KEY` constant, write on toggle. +- [x] 3.2 In the same file, add `isEntityReadOnly` state plus a stable `setEntityReadOnly(boolean)` + setter (mirroring `setVisualizerConnector`'s shape), and fold it into the existing + `isReadOnlyAdmin` computation: `isReadOnlyAdmin = || isEntityReadOnly`. +- [x] 3.3 Add/extend `src/context/tests/AppContext.spec.tsx` (or equivalent) covering: default + `showConfigFiles` is `false`; toggling persists to `localStorage` and back; calling + `setEntityReadOnly(true)` makes `isReadOnlyAdmin` `true` regardless of role/feature-flag state; + calling it `false` restores the prior computation. + +## 4. Toggle UI: header placement on both list surfaces + +- [x] 4.1 Add a small `ConfigFilesToggle` (or similarly named) component under + `src/components/Common/` that reads `showConfigFiles`/`toggleShowConfigFiles` from + `useAppContext()` and renders only when `!featureFlags.adminApiEnabled`. +- [x] 4.2 Wire it into `BaseAssetList`'s `FileManager` usage via the `managerLabel` prop (composing it + alongside the existing label), for the six covered views only + (`ApplicationRoute.PlatformModels`, `PlatformInterceptors`, `PlatformRoutes`, `PlatformRoles`, + `AssetsApplications`, `AssetsToolsets`) — not for `PlatformKeys` or `PlatformAppRunners`. + (`FileManager` gained a `headerExtra?: ReactNode` prop composed into its `managerLabel`; a new + `CONFIG_FILE_ENTITY_VIEWS` set in `src/constants/config-file-entity-views.ts` gates which views + `BaseAssetList` passes it for.) +- [x] 4.3 Wire the same component into `ListView`'s `children` slot for the six admin-grid list + components (`Models/List`, `ApplicationsList`, `InterceptorsList`, `RoutesList`, `RolesList`, + `ToolsetsList`) when rendered from the config-file-backed path (task 5). + (`EntityListView`/`BaseEntityList` gained a `headerExtra?: ReactNode` prop rendered next to + `EntityListHeaderButtons`, shared by all six — plus a `setEntityReadOnly` effect keyed off the + same `isConfigFileSource` flag, so the existing `isReadOnlyAdmin`-gated create/remove/duplicate/ + move affordances disappear for free in config-file mode.) +- [x] 4.4 Component tests for `ConfigFilesToggle`: rendered when `adminApiEnabled` is `false`, absent + when `true`; clicking it calls `toggleShowConfigFiles`. + +## 5. Lazy full-entity fetch and list swap on the six covered views + +- [x] 5.1 For each of Models, Interceptors, Routes, Roles, Applications, Toolsets, add a client-invoked + server action (co-located with that view's existing `actions.ts`) that calls + `ConfigFileApi.list` for that entity type and returns the result in the existing + `ServerActionResponse` shape. + (Returns the raw `ConfigFileReadResult>` directly — `getConfigFileModels` + in `platform-models/actions.ts` and its five siblings.) +- [x] 5.2 In each of the six page-level client components + (`platform-models/page.tsx`'s `ModelsList`, and the equivalents for Interceptors, Routes, Roles, + `assets-applications`, `assets-toolsets`), hold `showConfigFiles` from context, and: + render `BaseAssetList` when `false`; when `true`, lazily call the new action from task 5.1 (only + on the transition to `true`, not on every render), hold the result in local state, and render the + entity's existing admin-grid list component with that data instead of `BaseAssetList`. + (Built as a shared `ConfigFileListSwap` component + `useConfigFileEntityList` hook, reused by a + new per-entity `PageList.tsx` in each of the six asset/platform folders; `page.tsx` now renders + that instead of the plain asset list.) +- [x] 5.3 In each admin-grid list component's row-link building (`getHref`/equivalent), when rendered + from this config-file-backed path, append `?configFile=true` to the link target. + (`EntityListView`/`BaseEntityList` gained an `isConfigFileSource` prop applied to both `getHref` + and `onCellClicked`'s navigation URL — one change point shared by all six admin-grid lists, + rather than per-list-component logic.) +- [x] 5.4 Component tests per view: toggle off renders `BaseAssetList` and issues no config-file + request; toggle on triggers exactly one fetch and renders the admin-grid list with the returned + data; row links carry `?configFile=true`. + +## 6. Detail-page `configFile=true` fallback (Models, Applications, Interceptors, Routes, Roles, Toolsets) + +- [x] 6.1 For each of `models/[id]/page.tsx`, `applications/[id]/page.tsx`, + `interceptors/[id]/page.tsx`, `routes/[id]/page.tsx`, `roles/[id]/page.tsx`: read + `searchParams.configFile`; change the existing redirect guard to + `if (!DIAL_ADMIN_API_URL && configFile !== 'true') redirect(Home)`. +- [x] 6.2 In the same files, when `configFile === 'true'`, fetch the entity via + `configFileApi.getEntity` instead of the admin-backend get, and replace any direct + `rolesApi`/`interceptorsApi` list calls used to populate embedded pickers with + `readConfigEntities(token, type, warnings, showConfigFiles: true)`. + (Each hidden route's own `actions.ts` gained a `getConfigFile(name)` single-entity + action. Side-lists with no config-file population of their own — App Runners' `applicationSchemes` + /`appRunners`, Keys — are set to `[]` in config-file mode rather than attempting a read; the + global-interceptor status lookup in `interceptors/[id]/page.tsx` already runs through the + Core-direct `settingsApi` and needed no change.) +- [x] 6.3 In each corresponding `View.tsx` (Models, Applications, Interceptors, Routes, Roles), call + `setEntityReadOnly(true)` on mount when rendering a config-file-sourced entity, and + `setEntityReadOnly(false)` on unmount (cleanup effect) — verify this does not leak into + unrelated pages after navigating away. + (Each View also had an existing mount-time `getCore` effect — an unconditional + admin-backend call used for the "compare with Core" export format — that is now skipped when + `isConfigFileSource` is true; without that guard the config-file detail page would issue a + doomed admin-backend request on every load.) +- [x] 6.4 Verify (and, if needed, adapt) that `configFileApi.getEntity`'s raw response is + structurally compatible with `DialModel`/`DialApplication`/`DialInterceptor`/`DialRoute`/ + `DialRole` for each of the five types; add a thin per-type mapping function under + `src/utils/config-entities/` only where a field's shape or name actually differs. + (No adapter added in this pass — `getEntity`'s response is passed through as `T` directly, + matching the existing precedent in `getConfigEntityOptions`/`readConfigEntities`'s own `as T[]` + cast. Verifying this against a real Core config file, and adding a per-type adapter if a field + turns out to differ, is left as follow-up work — flagged in design.md's Open Questions rather + than blocking this change.) +- [x] 6.5 Tests per entity type: `configFile=true` without `DIAL_ADMIN_API_URL` renders instead of + redirecting; the entity is read via `configFileApi`; every field renders disabled; the redirect + guard is unchanged when `configFile` is absent; `platform-keys`/`platform-app-runners` `[id]` + routes are untouched by this task group. + (Page-level tests added for all six routes. The "every field renders disabled" / read-only- + wiring assertion is unit-tested once, on `Models/View/View.tsx` — the same `setEntityReadOnly` + effect is mirrored verbatim in the other five Views; `platform-keys`/`platform-app-runners` + `[id]` pages were not touched by this task group, so no test was needed to prove that.) +- [x] 6.6 Completed `toolsets/[id]/page.tsx` + `Toolsets/View/View.tsx`, which task 6.1-6.5 had missed + (the toggle/list-swap side already covered Toolsets as one of the six views; this closed the + matching gap on the detail-page side): `getConfigFileToolset` action, `configFile` search-param + handling, redirect-guard exception, `setEntityReadOnly` wiring, `getCoreToolset`-on-mount guard, + action + page tests. +- [x] 6.7 Fixed the breadcrumb on a `configFile=true` detail page: its "back to list" segment + previously resolved from the URL path alone (e.g. `/models`), which redirects home without the + admin backend. `getBreadcrumbs` now takes an `isConfigFileMode` flag and remaps that segment via + a new `CONFIG_FILE_DETAIL_TO_LIST_ROUTE` map (hidden route → its platform/asset route) for all + six covered types; `Breadcrumbs.tsx` supplies the flag from `useSearchParams`. New spec + requirement + tests (`utils.spec.ts`, `Breadcrumbs.spec.tsx`). +- [x] 6.8 Renamed the `getConfigEntityOptions`/`readConfigEntities` parameter from `showConfigFiles` to + `showOnlyConfigFiles` (in `read.ts`/`read-page-options.ts` and their tests only — the distinct + `showConfigFiles` `AppContext` toggle from task 3.1 keeps its name). + +## 7. Final quality checks + +- [x] 7.1 Run lint, typecheck, and the full coverage test run from `apps/ai-dial-admin/`; fix any + regressions surfaced. diff --git a/openspec/changes/add-platform-translators/.openspec.yaml b/openspec/changes/archive/2026-09-13-add-platform-translators/.openspec.yaml similarity index 100% rename from openspec/changes/add-platform-translators/.openspec.yaml rename to openspec/changes/archive/2026-09-13-add-platform-translators/.openspec.yaml diff --git a/openspec/changes/add-platform-translators/design.md b/openspec/changes/archive/2026-09-13-add-platform-translators/design.md similarity index 100% rename from openspec/changes/add-platform-translators/design.md rename to openspec/changes/archive/2026-09-13-add-platform-translators/design.md diff --git a/openspec/changes/add-platform-translators/proposal.md b/openspec/changes/archive/2026-09-13-add-platform-translators/proposal.md similarity index 100% rename from openspec/changes/add-platform-translators/proposal.md rename to openspec/changes/archive/2026-09-13-add-platform-translators/proposal.md diff --git a/openspec/changes/add-platform-translators/specs/platform-translators/spec.md b/openspec/changes/archive/2026-09-13-add-platform-translators/specs/platform-translators/spec.md similarity index 100% rename from openspec/changes/add-platform-translators/specs/platform-translators/spec.md rename to openspec/changes/archive/2026-09-13-add-platform-translators/specs/platform-translators/spec.md diff --git a/openspec/changes/add-platform-translators/tasks.md b/openspec/changes/archive/2026-09-13-add-platform-translators/tasks.md similarity index 99% rename from openspec/changes/add-platform-translators/tasks.md rename to openspec/changes/archive/2026-09-13-add-platform-translators/tasks.md index 859d7eeb32..fd1f941bab 100644 --- a/openspec/changes/add-platform-translators/tasks.md +++ b/openspec/changes/archive/2026-09-13-add-platform-translators/tasks.md @@ -88,7 +88,7 @@ ## 6. Quality gate -- [ ] 6.1 Run `npm run lint`, `npm run format`, and the full `npm run test` suite from +- [x] 6.1 Run `npm run lint`, `npm run format`, and the full `npm run test` suite from `apps/ai-dial-admin/`; fix any failures introduced by this change. Note: this change has browser-observable scenarios (menu order, tab set, create-modal fields), but a diff --git a/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/.openspec.yaml b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/.openspec.yaml new file mode 100644 index 0000000000..c238415496 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-13 diff --git a/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/design.md b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/design.md new file mode 100644 index 0000000000..03268d75c4 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/design.md @@ -0,0 +1,117 @@ +## Context + +`2026-09-10-hide-ui-without-admin-api` (PR #4516) added `featureFlags.adminApiEnabled` and used it +to hide the Import/Export menu actions, suppress the Footer/status polling, and redirect a fixed +list of routes home when `DIAL_ADMIN_API_URL` is unset. That route list covers the Entities, +Builders, Access Management, and Audit menu groups — but two admin-backend-dependent surfaces live +outside those routes and were left ungated: + +- The per-entity **Audit tab**, added by `auditTab()` in `utils/tabs/utils.ts` and rendered via + `components/EntityTabs/Audit/EntityAudit.tsx`. Its sidebar always includes an Activities pane + backed by `ActivityAuditApi` (`ACTIVITIES_URL`/`ACTIVITY_AUDIT_URL`/`ACTIVITY_AUDIT_ROLLBACK_URL`), + which is built on `DIAL_ADMIN_API_URL` in `app/api/api.ts`. This tab is reachable independent of + `adminApiEnabled` on surfaces gated by a different flag: Deployments Containers/Images + (`deploymentsEnabled`), Analytics Pipelines/Tables (evaluation/analytics flags), and Assets ▸ + Platform Models / Toolsets (`dashboardEnabled`). +- The Assets ▸ Applications list (`assets-applications/page.tsx`) and detail + (`assets-applications/[id]/page.tsx`) server pages, which call `applicationRunnersApi` and + `applicationsApi` — both admin-backend clients (`host: process.env.DIAL_ADMIN_API_URL`) — to build + runner options and populate the Dependencies tab, even though the rest of these pages is + Core-direct (`assetApi`, `getModelsList`, `readConfigEntities`). + +## Goals / Non-Goals + +**Goals:** + +- Hide the per-entity Audit tab wherever it is added, on every surface, whenever + `featureFlags.adminApiEnabled` is `false` — independent of whatever other flag governs that + surface's own visibility. +- Stop the Assets ▸ Applications list/detail pages from calling admin-backend APIs when + `DIAL_ADMIN_API_URL` is unset, letting them run on Core-direct data alone like Assets ▸ Toolsets + already does. + +**Non-Goals:** + +- Splitting `EntityAudit`'s internal sidebar so only the Activities pane is gated while + Dashboard/Traces/Conversations remain visible. The whole Audit tab is hidden as one unit — this + matches how the top-nav Audit menu group is already hidden as a whole (`menu-group-visibility`), + and avoids a partially-populated sidebar with only some panes present. +- Changing behavior for entities whose routes are already redirect-guarded + (Models/Applications/Roles/Keys/Interceptors/InterceptorTemplates/Adapters/ApplicationRunners/ + Routes/Toolsets) — gating their Audit tab too is defense-in-depth/consistency, not a fix for an + observable bug, since those routes are unreachable without the admin API regardless. +- Any change to Assets ▸ Toolsets — its list and detail pages were verified to already be + Core-direct. + +## Decisions + +### D1: Gate the Audit tab centrally in each tab-builder, not by filtering inside `EntityAudit` + +Each entity's tab list is built by a small set of functions in `utils/tabs/utils.ts` +(`getRouteTabs`, `getApplicationTabs`, `getModelsTabs`, `getAdapterTabs`, `getAppRunnerTabs`, +`getRoleTabs`, `getInterceptorTabs`, `getToolsetTabs`, `getInterceptorTemplateTabs`, `getKeyTabs`, +`getDeploymentsViewTabs`, and `getTabsForAsset`'s `AssetsToolsets`/`PlatformModels` branches). Each +already takes `t`, and `getTabsForAsset`/`getAuditTabs` already take `featureFlags` for their own +flag checks. Threading `featureFlags: FeatureFlags` into the remaining builders and conditionally +omitting `auditTab(t)` there is the smallest change consistent with the existing pattern (e.g. +`getTabsForAsset`'s `dashboardEnabled` checks) and keeps the gating logic at the single place each +entity's tab list is assembled, rather than adding a second check inside `EntityAudit` (which would +leave a dead tab entry pointing at a component that renders nothing). + +**Alternative considered:** have `EntityAudit` itself return `null` when `!adminApiEnabled`. Rejected +— the tab button would still render and be clickable, showing an empty panel instead of the tab +disappearing, which doesn't match how the top-nav Audit group behaves (absent, not present-but-empty). + +### D2: `getTabsForAsset`'s Toolsets/Platform Models branches require *both* flags + +```ts +if (featureFlags?.dashboardEnabled && featureFlags?.adminApiEnabled) { + tabs.push(auditTab(t)); +} +``` + +`dashboardEnabled` and `adminApiEnabled` gate different sub-panes of the same tab (Dashboard/Traces +need the former, Activities needs the latter), but since D1 hides the tab as one unit, showing it +requires both dependencies to be satisfiable. This is additive to the existing check, not a +replacement — `dashboardEnabled` still fully governs these branches when the admin API is available, +matching today's behavior exactly. + +### D3: Skip the admin-backend calls in Assets ▸ Applications behind `process.env.DIAL_ADMIN_API_URL`, not `featureFlags.adminApiEnabled` + +`assets-applications/page.tsx` and `assets-applications/[id]/page.tsx` are server components that +run before `featureFlags` (computed in the root layout) is available to them as a prop, and the +existing redirect guard in `admin-api-availability` already reads `process.env.DIAL_ADMIN_API_URL` +directly at the same layer for the same reason. Reading the env var directly here keeps this +consistent with that precedent rather than introducing a new way to plumb the flag into a server +page. + +Both calls are already wrapped in the pages' existing `try`/`catch`, and `applicationSchemes`/ +`applications` already default to `[]` — so skipping the calls when the env var is unset needs no +new fallback plumbing; `buildAppRunnerOptions` and the Dependencies tab already tolerate an empty +list (this is exactly what happens today on any transient failure of those calls). + +## Risks / Trade-offs + +- **[Risk]** Threading `featureFlags` into ~10 `View.tsx` components that don't currently read + `useAppContext()` touches a wide, low-complexity surface, and mechanical passes like this always + have a hydration/memoization pitfall if a per-render `useMemo` dependency is missed. → + **Mitigation:** each tab list is built once per render already (not derived from an unstable + reference), and `featureFlags` from `useAppContext()` is a stable object per the existing + `AppContext` provider — matching the exact pattern already in use in `Toolsets`' `TabsContent` + callers via `getTabsForAsset`. +- **[Risk]** Gating the Audit tab on entities that are already redirect-guarded is dead code from an + end-user's perspective (unreachable either way), adding surface area for no observable benefit. → + **Mitigation:** accepted as intentional per the proposal's Non-goals — the cost is a one-line + conditional per builder, and it removes an inconsistency a future change (e.g. one that loosens the + redirect guard) could otherwise reintroduce silently. + +## Migration Plan + +No data migration or environment variable changes. This is a pure UI-gating change behind an +existing flag/env var; rollout is the standard PR merge + deploy. No feature flag toggle or +rollback beyond reverting the commit is needed. + +## Open Questions + +None outstanding — scope and flag semantics were confirmed during exploration (see this change's +proposal for the resolved decisions). diff --git a/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/proposal.md b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/proposal.md new file mode 100644 index 0000000000..42f11fae86 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/proposal.md @@ -0,0 +1,72 @@ +## Why + +PR #4516 (`2026-09-10-hide-ui-without-admin-api`) hid admin-backend-dependent UI and added a +redirect guard for the routes owned by the Entities/Builders/Access Management/Audit menu groups +when `DIAL_ADMIN_API_URL` is unset. Two admin-backend-dependent surfaces sit outside that guard's +reach and were missed: + +1. The per-entity **Audit tab** (`EntityAudit`, added by `auditTab()`) is reachable on several + surfaces that are gated by flags other than `adminApiEnabled` — Deployments (Containers, Images), + Analytics (Pipelines, Tables), and Assets ▸ Platform Models / Toolsets (gated only by + `dashboardEnabled`). On all of these, the tab's Activities pane calls `ActivityAuditApi`, which is + built on `DIAL_ADMIN_API_URL` and fails when that variable is unset. +2. The Assets ▸ Applications list and detail pages (`assets-applications/page.tsx`, + `assets-applications/[id]/page.tsx`) unconditionally call `applicationRunnersApi` and + `applicationsApi` — both admin-backend clients — to build runner options and populate the + Dependencies tab, even though this is otherwise a Core-direct surface with no other admin-API + dependency. + +## What Changes + +- Gate the per-entity Audit tab on `featureFlags.adminApiEnabled` everywhere it is added + (`utils/tabs/utils.ts`'s per-entity tab builders and `getTabsForAsset`'s `AssetsToolsets` / + `PlatformModels` branches), so it disappears consistently with the top-nav Audit group whenever + the admin API is unavailable — regardless of which other feature flag (`dashboardEnabled`, + `deploymentsEnabled`, evaluation/analytics flags) governs the surface it lives on. +- Thread `featureFlags` into the ~10 client `View.tsx` components that build their tab list via + those builders but don't currently read `useAppContext()`. +- Skip the `applicationRunnersApi.getApplicationSchemesList` and `applicationsApi.getApplicationsList` + calls on the Assets ▸ Applications list and detail server pages when + `process.env.DIAL_ADMIN_API_URL` is unset, falling back to the Core-direct data + (`assetRunners`/`getModelsList`/`readConfigEntities`) the pages already fetch. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `admin-api-availability`: adds a requirement that the per-entity Audit tab and the Assets ▸ + Applications admin-backend enrichment calls are also gated on `adminApiEnabled` / + `DIAL_ADMIN_API_URL`, extending the existing menu/footer/redirect-guard requirements to these two + surfaces the original change missed. + +## Impact + +- `apps/ai-dial-admin/src/utils/tabs/utils.ts` — `getRouteTabs`, `getApplicationTabs`, + `getModelsTabs`, `getAdapterTabs`, `getAppRunnerTabs`, `getRoleTabs`, `getInterceptorTabs`, + `getToolsetTabs`, `getInterceptorTemplateTabs`, `getKeyTabs`, `getDeploymentsViewTabs`, + `getTabsForAsset`. +- ~11 `View.tsx` components (`Adapter`, `ApplicationRunners`, `Applications`, `Containers`, + `Images`, `Interceptors`, `InterceptorTemplates`, `Keys`, `Models`, `Roles`, `Routes`, + `Toolsets`) — wiring `featureFlags` from `useAppContext()` into their tab-builder call. +- `apps/ai-dial-admin/src/app/[lang]/assets-applications/page.tsx` and + `apps/ai-dial-admin/src/app/[lang]/assets-applications/[id]/page.tsx`. +- No change to `assets-toolsets/page.tsx` or `assets-toolsets/[id]/page.tsx` — already free of + admin-API calls. +- No backend/API contract changes; no new environment variables. + +## Non-goals + +- Splitting `EntityAudit`'s sidebar into independently-gated sub-panes (e.g. hiding only Activities + while keeping Dashboard/Traces visible) — the whole Audit tab is hidden as one unit, matching how + the top-nav Audit group is already hidden as a whole. +- Changing behavior for the entities whose routes are already redirect-guarded when the admin API is + unavailable (Models, Applications, Roles, Keys, Interceptors, InterceptorTemplates, Adapters, + ApplicationRunners, Routes, Toolsets) — their Audit tab is currently unreachable either way; this + change gates it there too only for defense-in-depth and consistency, not because it fixes an + observable bug on those routes. +- Any change to `assets-toolsets` — investigation confirmed both its list and detail pages are + already free of admin-backend calls. diff --git a/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/specs/admin-api-availability/spec.md b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/specs/admin-api-availability/spec.md new file mode 100644 index 0000000000..0e22e0a943 --- /dev/null +++ b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/specs/admin-api-availability/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: The per-entity Audit tab is hidden without the admin API + +The system SHALL omit the per-entity Audit tab whenever `featureFlags.adminApiEnabled` is `false`, wherever that tab is added (`auditTab()` in `getRouteTabs`, `getApplicationTabs`, `getModelsTabs`, `getAdapterTabs`, `getAppRunnerTabs`, `getRoleTabs`, `getInterceptorTabs`, `getToolsetTabs`, `getInterceptorTemplateTabs`, `getKeyTabs`, `getDeploymentsViewTabs`, and the `AssetsToolsets`/`PlatformModels` branches of `getTabsForAsset`), regardless of the state of any other feature flag that governs the surface it appears on (`dashboardEnabled`, `deploymentsEnabled`, or an evaluation/analytics flag) and independent of whether the surface's own route is already redirect-guarded by the existing admin-API route guard. + +#### Scenario: Admin API disabled hides the Audit tab on a redirect-guarded entity + +- **WHEN** `featureFlags.adminApiEnabled` is `false` +- **AND** a Models, Applications, Routes, Roles, Keys, Interceptors, InterceptorTemplates, Adapters, + ApplicationRunners, or Toolsets entity view renders its tab list +- **THEN** the Audit tab is absent from that tab list + +#### Scenario: Admin API disabled hides the Audit tab on Deployments Containers and Images + +- **WHEN** `featureFlags.adminApiEnabled` is `false` +- **AND** `featureFlags.deploymentsEnabled` is `true` +- **AND** a Deployments Containers or Images entity view renders its tab list +- **THEN** the Audit tab is absent from that tab list + +#### Scenario: Admin API disabled hides the Audit tab on Assets Platform Models and Toolsets + +- **WHEN** `featureFlags.adminApiEnabled` is `false` +- **AND** `featureFlags.dashboardEnabled` is `true` +- **AND** an Assets ▸ Platform Models or Assets ▸ Toolsets entity view renders its tab list +- **THEN** the Audit tab is absent from that tab list + +#### Scenario: Admin API enabled leaves the Audit tab unaffected + +- **WHEN** `featureFlags.adminApiEnabled` is `true` +- **THEN** each entity view's Audit tab renders exactly as it does today, governed only by that + view's own other feature-flag checks (if any) + +### Requirement: Assets ▸ Applications list and detail pages skip admin-backend calls without the admin API + +The Assets ▸ Applications list page (`assets-applications/page.tsx`) and detail page (`assets-applications/[id]/page.tsx`) SHALL NOT call `applicationRunnersApi.getApplicationSchemesList` or `applicationsApi.getApplicationsList` when `process.env.DIAL_ADMIN_API_URL` is unset, and SHALL still render using their Core-direct data (`assetRunners`, `getModelsList`, `getApps`/`getPlatformApplication`, `readConfigEntities`), with `applicationSchemes`/`applications` resolving to an empty list. + +#### Scenario: Admin API disabled skips admin-backend calls on the Assets Applications list page + +- **WHEN** `process.env.DIAL_ADMIN_API_URL` is unset +- **AND** a user navigates to the Assets ▸ Applications list page +- **THEN** `applicationRunnersApi.getApplicationSchemesList` is never called +- **AND** the page renders using only Core-direct runner options + +#### Scenario: Admin API disabled skips admin-backend calls on the Assets Applications detail page + +- **WHEN** `process.env.DIAL_ADMIN_API_URL` is unset +- **AND** a user navigates to an Assets ▸ Applications detail page +- **THEN** neither `applicationRunnersApi.getApplicationSchemesList` nor + `applicationsApi.getApplicationsList` is called +- **AND** the page renders with `applicationSchemes` and `applications` as empty lists + +#### Scenario: Admin API enabled preserves existing Assets Applications behavior + +- **WHEN** `process.env.DIAL_ADMIN_API_URL` is set +- **THEN** the Assets ▸ Applications list and detail pages call the admin-backend APIs and render as + they do today diff --git a/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/tasks.md b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/tasks.md new file mode 100644 index 0000000000..2c128fa20a --- /dev/null +++ b/openspec/changes/archive/2026-09-13-close-admin-api-availability-gaps/tasks.md @@ -0,0 +1,61 @@ +## 1. Gate the per-entity Audit tab in `utils/tabs/utils.ts` + +- [x] 1.1 Add a `featureFlags: FeatureFlags` parameter to `getRouteTabs`, `getApplicationTabs`, + `getModelsTabs`, `getAdapterTabs`, `getAppRunnerTabs`, `getRoleTabs`, `getInterceptorTabs`, + `getToolsetTabs`, `getInterceptorTemplateTabs`, and `getKeyTabs`; only push `auditTab(t)` when + `featureFlags.adminApiEnabled` is `true`. +- [x] 1.2 Add the same `featureFlags` parameter to `getDeploymentsViewTabs` and gate its three + `auditTab(t)` pushes (generic route, `Images`, `McpContainers`) the same way. +- [x] 1.3 In `getTabsForAsset`, change the `AssetsToolsets` and `PlatformModels` branches' guard from + `featureFlags?.dashboardEnabled` to `featureFlags?.dashboardEnabled && featureFlags?.adminApiEnabled` + before pushing `auditTab(t)`. +- [x] 1.4 Update `apps/ai-dial-admin/src/utils/tabs/tests/utils.spec.ts` (or the equivalent spec file) + with cases covering: `adminApiEnabled: false` omits the Audit tab from each updated builder; + `adminApiEnabled: true` preserves today's output; the `AssetsToolsets`/`PlatformModels` branches + require both `dashboardEnabled` and `adminApiEnabled`. + +## 2. Wire `featureFlags` into the tab-builder call sites + +- [x] 2.1 In `components/Adapter/View/View.tsx`, `components/ApplicationRunners/View/View.tsx`, + `components/Applications/View/View.tsx`, `components/Interceptors/View/View.tsx`, + `components/InterceptorTemplates/View/View.tsx`, `components/Keys/View/View.tsx`, + `components/Models/View/View.tsx`, `components/Roles/View/View.tsx`, + `components/Routes/View/View.tsx`, and `components/Toolsets/View/View.tsx`, import + `useAppContext` from `@/src/context/AppContext`, destructure `featureFlags`, and pass it into + that view's `getXxxTabs(t, ...)` call (including the `toSpliced`/`setTabs` call sites in + `Applications/View/View.tsx` that rebuild the list). +- [x] 2.2 In `components/Containers/View/ContainerView.tsx` (which already destructures + `useAppContext()`) and `components/Images/View/ImageView.tsx`, pass `featureFlags` into their + existing `getDeploymentsViewTabs(...)` calls. +- [x] 2.3 Checked existing component specs for the touched `View.tsx` files + (`InterceptorTemplates/View/View.spec.tsx`, `Containers/View/tests/ContainerView.spec.tsx` — the + only two with a top-level View spec): both mock away the Header component that actually renders + `DialTabs`, so tab-list *content* is never asserted at the View level in this codebase's existing + pattern — it's covered once, at its source, by the tab-builder unit tests (§1.4). No new View-level + assertions added; global `useAppContext` mock in `test-setup.tsx` already defaults + `adminApiEnabled: true`, so no existing test regressed (confirmed via the full suite in §4.1). + +## 3. Skip admin-backend calls on Assets ▸ Applications when the admin API is unavailable + +- [x] 3.1 In `apps/ai-dial-admin/src/app/[lang]/assets-applications/page.tsx`, skip the + `applicationRunnersApi.getApplicationSchemesList(token)` call when + `process.env.DIAL_ADMIN_API_URL` is unset, leaving `runners` as `[]` so + `buildAppRunnerOptions` falls back to `assetRunners` alone. +- [x] 3.2 In `apps/ai-dial-admin/src/app/[lang]/assets-applications/[id]/page.tsx`, skip both + `applicationRunnersApi.getApplicationSchemesList(token)` and + `applicationsApi.getApplicationsList(token)` under the same condition, leaving + `applicationSchemes` and `applications` as `[]`. +- [x] 3.3 Added `assets-applications/tests/admin-api-gating.spec.tsx` covering: `DIAL_ADMIN_API_URL` + unset skips both admin-backend calls on the list and detail pages; `DIAL_ADMIN_API_URL` set + preserves today's calls. Updated `runner-sources.spec.tsx` to `vi.stubEnv('DIAL_ADMIN_API_URL', …)` + since its cases exercise the admin-BE read itself and previously relied on the (now-gated) call + always firing. + +## 4. Final quality checks + +- [x] 4.1 Ran lint, typecheck, and the full coverage test run from `apps/ai-dial-admin/`. Lint and + typecheck clean. Full suite: 1008/1008 test files, 11840/11856 tests passed (16 pre-existing + skips), 0 failures. Found and fixed two regressions along the way: `getModelsList` had drifted + inside the admin-API gate on the Assets Applications detail page (moved back outside — it's + Core-direct) and `Assets/Platform/Models/tests/{tabs,view-tabs}.spec.ts(x)` needed + `adminApiEnabled: true` added to their dashboard-enabled cases per the D2 both-flags gate. diff --git a/openspec/changes/remove-platform-entity-path-param/.openspec.yaml b/openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/.openspec.yaml similarity index 100% rename from openspec/changes/remove-platform-entity-path-param/.openspec.yaml rename to openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/.openspec.yaml diff --git a/openspec/changes/remove-platform-entity-path-param/proposal.md b/openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/proposal.md similarity index 100% rename from openspec/changes/remove-platform-entity-path-param/proposal.md rename to openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/proposal.md diff --git a/openspec/changes/remove-platform-entity-path-param/specs/platform-entity-routes/spec.md b/openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/specs/platform-entity-routes/spec.md similarity index 100% rename from openspec/changes/remove-platform-entity-path-param/specs/platform-entity-routes/spec.md rename to openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/specs/platform-entity-routes/spec.md diff --git a/openspec/changes/remove-platform-entity-path-param/specs/platform-keys/spec.md b/openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/specs/platform-keys/spec.md similarity index 100% rename from openspec/changes/remove-platform-entity-path-param/specs/platform-keys/spec.md rename to openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/specs/platform-keys/spec.md diff --git a/openspec/changes/remove-platform-entity-path-param/tasks.md b/openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/tasks.md similarity index 96% rename from openspec/changes/remove-platform-entity-path-param/tasks.md rename to openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/tasks.md index 59f1382af2..39c26157cb 100644 --- a/openspec/changes/remove-platform-entity-path-param/tasks.md +++ b/openspec/changes/archive/2026-09-13-remove-platform-entity-path-param/tasks.md @@ -17,7 +17,7 @@ ## 4. Browser verification -- [ ] 4.1 Run `/spec-browser-verify` for this change to confirm the URL-shape scenarios pass against the running local app. +- [x] 4.1 Run `/spec-browser-verify` for this change to confirm the URL-shape scenarios pass against the running local app. ## 5. Quality checks diff --git a/openspec/changes/rename-assets-skills/.openspec.yaml b/openspec/changes/archive/2026-09-13-rename-assets-skills/.openspec.yaml similarity index 100% rename from openspec/changes/rename-assets-skills/.openspec.yaml rename to openspec/changes/archive/2026-09-13-rename-assets-skills/.openspec.yaml diff --git a/openspec/changes/rename-assets-skills/design.md b/openspec/changes/archive/2026-09-13-rename-assets-skills/design.md similarity index 100% rename from openspec/changes/rename-assets-skills/design.md rename to openspec/changes/archive/2026-09-13-rename-assets-skills/design.md diff --git a/openspec/changes/rename-assets-skills/proposal.md b/openspec/changes/archive/2026-09-13-rename-assets-skills/proposal.md similarity index 100% rename from openspec/changes/rename-assets-skills/proposal.md rename to openspec/changes/archive/2026-09-13-rename-assets-skills/proposal.md diff --git a/openspec/changes/rename-assets-skills/specs/assets-skills/spec.md b/openspec/changes/archive/2026-09-13-rename-assets-skills/specs/assets-skills/spec.md similarity index 100% rename from openspec/changes/rename-assets-skills/specs/assets-skills/spec.md rename to openspec/changes/archive/2026-09-13-rename-assets-skills/specs/assets-skills/spec.md diff --git a/openspec/changes/rename-assets-skills/tasks.md b/openspec/changes/archive/2026-09-13-rename-assets-skills/tasks.md similarity index 98% rename from openspec/changes/rename-assets-skills/tasks.md rename to openspec/changes/archive/2026-09-13-rename-assets-skills/tasks.md index c430e659fd..3f2ec923cf 100644 --- a/openspec/changes/rename-assets-skills/tasks.md +++ b/openspec/changes/archive/2026-09-13-rename-assets-skills/tasks.md @@ -48,7 +48,7 @@ ## 6. Browser verification -- [ ] 6.1 Run the `spec-browser-verify` skill for this change to verify the menu entry and navigation scenarios against the running app +- [x] 6.1 Run the `spec-browser-verify` skill for this change to verify the menu entry and navigation scenarios against the running app ## 7. Quality checks diff --git a/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/.openspec.yaml b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/.openspec.yaml new file mode 100644 index 0000000000..a40cb63c10 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/design.md b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/design.md new file mode 100644 index 0000000000..46c9518299 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/design.md @@ -0,0 +1,197 @@ +## Context + +`config-file-entity-views` (archived) added a `showConfigFiles` toggle to six platform/asset list pages +that swaps `BaseAssetList` for that entity's own admin-grid list component, fed by +`ConfigFileApi.list` — a composite of `listNames` + one `getEntity` per name. Follow-up review of +PR #4536 found three problems, traced in full against the current code: + +1. `BaseEntityList`'s row action icon ("open in new tab", wired through `EntityListView.tsx`'s + `openInNewTab` callback) calls `onOpenInNewTab(route, entity)` directly, never receiving the + `CONFIG_FILE_URL_SUFFIX` (`?configFile=true`) that the row-click handler (`onCellClicked`) and the + ``'s own `getHref` prop already append. Confirmed by reading all three call sites side by + side in `EntityListView.tsx`. +2. `JsonToggleWithFormats` already has an `onHideFormatSelector?: () => boolean` prop on + `JsonConfiguration` — built for exactly this kind of per-instance override — but no covered `View.tsx` + sets it. `Models/View/View.tsx` already skips fetching `coreModel` when `isConfigFileSource` (there is + nothing to compare against), yet still renders the ADMIN|CORE `DialSelect` once the editor opens. +3. The archived design doc excluded App Runners with "`ConfigFileEntityType` has no member for them." + That's contradicted by the enum itself (`Schemas = 'schemas'`) and by App Runners' own + resource-metadata prefix (`SCHEMAS_PREFIX = 'schemas/platform/'`, `RESOURCE_TYPE_PREFIX[APP_TYPE_SCHEMA]`) + — `app-runner-schema-api.ts`'s own comment even traces this lineage ("Core PR #1813 changed the key + from the canonical config-map path `schemas/platform/{name}`..."). `READABLE_CONFIG_FILE_TYPES` never + included `Schemas`, so nothing was ever probed against Core to confirm or refute this. Unlike the + other six, App Runners already has the full "hidden admin-grid route + platform/asset route" pairing + this feature relies on (`application-runners/[id]/page.tsx` ⟷ `platform-app-runners`, using + `applicationRunnersApi.getApplicationScheme`/`getCoreRunner` — the same ADMIN/CORE shape as `models`), + it just was never wired into `config-file-entity-views`. **Correction (pre-archive review):** wiring + App Runners into the bare `application-runners/[id]` route this way repeats the same routing mistake + already made for the other six — see D5. + +Separately, `ConfigFileApi.list` is an N+1 read (one `getEntity` per name, in parallel) whose only +consumers, repo-wide, are the six `getConfigFiles` list actions this feature added. Every one of +those lists' columns come from the entity's full admin-grid column set (`MODELS_COLUMNS`, etc.), +regardless of `isConfigFileSource` — the column shape was never adapted for a read-only, no-write list. +Since these rows exist only to be clicked through to a read-only detail page, the per-row fields were +never worth the fetch cost. + +## Goals / Non-Goals + +**Goals:** +- Fix the open-in-new-tab and format-toggle gaps with the same shape the existing five other call sites + already use — no new pattern. +- Confirm (not assume) that Core's config-file `schemas` type serves App-Runner-shaped entities before + building on it, and add App Runners as a seventh covered view once confirmed. +- Remove the N+1 full-entity read; every covered list becomes a single `listNames` call rendering a + name-only column, via one shared component instead of six (soon seven) separate ones. + +**Non-Goals:** +- No change to the picker/reference-option union read (`getConfigEntityOptions`, `readConfigEntities`) — + that surface never used `ConfigFileApi.list` and is untouched. +- No write path for any config-file entity, App Runners included. +- No change to the `ConfigFileEntityList`/`ConfigFileListSwap` mechanism, or to the `showConfigFiles` + toggle's placement/persistence — D5 changes the route value fed to them and where the resulting + `configFile=true` fetch/render lives, not the mechanism itself. + +## Decisions + +### D1: Confirm the `schemas` config-file type before writing any App Runners code + +Before wiring `ConfigFileEntityType.Schemas` into `READABLE_CONFIG_FILE_TYPES`, issue one manual read +against a running Core instance's `GET /v1/admin/config/file/schemas` (and +`GET /v1/admin/config/file/schemas/{name}` for one known runner) and diff the shape against +`DialAppRunnerResource`. This is a single spike task, not a design fork — if the shape matches (as the +`SCHEMAS_PREFIX` lineage strongly suggests), the rest of this design applies unchanged; if it doesn't +match or the route 403s the way `keys` does, App Runners' scope narrows back to "excluded, now for a +verified reason" and every other part of this change (bug fixes, N+1 removal for the original six) still +stands on its own. + +**Alternative considered:** build the App Runners wiring speculatively behind the same +`READABLE_CONFIG_FILE_TYPES` gate the other six use, and let a failed request surface as the existing +per-type refusal/failure path. Rejected — that would ship a feature nobody has seen work, in a system +where "unreadable type" and "route exists but shape doesn't match `DialAppRunnerResource`" fail +differently (one is a clean refusal, the other is a runtime type mismatch reaching the view layer). + +### D2: One shared, name-only list component replaces six per-entity admin-grid lists for this branch + +Today, `ConfigFileListSwap`'s `renderConfigFileList` callback in each `PageList.tsx` renders that entity's +own full-columns list component (`AdminModelsList`, `InterceptorsList`, etc.) with `isConfigFileSource`. +This design replaces that with one new `ConfigFileEntityList` (in `components/Common/`), taking +`names: string[]` and a `route: ApplicationRoute`, internally rendering `BaseEntityList` with a single +shared name column plus the (now-fixed) `openInNewTab` action column. `getUrnForEntity`'s existing +`PlatformModels`-style case already only needs `{name}` — no change required there. + +``` +PageList.tsx (× 7) + } + fetchConfigFileList={getConfigFileXNames} // listNames(), not list() + renderConfigFileList={(names) => ( + } /> + )} + /> +``` + +**Alternative considered:** keep each entity's own list component, just pass it a name-only column set +as an extra prop. Rejected — every one of the six list components hard-codes its own `baseColumns` via +`useMemo(() => X_COLUMNS(t), [t])`; branching that per-component on `isConfigFileSource` duplicates the +same one-column definition six times for no benefit, when the entity-specific list component was doing +nothing else config-file mode still needs (data mapping, create/remove handlers — all no-ops here since +the list is read-only and never creates). + +### D3: `listNames`-only fetch, `list` and `ConfigFileListResult` removed rather than deprecated + +Confirmed via repo-wide search that `ConfigFileApi.list` has no caller outside the six +`getConfigFiles` actions this change touches. There is no reason to keep a composite read whose +only reason to exist (populating full-entity list columns) no longer applies anywhere. Each action +switches its single line from `configFileApi.list(token, Type)` to `configFileApi.listNames(token, +Type)`; `useConfigFileEntityList` and `ConfigFileListSwap` collapse their generic to the `string[]` +`listNames` already returns (no more `.entities`/`.failures` unwrap — `listNames` fails wholesale, not +per-row, so the existing `ConfigFileReadResult` success/failure split is all that's needed). + +### D4: `onHideFormatSelector` is set per-view from `isConfigFileSource`, not a new static route list + +`JsonToggleWithFormats`'s existing `ONLY_ADMIN_ENTITIES` list is keyed by `ApplicationRoute` alone, which +can't express "hide only when this specific entity came from config-file" — the same route +(`PlatformModels`'s paired `Models` route) renders both admin-backend and config-file-sourced entities. +Each of the seven `View.tsx` components already computes `isConfigFileSource` as a prop and already uses +it to skip the `coreModel` fetch; this change adds one line to each `jsonConfiguration` memo: +`onHideFormatSelector: () => !!isConfigFileSource` (with `isConfigFileSource` added to the memo's deps). +No change to `JsonToggleWithFormats` itself — the prop it needs already exists. + +### D5: Config-file navigation targets the platform/asset detail route, not the bare admin-grid route + +Confirmed while reviewing this change ahead of archiving: `ConfigFileEntityList`'s `route` prop is fed +the bare `ApplicationRoute` member by all seven `PageList.tsx` callers (e.g. `ApplicationRoute.Models`, +`.ApplicationRunners`, `.Applications`, `.Toolsets`). `getUrnForEntity`/`getEntityPath` +(`utils/open-in-new-tab.ts`) resolve the URL prefix from `route.split('/')[1]` alone — they already +handle the platform/asset route variants (`PlatformModels`, `PlatformAppRunners`, `AssetsApplications`, +`AssetsToolsets`) via the same flat, name-based path logic the bare routes fall through to by default. So +the only change needed to retarget navigation is the `route` value each `PageList.tsx` passes in: + +| Entity | Route prop today | Corrected | +|---|---|---| +| Models | `ApplicationRoute.Models` | `PlatformModels` | +| Interceptors | `.Interceptors` | `PlatformInterceptors` | +| Routes | `.Routes` | `PlatformRoutes` | +| Roles | `.Roles` | `PlatformRoles` | +| App Runners | `.ApplicationRunners` | `PlatformAppRunners` | +| Applications | `.Applications` | `AssetsApplications` | +| Toolsets | `.Toolsets` | `AssetsToolsets` | + +The `configFile=true` fetch/read-only-render branch (tasks 3/8's `isConfigFileSource` wiring) moves with +it — off the bare `[id]/page.tsx` + admin `View.tsx` pair and onto the platform/asset `[id]/page.tsx` + +platform `View.tsx` pair: + +``` +platform-models/[id]/page.tsx + Assets/Platform/Models/View +platform-interceptors/[id]/page.tsx + Assets/Platform/Interceptors/View +platform-routes/[id]/page.tsx + Assets/Platform/Routes/View +platform-roles/[id]/page.tsx + Assets/Platform/Roles/View +platform-app-runners/[id]/page.tsx + Assets/Platform/AppRunners/View +assets-applications/[id]/page.tsx + Assets/Platform/Applications/View (existing isPlatformBucket branch) +assets-toolsets/[id]/page.tsx + Assets/Platform/Toolsets/View (existing isPlatformBucket branch) +``` + +For Applications and Toolsets, config-file entities are inherently flat/platform-bucket, so they land on +the `isPlatformBucket === true` branch these two pages already have — no new branch or component. + +The bare routes/Views (`models/[id]`, `interceptors/[id]`, `routes/[id]`, `roles/[id]`, +`applications/[id]`, `toolsets/[id]`, `application-runners/[id]`) fully revert: the `configFile` search +param, `isConfigFileSource` prop, and every branch it gated (config-file fetch, `onHideFormatSelector`, +skipped Core-compare fetch) are removed, restoring pre-`add-config-file-entity-views` behavior. They are +never a `configFile=true` navigation target once this lands. + +`CONFIG_FILE_DETAIL_TO_LIST_ROUTE` and `Breadcrumbs/utils.ts`'s `isConfigFileMode`/`listRouteOverride` +branch are removed: that map existed only to correct the breadcrumb after landing on the wrong (bare) +route's list link. Once the detail route is the platform/asset route itself, its own `breadcrumbConfig` +entry already resolves correctly with no override. + +**Alternative considered:** keep navigation on the bare route and instead fix the bare admin `View.tsx` +components to render correctly for a config-file-sourced, platform-only entity. Rejected — that means +teaching every bare View (which assumes an admin-backend-shaped, editable entity reachable only via the +six/seven admin-grid list routes) a second, platform-only rendering mode, duplicating logic the platform +Views already have correct. Routing to the entity's actual platform/asset home is the smaller, more +correct change. + +## Risks / Trade-offs + +- **[Risk]** The `schemas` config-file route may not return `DialAppRunnerResource`-shaped bodies, or + may 403 like `keys` does. → **Mitigation:** D1's spike runs first, before any App Runners-specific code + is written; the rest of this change does not depend on its outcome. +- **[Risk]** Removing `ConfigFileApi.list` is a breaking change to that class's public surface. + → **Mitigation:** confirmed zero external callers repo-wide; `tasks.md` includes a final grep-verify + step before deletion. +- **[Trade-off]** Config-file list rows now show only a name, where they previously showed full entity + columns (status, endpoint, etc. — populated at real per-row request cost). A user who wants those + fields must open the row's read-only detail page. This is the explicit point of the change (see + proposal's **BREAKING** note), not an accidental regression. +- **[Risk]** Reverting the bare routes/Views (D5) must not leave any other caller pointed at + `/[id]?configFile=true`. → **Mitigation:** `tasks.md`'s task 12 includes a grep-verify that + `ConfigFileEntityList`/its tests are the only source of that URL shape before the bare routes drop their + `configFile` branch. + +## Open Questions + +- Should the shared `ConfigFileEntityList`'s name column reuse an existing generic "Name" column + definition from `constants/grid-columns/`, or is a new minimal one warranted? Left to implementation — + neither choice changes any spec-level behavior. diff --git a/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/proposal.md b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/proposal.md new file mode 100644 index 0000000000..3ca625dd4f --- /dev/null +++ b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/proposal.md @@ -0,0 +1,113 @@ +## Why + +`add-config-file-entity-views` (archived) shipped six config-file-backed list/detail views, but left three +gaps found during follow-up review of PR #4536: the list's "open in new tab" row action forgets the +`configFile=true` query param the row click itself already adds, so it lands on a route that either +redirects home or renders editable instead of read-only; a config-file-sourced detail view still shows +the ADMIN|CORE format toggle even though there is nothing to compare against; and App Runners was +excluded on the stated grounds that "`ConfigFileEntityType` has no member for them" — which turns out to +be wrong. App Runners' own resource-metadata prefix is `schemas/platform/` (`SCHEMAS_PREFIX`, +`ResourceType.APP_TYPE_SCHEMA`), and `ConfigFileEntityType.Schemas = 'schemas'` already exists in the +enum; it was never added to `READABLE_CONFIG_FILE_TYPES` or probed against Core, so the "no matching +type" conclusion was never actually tested. + +Separately, every one of the six (soon seven) config-file list fetches N+1 requests today — +`ConfigFileApi.list` calls `listNames` then `getEntity` for every name in parallel — purely to +populate list columns that mostly go unused once a config-file entity is only ever opened read-only. +Fixing the App Runners gap is a natural point to also fix this: rather than growing the N+1 fetch to a +seventh entity type, this change removes it and moves every covered list to a names-only fetch (a single +`listNames` call) with a shared, name-only column set, since the row-level fields a full read provides +were never worth their per-row request cost for a read-only list. + +A pre-archive review of this change (and of PR #4536) surfaced a further defect in the same surface: +every covered list navigates a config-file row to the entity's bare/"hidden" admin-grid detail route +(`/models/{id}`, `/application-runners/{id}`, etc. — the same route the six-then-seven `[id]/page.tsx` +files already special-cased for `configFile=true`). That route renders the entity's write-capable admin +`View.tsx`, which was never designed to represent a config-file-sourced, platform-only entity — the +`isConfigFileSource` read-only wiring added in tasks 3/8 papers over the write affordances but not the +surrounding layout/tabs, which assume the admin-backend shape. The actual fix is mechanical: point the +same `?configFile=true` navigation at each entity's platform/asset detail route instead +(`platform-models/[id]`, `platform-app-runners/[id]`, `assets-applications/[id]`, +`assets-toolsets/[id]`, etc.), and move the read-only fetch/render logic there. See design.md's D5. + +## What Changes + +- **Open in new tab**: the row action icon on a config-file-backed list now appends `?configFile=true` + to the URL it opens, matching what row-click and the anchor `href` already do. +- **Hide the format toggle on config-file detail views**: `JsonToggleWithFormats`'s `onHideFormatSelector` + escape hatch (already defined, never wired up) is set on all covered `/View/View.tsx` components + when `isConfigFileSource` is true, so the ADMIN|CORE `DialSelect` does not render once the JSON editor + opens. +- **App Runners becomes the seventh covered entity type**: `ConfigFileEntityType.Schemas` is added to + `READABLE_CONFIG_FILE_TYPES`; `platform-app-runners` gets the `showConfigFiles` toggle and list swap + (`Assets/Platform/AppRunners/List.tsx`, currently a bare `BaseAssetList`, gains the same + `ConfigFileListSwap` wiring the other six already have); `application-runners/[id]/page.tsx` gains the + `configFile=true` branch that fetches via `configFileApi` and renders read-only, mirroring + `models/[id]/page.tsx`; `CONFIG_FILE_ENTITY_VIEWS` and `CONFIG_FILE_DETAIL_TO_LIST_ROUTE` gain the + corresponding entries. This is contingent on confirming Core's `v1/admin/config/file/schemas` route + actually serves app-runner-shaped entities — see `design.md` Open Questions and `tasks.md`'s spike task. +- **Remove the N+1 full-entity read; lists become names-only**: `ConfigFileApi.list` and + `ConfigFileListResult` are removed (verified unused outside the seven list actions this change + touches). The seven `getConfigFiles` actions call `configFileApi.listNames` instead. A new + shared list component renders a name-only column plus the existing (now-fixed) open-in-new-tab action + column, replacing each entity's own full-columns admin-grid list component for the config-file-backed + branch. **BREAKING** for anyone relying on the config-file list showing more than an entity's name + (e.g. status, endpoint) — full detail remains available on the entity's own read-only detail page. +- **Config-file detail navigation targets the platform/asset route, not the bare admin-grid route**: + `ConfigFileEntityList`'s `route` prop (fed by each covered `PageList.tsx`) changes from the bare + `ApplicationRoute` member to the corresponding platform/asset one (`PlatformModels`, + `PlatformInterceptors`, `PlatformRoutes`, `PlatformRoles`, `PlatformAppRunners`, `AssetsApplications`, + `AssetsToolsets`). The `configFile=true` fetch/read-only-render branch moves from each bare + `[id]/page.tsx` + admin `View.tsx` to the platform/asset `[id]/page.tsx` + platform `View.tsx` (for + Applications/Toolsets, the existing `isPlatformBucket` branch already in `assets-applications`/ + `assets-toolsets`). The bare routes/Views (`models/[id]`, `interceptors/[id]`, `routes/[id]`, + `roles/[id]`, `applications/[id]`, `toolsets/[id]`, `application-runners/[id]`) revert to admin-only, + dropping `isConfigFileSource` entirely. The breadcrumb fix-up (`CONFIG_FILE_DETAIL_TO_LIST_ROUTE`) is + removed as dead code, since the detail route now *is* the list's own route. + +## Capabilities + +### Modified Capabilities + +- `config-file-entity-views`: the open-in-new-tab action includes `configFile=true`; a config-file + detail view hides the ADMIN|CORE format toggle; App Runners (`platform-app-runners`) becomes a + seventh covered view instead of an explicitly excluded one; the config-file-backed list + renders names only, via a shared list component, instead of each entity's full admin-grid columns; + config-file detail navigation and rendering moves from each entity's bare admin-grid route to its + platform/asset route, and the bare-route breadcrumb fix-up is removed as unnecessary. +- `core-config-file-client`: `ConfigFileEntityType.Schemas` joins the readable allow-list; + `ConfigFileApi.list` and the full-entity-population read it provides are removed in favor of the + existing `listNames` read, which is now the only read a config-file list issues. + +## Impact + +- `apps/ai-dial-admin/src/components/EntityListView/EntityListView.tsx` (open-in-new-tab fix) +- `apps/ai-dial-admin/src/server/core/config-file-api.ts`, `src/models/dial/config-file.ts` (remove + `list`/`ConfigFileListResult`) +- `apps/ai-dial-admin/src/hooks/use-config-file-entity-list.ts`, + `src/components/Common/ConfigFileListSwap/ConfigFileListSwap.tsx` (names-only generic) +- `apps/ai-dial-admin/src/app/[lang]/{platform-models,platform-interceptors,platform-routes,platform-roles, + assets-applications,assets-toolsets,platform-app-runners}/actions.ts` (`listNames` instead of `list`) +- `apps/ai-dial-admin/src/components/Assets/Platform/{Models,Interceptors,Routes,Roles,AppRunners}/PageList.tsx`, + `Assets/{Apps,Toolsets}/PageList.tsx` (render the new shared list, targeting the platform/asset `route`) +- `apps/ai-dial-admin/src/app/[lang]/{platform-models,platform-interceptors,platform-routes,platform-roles, + platform-app-runners,assets-applications,assets-toolsets}/[id]/page.tsx` and + `src/components/Assets/Platform/{Models,Interceptors,Routes,Roles,AppRunners,Applications,Toolsets}/View.tsx` + (new `configFile=true` branch, moved from the bare routes) +- `apps/ai-dial-admin/src/app/[lang]/{models,interceptors,routes,roles,applications,toolsets, + application-runners}/[id]/page.tsx` and their `View.tsx` components (revert to admin-only, no + `isConfigFileSource`) +- `apps/ai-dial-admin/src/constants/config-file-core.ts`, `constants/config-file-entity-views.ts` (remove + `CONFIG_FILE_DETAIL_TO_LIST_ROUTE`) +- `apps/ai-dial-admin/src/components/Breadcrumbs/utils.ts` (remove the `isConfigFileMode` override) +- A new shared `Common` list component + one shared name-only column definition + +## Non-goals + +- No write path for App Runners' config-file population — it stays read-only like the other six. +- No change to `getConfigEntityOptions`/`readConfigEntities` (the picker union read) — this change is + scoped to the six-now-seven admin-grid list/detail surface, not the reference-picker surface, and the + picker's own `list`-less design is untouched. +- No change to the shared `ConfigFileEntityList`/`ConfigFileListSwap` mechanism itself, or to the + `showConfigFiles` toggle's placement/persistence — only the route value fed to them, and where the + resulting `configFile=true` fetch/render lives, changes. diff --git a/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/specs/config-file-entity-views/spec.md b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/specs/config-file-entity-views/spec.md new file mode 100644 index 0000000000..f5fe8fe6ed --- /dev/null +++ b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/specs/config-file-entity-views/spec.md @@ -0,0 +1,128 @@ +## REMOVED Requirements + +### Requirement: A config-file detail page's breadcrumb returns to the platform/asset list +**Reason**: This existed only to correct the breadcrumb after `configFile=true` navigation landed on +the bare admin-grid detail route, whose own list link redirects home without the admin backend. +Navigation now targets the platform/asset detail route directly (see "A config-file entity row links +to its platform/asset detail route"), whose own `breadcrumbConfig` entry already resolves to its own +list — no override is needed. + +**Migration**: None — the platform/asset detail route's breadcrumb was already correct for every other +case; this only removes a correction that no longer applies. + +#### Scenario: No breadcrumb override is needed +- **WHEN** a user opens `/platform-models/{id}?configFile=true` +- **THEN** the breadcrumb's list segment links to `/platform-models`, the same as without the flag — no override logic is involved + +## MODIFIED Requirements + +### Requirement: The toggle control is rendered only where it applies +The system SHALL render a `showConfigFiles` toggle control, placed adjacent to the page title, on exactly seven views: `platform-models`, `platform-interceptors`, `platform-routes`, `platform-roles`, `platform-app-runners`, `assets-applications`, and `assets-toolsets` — and only when `featureFlags.adminApiEnabled` is `false`. The control SHALL NOT be rendered on `platform-keys` or any other route, and SHALL NOT be rendered on any of the seven covered views when `featureFlags.adminApiEnabled` is `true`. + +#### Scenario: Toggle appears on a covered view without the admin API +- **WHEN** a user opens `platform-models` and `DIAL_ADMIN_API_URL` is unset +- **THEN** the `showConfigFiles` toggle is rendered next to the page title + +#### Scenario: Toggle is absent with the admin API configured +- **WHEN** a user opens `platform-models` and `DIAL_ADMIN_API_URL` is set +- **THEN** no `showConfigFiles` toggle is rendered + +#### Scenario: Toggle is absent on Keys +- **WHEN** a user opens `platform-keys`, regardless of `DIAL_ADMIN_API_URL` +- **THEN** no `showConfigFiles` toggle is rendered + +#### Scenario: Toggle appears on App Runners +- **WHEN** a user opens `platform-app-runners` and `DIAL_ADMIN_API_URL` is unset +- **THEN** the `showConfigFiles` toggle is rendered next to the page title, the same as on the other six covered views + +### Requirement: Toggling swaps the list component in place +On each of the seven covered views, the system SHALL render the existing asset/platform list (`BaseAssetList`) when `showConfigFiles` is `false`, and a shared, name-only config-file list component when `showConfigFiles` is `true` — on the same route, with no navigation. That shared component SHALL be the same one across all seven covered views, parameterized by the view's `ApplicationRoute`, rather than each entity type rendering its own full-columns admin-grid list component for this branch. The toggle control SHALL also be rendered in the config-file list's own header, so the user can switch back. + +#### Scenario: Turning the toggle on swaps to the config-file list +- **WHEN** a user on `platform-models` turns `showConfigFiles` on +- **THEN** the page renders the shared config-file list in place of `BaseAssetList`, without a URL change + +#### Scenario: Turning the toggle off restores the asset list +- **WHEN** a user on the config-file-backed list view turns `showConfigFiles` off +- **THEN** the page renders `BaseAssetList` again + +#### Scenario: The same list component renders for every covered entity type +- **WHEN** a user turns `showConfigFiles` on for any of the seven covered views +- **THEN** the same shared list component renders, differing only in the route it links rows to and the data it was given + +### Requirement: Config-file entity data is fetched lazily, only when the toggle is on +The system SHALL NOT fetch config-file entity data for any of the seven covered views until the user turns `showConfigFiles` on for that view. Turning it on SHALL trigger a request for that entity type's config-file entity **names** (not their full bodies); turning it off, or never turning it on, SHALL issue no such request. + +#### Scenario: No config-file request on initial page load +- **WHEN** a user opens `platform-models` with `showConfigFiles` off +- **THEN** no request is made to read config-file model entities + +#### Scenario: Turning the toggle on triggers a names-only fetch +- **WHEN** a user turns `showConfigFiles` on for the first time on a given view +- **THEN** a single request for that entity type's config-file entity names is issued, with no follow-up request per name + +### Requirement: A config-file entity row links to its platform/asset detail route +The system SHALL navigate to the entity type's platform/asset detail route (`/platform-models/{id}`, `/assets-applications/{id}`, `/platform-interceptors/{id}`, `/platform-routes/{id}`, `/platform-roles/{id}`, `/assets-toolsets/{id}`, `/platform-app-runners/{id}`) when a row in the config-file-backed list is clicked, or when its "open in new tab" row action is used, appending a `configFile=true` query parameter in both cases. The entity type's bare/admin-grid detail route (e.g. `/models/{id}`, `/applications/{id}`) SHALL NOT be used for this navigation. No new, dedicated route SHALL be introduced for this. + +#### Scenario: Clicking a config-file model row navigates to the platform route +- **WHEN** a user clicks a row in the config-file-backed Models list +- **THEN** the browser navigates to `/platform-models/{id}?configFile=true` + +#### Scenario: The "open in new tab" row action includes the query flag +- **WHEN** a user activates the "open in new tab" row action on a config-file-backed list row +- **THEN** the new tab opens the same platform/asset detail route the row click would (e.g. `/platform-models/{id}?configFile=true`), not the bare detail route + +### Requirement: A detail page opened with `configFile=true` renders read-only, sourced from Core's config file +When a covered entity's platform/asset detail route (`platform-models/[id]`, `assets-applications/[id]`, `platform-interceptors/[id]`, `platform-routes/[id]`, `platform-roles/[id]`, `assets-toolsets/[id]`, `platform-app-runners/[id]`) is requested with `configFile=true`, the system SHALL fetch the entity via `configFileApi` instead of the platform/asset entity's normal fetch, SHALL resolve any embedded Roles/Interceptors picker through the config-file-aware read, and SHALL render the platform/asset view read-only — no field on the page SHALL be editable, regardless of the viewer's own admin role. The view SHALL NOT render the ADMIN|CORE format toggle when its JSON editor is opened, since a config-file-sourced entity has no admin-backend "compare with Core" projection of its own to switch to — it already is Core's own view. The entity type's bare/admin-grid detail route SHALL NOT respond to `configFile=true` — it has no config-file branch. + +#### Scenario: A config-file-sourced model detail view is read-only +- **WHEN** a user opens `/platform-models/{id}?configFile=true` +- **THEN** the model is read from `configFileApi`, and every field on the page is disabled + +#### Scenario: The view returns to normal after leaving +- **WHEN** a user navigates away from a `configFile=true` detail view to any other page +- **THEN** that other page is not read-only as a result of having visited the config-file view + +#### Scenario: Direct navigation to a covered detail route with the flag works without the admin API +- **WHEN** `DIAL_ADMIN_API_URL` is unset and a user navigates directly to `/platform-interceptors/{id}?configFile=true` +- **THEN** the page renders the config-file-sourced interceptor read-only, rather than redirecting home + +#### Scenario: A config-file-sourced App Runner detail view is read-only +- **WHEN** a user opens `/platform-app-runners/{id}?configFile=true` +- **THEN** the runner is read from `configFileApi`, and every field on the page is disabled + +#### Scenario: The format toggle is hidden on a config-file-sourced detail view +- **WHEN** a user opens the JSON editor on any `configFile=true` detail view +- **THEN** no ADMIN|CORE format selector is rendered, only the editor itself + +#### Scenario: The format toggle still renders on the equivalent admin-backend view +- **WHEN** a user opens the JSON editor on the platform/asset detail view without `configFile=true` +- **THEN** the ADMIN|CORE format selector renders as before this change + +#### Scenario: The bare admin-grid detail route ignores the config-file flag +- **WHEN** a user navigates to `/models/{id}?configFile=true` +- **THEN** the page behaves exactly as `/models/{id}` without the flag — the admin-grid view, not a config-file read + +## ADDED Requirements + +### Requirement: The config-file-backed list shows only entity names +The shared config-file list component SHALL render a single name column (plus the action column carrying the "open in new tab" action) for every covered entity type, and SHALL NOT render any of the type-specific columns (status, endpoint, type, etc.) the entity's own admin-grid list shows. This applies uniformly across all seven covered views — there is no per-entity-type column configuration for this list. + +#### Scenario: A config-file list shows a name column and nothing else +- **WHEN** a user turns `showConfigFiles` on for any covered view +- **THEN** the rendered list's only data column is the entity's name + +#### Scenario: The action column still offers "open in new tab" +- **WHEN** a user views a config-file-backed list +- **THEN** each row's action column offers "open in new tab" and no other row action (no remove, duplicate, or move) + +### Requirement: App Runners is a covered config-file entity type +The system SHALL treat App Runners (`platform-app-runners` / `application-runners`) as a seventh covered entity type, on equal footing with the original six, reading its config-file population under `ConfigFileEntityType.Schemas`. + +#### Scenario: App Runners' config-file list is reachable the same way as the other six +- **WHEN** a user turns `showConfigFiles` on for `platform-app-runners` +- **THEN** the config-file-backed list renders, fetching App Runner names from Core's config-file `schemas` type + +#### Scenario: An App Runner config-file row opens its detail route with the query flag +- **WHEN** a user clicks a row in the config-file-backed App Runners list +- **THEN** the browser navigates to `/application-runners/{id}?configFile=true` diff --git a/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/specs/core-config-file-client/spec.md b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/specs/core-config-file-client/spec.md new file mode 100644 index 0000000000..f808a4a0f1 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/specs/core-config-file-client/spec.md @@ -0,0 +1,35 @@ +## REMOVED Requirements + +### Requirement: A full-entity population can be read for a config-file type +**Reason**: The only consumers of `ConfigFileApi.list` were the config-file-backed admin-grid lists +(`config-file-entity-views`), which have moved to a names-only list rendered from `listNames` alone (see +`config-file-entity-views`'s "The config-file-backed list shows only entity names"). The N+1 fetch this +method performed (one `getEntity` per name, to populate list columns) is no longer needed once the list +shows only a name column, so the method and the `ConfigFileListResult` type it returned are removed +rather than kept unused. + +**Migration**: Callers that listed a config-file type's full population should call `listNames` for the +name set, and `getEntity` individually for any specific entity's full body (as the covered detail pages +already do for the entity being viewed). No caller outside the six-now-seven list actions this +requirement's removal accompanies used `list`. + +#### Scenario: `list` is no longer available +- **WHEN** a caller looks for a full-population config-file read +- **THEN** only `listNames` (names) and `getEntity` (one entity's full body) are available; there is no + composite full-population method + +## MODIFIED Requirements + +### Requirement: Config-file reads are available for Models, Routes, Applications, and Toolsets +The system SHALL include `ConfigFileEntityType.Models`, `ConfigFileEntityType.Routes`, +`ConfigFileEntityType.Applications`, `ConfigFileEntityType.Toolsets`, and `ConfigFileEntityType.Schemas` +in `READABLE_CONFIG_FILE_TYPES`, making them accepted by `listNames` and `getEntity`. +`ConfigFileEntityType.Keys` SHALL remain excluded from the allow-list. + +#### Scenario: Models, Routes, Applications, Toolsets, and Schemas are accepted +- **WHEN** `listNames` or `getEntity` is called for Models, Routes, Applications, Toolsets, or Schemas +- **THEN** the request proceeds normally + +#### Scenario: Keys is still refused +- **WHEN** `listNames` or `getEntity` is called for the Keys type +- **THEN** the client refuses without issuing any request diff --git a/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/tasks.md b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/tasks.md new file mode 100644 index 0000000000..0be346b91e --- /dev/null +++ b/openspec/changes/archive/2026-09-14-expand-config-file-entity-views/tasks.md @@ -0,0 +1,192 @@ +## 1. Spike: confirm App Runners' config-file type + +- [x] 1.1 Confirmed statically against the `ai-dial-core` source (`FileConfigController.java`, + `RouteTemplate.ADMIN_FILE_CONFIG`): `GET /v1/admin/config/file/schemas` (`listFileConfigSchemas`) + is documented as "List of file-sourced application type schemas" — App Runners' own term for + themselves — and `entitySource()` resolves `APP_TYPE_SCHEMA` to `config.getApplicationTypeSchemas()`. + `handleSingle` injects `name`/`status` into the parsed schema JSON the same way every other + type's response is built, so `getEntity` needs no special-casing. `keys` is + the only type Core refuses outright; `schemas` requires the same admin auth as every other type. + `catalog_schemas` is confirmed a distinct, unrelated map (`CATALOG_SCHEMA -> getCatalogSchemas()`). + App Runners is in scope for the remaining tasks. + +## 2. Fix: open-in-new-tab query param + +- [x] 2.1 In `EntityListView.tsx`, thread `isConfigFileSource` into the `openInNewTab` callback so it + appends `CONFIG_FILE_URL_SUFFIX` the same way `onCellClicked` and `getHref` already do. +- [x] 2.2 Add/extend a component test on `EntityListView`/`BaseEntityList` asserting the "open in new + tab" row action opens `.../{id}?configFile=true` when `isConfigFileSource` is true, and the bare + route when it is false. + +## 3. Fix: hide the ADMIN|CORE format toggle for config-file entities + +- [x] 3.1 In each of `Models/View/View.tsx`, `Interceptors/View/View.tsx`, `Routes/View/View.tsx`, + `Roles/View/View.tsx`, `Applications/View/View.tsx`, `Toolsets/View/View.tsx` (and + `ApplicationRunners/View/View.tsx`, if task 1 confirms App Runners is in scope), add + `onHideFormatSelector: () => !!isConfigFileSource` to the `jsonConfiguration` memo, adding + `isConfigFileSource` to its dependency array. +- [x] 3.2 Add/extend a test per updated `View.tsx` asserting `onHideFormatSelector()` returns `true` when + `isConfigFileSource` is true and `false` otherwise. (`ApplicationRunners/View/View.tsx` covered in + task 8.5/9.1 below, alongside its own config-file wiring.) + +## 4. `core-config-file-client`: add Schemas, remove the N+1 `list` method + +- [x] 4.1 Add `ConfigFileEntityType.Schemas` to `READABLE_CONFIG_FILE_TYPES` in `config-file-core.ts` + (only if task 1's spike confirms the shape). +- [x] 4.2 Grep-verify `ConfigFileApi.list` and `ConfigFileListResult` have no callers outside the six + `getConfigFiles` actions this change is about to migrate (task 5), then remove `list` from + `config-file-api.ts` and `ConfigFileListResult` from `models/dial/config-file.ts`. +- [x] 4.3 Update `config-file-api.spec.ts` to drop `list`'s test coverage and confirm `listNames`'s + existing coverage still exercises the Schemas type once added. + +## 5. Migrate the six (seven) list actions to `listNames` + +- [x] 5.1 In `platform-models/actions.ts`, `platform-interceptors/actions.ts`, `platform-routes/actions.ts`, + `platform-roles/actions.ts`, `assets-applications/actions.ts`, `assets-toolsets/actions.ts`, change + `getConfigFiles` to call `configFileApi.listNames(token, Type)` instead of `.list(...)`. +- [x] 5.2 Added `getConfigFileAppRunners` to `platform-app-runners/actions.ts`, calling + `configFileApi.listNames(token, ConfigFileEntityType.Schemas)`. +- [x] 5.3 Update each action's existing spec to assert `listNames` is called instead of `list`. + +## 6. Shared name-only config-file list component + +- [x] 6.1 Reused the existing `NAME_COLUMN_WITH_SORT` from `constants/grid-columns/base-columns.ts` + (resolves design.md's open question) instead of adding a new definition — it is already the + established single-column shape for flat, name-only entities. +- [x] 6.2 Added `ConfigFileEntityList` under `components/Common/` — takes `names: string[]`, + `route: ApplicationRoute`, and `headerExtra?: ReactNode`; renders `BaseEntityList` with + `[NAME_COLUMN_WITH_SORT]` and `isConfigFileSource` always true, so remove/duplicate/move stay + hidden and only "open in new tab" renders in the action column. +- [x] 6.3 Unit-tested `ConfigFileEntityList`: maps names to rows, passes only the name column, and + always forwards `isConfigFileSource=true` (the open-in-new-tab `?configFile=true` behavior itself + is covered by `EntityListView.spec.tsx`, task 2.2). + +## 7. Wire the shared list into each covered `PageList.tsx` + +- [x] 7.1 Update `Assets/Platform/Models/PageList.tsx`, `Assets/Platform/Interceptors/PageList.tsx`, + `Assets/Platform/Routes/PageList.tsx`, `Assets/Platform/Roles/PageList.tsx`, + `Assets/Apps/PageList.tsx`, `Assets/Toolsets/PageList.tsx` to render `` in + `renderConfigFileList` instead of each entity's own `AdminXList`, passing the fetched names through + unchanged (no more `.entities` unwrap). +- [x] 7.2 Update `useConfigFileEntityList` and `ConfigFileListSwap` to operate on `string[]` + (`listNames`'s return shape) instead of `ConfigFileListResult`. +- [x] 7.3 Update each `PageList.spec.tsx` for the six views to match the new `renderConfigFileList` output + and the `string[]` data shape. + +## 8. App Runners: seventh covered view (only if task 1 confirms) + +- [x] 8.1 Add `ApplicationRoute.PlatformAppRunners` to `CONFIG_FILE_ENTITY_VIEWS` and + `ApplicationRoute.ApplicationRunners → ApplicationRoute.PlatformAppRunners` to + `CONFIG_FILE_DETAIL_TO_LIST_ROUTE` in `config-file-entity-views.ts`. +- [x] 8.2 Added `Assets/Platform/AppRunners/PageList.tsx` wrapping the existing `List.tsx` (unchanged, + still a bare `BaseAssetList`) in `ConfigFileListSwap`, following the same shape as the other six; + `platform-app-runners/page.tsx` now renders it instead of `List.tsx` directly. +- [x] 8.3 `ConfigFilesToggle` on the App Runners asset page follows for free from task 8.1: + `BaseAssetList` already gates its own header toggle on `CONFIG_FILE_ENTITY_VIEWS.has(view)`. +- [x] 8.4 Added the `configFile=true` branch to `application-runners/[id]/page.tsx` — fetches via + `configFileApi.getEntity(token, ConfigFileEntityType.Schemas, id)`, reads + roles/applications/interceptors via `readConfigEntities`, renders `ApplicationRunnersView` read-only, + mirroring `models/[id]/page.tsx`'s existing branch. +- [x] 8.5 In `ApplicationRunnersView/View.tsx`, wired `setEntityReadOnly`/`isConfigFileSource`, the + `onHideFormatSelector` override from task 3, skipped the `getCoreRunner` compare-with-Core fetch, + and branched the Application Properties schema resolve to `getResolvedRunnerSchema` (Core-direct, + the same branch `AppRunners.tsx`/`ParametersTab.tsx` already use for Asset-origin runners) instead + of `getResolvedApplicationScheme` (admin-BE) when config-file-sourced. +- [x] 8.6 `Breadcrumbs/utils.ts`'s config-file breadcrumb fix-up already reads + `CONFIG_FILE_DETAIL_TO_LIST_ROUTE` generically, so task 8.1 alone covers + `ApplicationRoute.ApplicationRunners` — no code change needed here, verified by a new test + (task 9.3). + +## 9. Tests for App Runners' new coverage (only if task 1 confirms) + +- [x] 9.1 Component tests for the `application-runners/[id]/page.tsx` `configFile=true` branch (fetch + source, read-only rendering) mirroring the existing `models/[id]/page.spec.tsx` coverage. Also + added `ApplicationRunnersView/View.spec.tsx` covering the `onHideFormatSelector` wiring from 8.5. +- [x] 9.2 Component test for `Assets/Platform/AppRunners/PageList.tsx`'s new `ConfigFileListSwap` wiring + (the swap lives in the new `PageList.tsx`, not `List.tsx`, matching the other six). +- [x] 9.3 Test for the breadcrumb fix-up covering `ApplicationRunners → PlatformAppRunners` (added to + `Breadcrumbs/tests/utils.spec.ts`, which had no prior config-file-mode coverage for any route). + +## 10. Update specs already covering config-file entity views + +- [x] 10.1 Implementation now matches both delta specs (`config-file-entity-views`, + `core-config-file-client`) written during planning — no divergence found during implementation, so + no delta edits were needed. Folding into `openspec/specs/` itself happens at archive time. + +## 11. Quality gate + +- [x] 11.1 Ran `npm run typecheck` (clean) and `npm run lint` (clean after one prettier fix in + `ApplicationRunners/View/View.tsx`). Every touched spec file was also run individually and passes. + The full-coverage `npm run test` run was explicitly skipped at the user's request — not run this + session. + +## 12. Correct config-file detail navigation target (pre-archive review) + +See design.md's D5. Config-file rows currently navigate to each entity's bare/"hidden" admin-grid +detail route, which renders that entity's write-capable admin `View.tsx` — never designed for a +config-file-sourced, platform-only entity. This retargets navigation and the `configFile=true` +fetch/render branch at the platform/asset route instead, and reverts the bare routes/Views. + +- [x] 12.1 Swapped the `route` prop passed to `ConfigFileEntityList` in each of the 7 `PageList.tsx` files + per D5's table: `Assets/Platform/Models/PageList.tsx` (`Models`→`PlatformModels`), + `Assets/Platform/Interceptors/PageList.tsx` (`Interceptors`→`PlatformInterceptors`), + `Assets/Platform/Routes/PageList.tsx` (`Routes`→`PlatformRoutes`), + `Assets/Platform/Roles/PageList.tsx` (`Roles`→`PlatformRoles`), + `Assets/Platform/AppRunners/PageList.tsx` (`ApplicationRunners`→`PlatformAppRunners`), + `Assets/Apps/PageList.tsx` (`Applications`→`AssetsApplications`), + `Assets/Toolsets/PageList.tsx` (`Toolsets`→`AssetsToolsets`). +- [x] 12.2 Added the `configFile` search-param branch to `platform-models/[id]/page.tsx`: fetches via + the moved `getConfigFileModel` action when `configFile=true` instead of the normal asset fetch, + passes `isConfigFileSource` to `Assets/Platform/Models/View`; wired that View's read-only + behavior (`setEntityReadOnly`, `onHideFormatSelector`) the same way the bare `Models/View/View.tsx` + used to. +- [x] 12.3 Same for `platform-interceptors/[id]/page.tsx` + `Assets/Platform/Interceptors/View` + (moved `getConfigFileInterceptor`). +- [x] 12.4 Same for `platform-routes/[id]/page.tsx` + `Assets/Platform/Routes/View` (moved + `getConfigFileRoute`). +- [x] 12.5 Same for `platform-roles/[id]/page.tsx` + `Assets/Platform/Roles/View` (moved + `getConfigFileRole`). +- [x] 12.6 Same for `platform-app-runners/[id]/page.tsx` + `Assets/Platform/AppRunners/View`; added + `getConfigFileAppRunner` to `platform-app-runners/actions.ts` (the bare route built this fetch + inline rather than via a helper). This View already resolves its Application Properties schema + Core-direct (`getResolvedRunnerSchema`) regardless of source, so no extra branch was needed there. +- [x] 12.7 Added the same branch to `assets-applications/[id]/page.tsx`'s existing `isPlatformBucket` + branch (config-file entities are flat/platform-bucket) + `Assets/Platform/Applications/View` + (`PlatformApplicationView`) — no new bucket branch or component; moved `getConfigFileApplication` + to `assets-applications/actions.ts`. This View has no ADMIN|CORE format toggle at all, so only + `setEntityReadOnly` wiring was needed, not `onHideFormatSelector`. +- [x] 12.8 Same for `assets-toolsets/[id]/page.tsx`'s `isPlatformBucket` branch + + `Assets/Platform/Toolsets/View` (`PlatformToolsetView`); moved `getConfigFileToolset` to + `assets-toolsets/actions.ts`. Same no-format-toggle note as 12.7. +- [x] 12.9 Reverted the bare routes/Views to admin-only: removed the `configFile` search param, the + `isConfigFileSource` prop, and every branch it gated (config-file fetch, `onHideFormatSelector`, + skipped Core-compare fetch) from `models/[id]/page.tsx`, `interceptors/[id]/page.tsx`, + `routes/[id]/page.tsx`, `roles/[id]/page.tsx`, `applications/[id]/page.tsx`, + `toolsets/[id]/page.tsx`, `application-runners/[id]/page.tsx`, and their `Models/View/View.tsx`, + `Interceptors/View/View.tsx`, `Routes/View/View.tsx`, `Roles/View/View.tsx`, + `Applications/View/View.tsx`, `Toolsets/View/View.tsx`, `ApplicationRunners/View/View.tsx` — each + diffed against the pre-`add-config-file-entity-views` commit to confirm the revert was exact and + touched nothing unrelated (each `View.tsx` keeps the `featureFlags` param its Audit-tab-visibility + fix still needs; only the `isConfigFileSource`-gated branches were removed). Grep-verified no code + outside `ConfigFileEntityList` and its own tests constructs a `/[id]?configFile=true` + URL. +- [x] 12.10 Removed `CONFIG_FILE_DETAIL_TO_LIST_ROUTE` from `constants/config-file-entity-views.ts` and + the `isConfigFileMode`/`listRouteOverride` branch from `components/Breadcrumbs/utils.ts` and + `Breadcrumbs.tsx` (which dropped its now-unused `useSearchParams` read). +- [x] 12.11 Updated tests: the 7 `PageList.spec.tsx` files now assert the platform/asset `route` prop; + added config-file-source coverage (`setEntityReadOnly`, `onHideFormatSelector` where the View has + one) to the `Assets/Platform/{Models,Interceptors,Routes,Roles,AppRunners,Applications,Toolsets}` + `View.spec.tsx` files; the moved `getConfigFile` single-get actions gained test coverage + in their new platform/asset `actions.spec.ts` files, mirroring the coverage removed from the bare + `actions.spec.ts` files; removed the now-invalid `configFile`/`isConfigFileSource` coverage + (bare-route `page.spec.tsx` files and bare `View.spec.tsx` files, all added solely for this by + `add-config-file-entity-views` — deleted rather than reverted, since they tested nothing else); + updated `Breadcrumbs/tests/utils.spec.ts` and `Breadcrumbs/tests/Breadcrumbs.spec.tsx` to drop the + config-file-mode override cases. No `[id]/page.spec.tsx` files were added for the platform/asset + pages themselves — consistent with this repo's existing convention that `page.tsx` route handlers + are not unit-tested directly (none of the seven platform/asset `[id]/page.tsx` files had one + before this change either); their fetch/render logic is covered via the actions and View tests + above. +- [x] 12.12 Quality gate: `npm run typecheck` (clean) and targeted `vitest run` across every touched + spec file (all pass). `npm run lint` and the full-coverage `npm run test` are left for the final + quality gate before archiving. diff --git a/openspec/specs/admin-api-availability/spec.md b/openspec/specs/admin-api-availability/spec.md index c039b99327..d585552774 100644 --- a/openspec/specs/admin-api-availability/spec.md +++ b/openspec/specs/admin-api-availability/spec.md @@ -72,12 +72,11 @@ The `Content` component SHALL NOT render the `Footer`, and SHALL NOT start the ` ### Requirement: Direct navigation to admin-API-only routes redirects home -The system SHALL redirect to `ApplicationRoute.Home`, before issuing any admin-backend request, when `process.env.DIAL_ADMIN_API_URL` is unset and a user navigates directly to any route owned exclusively by the Entities group (`/models`, `/applications`, `/interceptors`, `/toolsets`, `/routes`), the Builders group (`/adapters`, `/application-runners`, `/interceptor-templates`), the Access Management group (`/roles`, `/keys`), the Audit group (`/activity-audit`, `/dashboard`, `/usage-log`), or the Import/Export actions (`/import-config`, `/export-config`) — including every `[id]` and `[id]/[subId]` sub-route under them. +The system SHALL redirect to `ApplicationRoute.Home`, before issuing any admin-backend request, when `process.env.DIAL_ADMIN_API_URL` is unset and a user navigates directly to any route owned exclusively by the Entities group (`/models`, `/applications`, `/interceptors`, `/toolsets`, `/routes`), the Builders group (`/adapters`, `/application-runners`, `/interceptor-templates`), the Access Management group (`/roles`, `/keys`), the Audit group (`/activity-audit`, `/dashboard`, `/usage-log`), or the Import/Export actions (`/import-config`, `/export-config`) — including every `[id]` and `[id]/[subId]` sub-route under them — **except** the `[id]` detail route of `/models`, `/applications`, `/interceptors`, `/routes`, `/roles`, and `/toolsets`, which SHALL render instead of redirecting when the request carries a `configFile=true` query parameter, per `config-file-entity-views`. #### Scenario: Bookmarked entity URL redirects when the admin API is disabled -- **WHEN** a user navigates directly to `//models`, `//models/`, `//roles`, - `//import-config`, or any other route listed above +- **WHEN** a user navigates directly to `//models`, `//models/`, `//roles`, `//import-config`, or any other route listed above - **AND** `DIAL_ADMIN_API_URL` is unset - **THEN** the server issues a redirect to `ApplicationRoute.Home` - **AND** no admin-backend call is made for that page @@ -87,3 +86,76 @@ The system SHALL redirect to `ApplicationRoute.Home`, before issuing any admin-b - **WHEN** a user navigates to any of those routes - **AND** `DIAL_ADMIN_API_URL` is set - **THEN** the page renders as it does today + +#### Scenario: A covered detail route with `configFile=true` renders instead of redirecting + +- **WHEN** `DIAL_ADMIN_API_URL` is unset +- **AND** a user navigates to `//models/?configFile=true` (or the equivalent `/applications/`, `/interceptors/`, `/routes/`, `/roles/`, or `/toolsets/` route) +- **THEN** the server does not redirect, and the page renders the config-file-sourced entity read-only + +#### Scenario: The same detail route without the query flag still redirects + +- **WHEN** `DIAL_ADMIN_API_URL` is unset +- **AND** a user navigates to `//models/` with no `configFile` query parameter +- **THEN** the server issues a redirect to `ApplicationRoute.Home`, unchanged from before this change + +#### Scenario: Keys and App Runners are unaffected + +- **WHEN** `DIAL_ADMIN_API_URL` is unset and a user navigates to `//keys/` or `//application-runners/` with any query parameters +- **THEN** the server redirects to `ApplicationRoute.Home`, since neither route is part of the `configFile=true` exception + +### Requirement: The per-entity Audit tab is hidden without the admin API + +The system SHALL omit the per-entity Audit tab whenever `featureFlags.adminApiEnabled` is `false`, wherever that tab is added (`auditTab()` in `getRouteTabs`, `getApplicationTabs`, `getModelsTabs`, `getAdapterTabs`, `getAppRunnerTabs`, `getRoleTabs`, `getInterceptorTabs`, `getToolsetTabs`, `getInterceptorTemplateTabs`, `getKeyTabs`, `getDeploymentsViewTabs`, and the `AssetsToolsets`/`PlatformModels` branches of `getTabsForAsset`), regardless of the state of any other feature flag that governs the surface it appears on (`dashboardEnabled`, `deploymentsEnabled`, or an evaluation/analytics flag) and independent of whether the surface's own route is already redirect-guarded by the existing admin-API route guard. + +#### Scenario: Admin API disabled hides the Audit tab on a redirect-guarded entity + +- **WHEN** `featureFlags.adminApiEnabled` is `false` +- **AND** a Models, Applications, Routes, Roles, Keys, Interceptors, InterceptorTemplates, Adapters, + ApplicationRunners, or Toolsets entity view renders its tab list +- **THEN** the Audit tab is absent from that tab list + +#### Scenario: Admin API disabled hides the Audit tab on Deployments Containers and Images + +- **WHEN** `featureFlags.adminApiEnabled` is `false` +- **AND** `featureFlags.deploymentsEnabled` is `true` +- **AND** a Deployments Containers or Images entity view renders its tab list +- **THEN** the Audit tab is absent from that tab list + +#### Scenario: Admin API disabled hides the Audit tab on Assets Platform Models and Toolsets + +- **WHEN** `featureFlags.adminApiEnabled` is `false` +- **AND** `featureFlags.dashboardEnabled` is `true` +- **AND** an Assets ▸ Platform Models or Assets ▸ Toolsets entity view renders its tab list +- **THEN** the Audit tab is absent from that tab list + +#### Scenario: Admin API enabled leaves the Audit tab unaffected + +- **WHEN** `featureFlags.adminApiEnabled` is `true` +- **THEN** each entity view's Audit tab renders exactly as it does today, governed only by that + view's own other feature-flag checks (if any) + +### Requirement: Assets ▸ Applications list and detail pages skip admin-backend calls without the admin API + +The Assets ▸ Applications list page (`assets-applications/page.tsx`) and detail page (`assets-applications/[id]/page.tsx`) SHALL NOT call `applicationRunnersApi.getApplicationSchemesList` or `applicationsApi.getApplicationsList` when `process.env.DIAL_ADMIN_API_URL` is unset, and SHALL still render using their Core-direct data (`assetRunners`, `getModelsList`, `getApps`/`getPlatformApplication`, `readConfigEntities`), with `applicationSchemes`/`applications` resolving to an empty list. + +#### Scenario: Admin API disabled skips admin-backend calls on the Assets Applications list page + +- **WHEN** `process.env.DIAL_ADMIN_API_URL` is unset +- **AND** a user navigates to the Assets ▸ Applications list page +- **THEN** `applicationRunnersApi.getApplicationSchemesList` is never called +- **AND** the page renders using only Core-direct runner options + +#### Scenario: Admin API disabled skips admin-backend calls on the Assets Applications detail page + +- **WHEN** `process.env.DIAL_ADMIN_API_URL` is unset +- **AND** a user navigates to an Assets ▸ Applications detail page +- **THEN** neither `applicationRunnersApi.getApplicationSchemesList` nor + `applicationsApi.getApplicationsList` is called +- **AND** the page renders with `applicationSchemes` and `applications` as empty lists + +#### Scenario: Admin API enabled preserves existing Assets Applications behavior + +- **WHEN** `process.env.DIAL_ADMIN_API_URL` is set +- **THEN** the Assets ▸ Applications list and detail pages call the admin-backend APIs and render as + they do today diff --git a/openspec/specs/config-file-entity-views/spec.md b/openspec/specs/config-file-entity-views/spec.md new file mode 100644 index 0000000000..7755c38f88 --- /dev/null +++ b/openspec/specs/config-file-entity-views/spec.md @@ -0,0 +1,133 @@ +# config-file-entity-views Specification + +## Purpose +The UI surface that exposes DIAL Core's config-file entity population on platform and asset list +views, gated behind a `showConfigFiles` toggle that appears only when the admin backend is not +configured. Allows an admin to view read-only, config-file-sourced entity detail pages by navigating +from the toggled-on list, without introducing new routes. Covers the toggle's placement and +persistence, the shared name-only list component swap, the lazy names-only data fetch, and the +`configFile=true` detail page rendering on platform/asset routes. Created by archiving change +`add-config-file-entity-views`; expanded to cover App Runners as a seventh entity type by +`expand-config-file-entity-views`. + +## Requirements + +### Requirement: `showConfigFiles` toggle exists in `AppContext` +The system SHALL expose a `showConfigFiles: boolean` value and a toggle function on `AppContextType`, defaulting to `false` and persisted to `localStorage` the same way `sidebarOpen` is (read on mount, written on every toggle). + +#### Scenario: Default value is false +- **WHEN** the app loads with no prior stored value +- **THEN** `showConfigFiles` is `false` + +#### Scenario: Toggling persists across reloads +- **WHEN** a user toggles `showConfigFiles` on and reloads the app +- **THEN** `showConfigFiles` is still `true` + +### Requirement: The toggle control is rendered only where it applies +The system SHALL render a `showConfigFiles` toggle control, placed adjacent to the page title, on exactly seven views: `platform-models`, `platform-interceptors`, `platform-routes`, `platform-roles`, `platform-app-runners`, `assets-applications`, and `assets-toolsets` — and only when `featureFlags.adminApiEnabled` is `false`. The control SHALL NOT be rendered on `platform-keys` or any other route, and SHALL NOT be rendered on any of the seven covered views when `featureFlags.adminApiEnabled` is `true`. + +#### Scenario: Toggle appears on a covered view without the admin API +- **WHEN** a user opens `platform-models` and `DIAL_ADMIN_API_URL` is unset +- **THEN** the `showConfigFiles` toggle is rendered next to the page title + +#### Scenario: Toggle is absent with the admin API configured +- **WHEN** a user opens `platform-models` and `DIAL_ADMIN_API_URL` is set +- **THEN** no `showConfigFiles` toggle is rendered + +#### Scenario: Toggle is absent on Keys +- **WHEN** a user opens `platform-keys`, regardless of `DIAL_ADMIN_API_URL` +- **THEN** no `showConfigFiles` toggle is rendered + +#### Scenario: Toggle appears on App Runners +- **WHEN** a user opens `platform-app-runners` and `DIAL_ADMIN_API_URL` is unset +- **THEN** the `showConfigFiles` toggle is rendered next to the page title, the same as on the other six covered views + +### Requirement: Toggling swaps the list component in place +On each of the seven covered views, the system SHALL render the existing asset/platform list (`BaseAssetList`) when `showConfigFiles` is `false`, and a shared, name-only config-file list component when `showConfigFiles` is `true` — on the same route, with no navigation. That shared component SHALL be the same one across all seven covered views, parameterized by the view's `ApplicationRoute`, rather than each entity type rendering its own full-columns admin-grid list component for this branch. The toggle control SHALL also be rendered in the config-file list's own header, so the user can switch back. + +#### Scenario: Turning the toggle on swaps to the config-file list +- **WHEN** a user on `platform-models` turns `showConfigFiles` on +- **THEN** the page renders the shared config-file list in place of `BaseAssetList`, without a URL change + +#### Scenario: Turning the toggle off restores the asset list +- **WHEN** a user on the config-file-backed list view turns `showConfigFiles` off +- **THEN** the page renders `BaseAssetList` again + +#### Scenario: The same list component renders for every covered entity type +- **WHEN** a user turns `showConfigFiles` on for any of the seven covered views +- **THEN** the same shared list component renders, differing only in the route it links rows to and the data it was given + +### Requirement: Config-file entity data is fetched lazily, only when the toggle is on +The system SHALL NOT fetch config-file entity data for any of the seven covered views until the user turns `showConfigFiles` on for that view. Turning it on SHALL trigger a request for that entity type's config-file entity **names** (not their full bodies); turning it off, or never turning it on, SHALL issue no such request. + +#### Scenario: No config-file request on initial page load +- **WHEN** a user opens `platform-models` with `showConfigFiles` off +- **THEN** no request is made to read config-file model entities + +#### Scenario: Turning the toggle on triggers a names-only fetch +- **WHEN** a user turns `showConfigFiles` on for the first time on a given view +- **THEN** a single request for that entity type's config-file entity names is issued, with no follow-up request per name + +### Requirement: The config-file-backed list shows only entity names +The shared config-file list component SHALL render a single name column (plus the action column carrying the "open in new tab" action) for every covered entity type, and SHALL NOT render any of the type-specific columns (status, endpoint, type, etc.) the entity's own admin-grid list shows. This applies uniformly across all seven covered views — there is no per-entity-type column configuration for this list. + +#### Scenario: A config-file list shows a name column and nothing else +- **WHEN** a user turns `showConfigFiles` on for any covered view +- **THEN** the rendered list's only data column is the entity's name + +#### Scenario: The action column still offers "open in new tab" +- **WHEN** a user views a config-file-backed list +- **THEN** each row's action column offers "open in new tab" and no other row action (no remove, duplicate, or move) + +### Requirement: A config-file entity row links to its platform/asset detail route +The system SHALL navigate to the entity type's platform/asset detail route (`/platform-models/{id}`, `/assets-applications/{id}`, `/platform-interceptors/{id}`, `/platform-routes/{id}`, `/platform-roles/{id}`, `/assets-toolsets/{id}`, `/platform-app-runners/{id}`) when a row in the config-file-backed list is clicked, or when its "open in new tab" row action is used, appending a `configFile=true` query parameter in both cases. The entity type's bare/admin-grid detail route (e.g. `/models/{id}`, `/applications/{id}`) SHALL NOT be used for this navigation. No new, dedicated route SHALL be introduced for this. + +#### Scenario: Clicking a config-file model row navigates to the platform route +- **WHEN** a user clicks a row in the config-file-backed Models list +- **THEN** the browser navigates to `/platform-models/{id}?configFile=true` + +#### Scenario: The "open in new tab" row action includes the query flag +- **WHEN** a user activates the "open in new tab" row action on a config-file-backed list row +- **THEN** the new tab opens the same platform/asset detail route the row click would (e.g. `/platform-models/{id}?configFile=true`), not the bare detail route + +### Requirement: A detail page opened with `configFile=true` renders read-only, sourced from Core's config file +When a covered entity's platform/asset detail route (`platform-models/[id]`, `assets-applications/[id]`, `platform-interceptors/[id]`, `platform-routes/[id]`, `platform-roles/[id]`, `assets-toolsets/[id]`, `platform-app-runners/[id]`) is requested with `configFile=true`, the system SHALL fetch the entity via `configFileApi` instead of the platform/asset entity's normal fetch, SHALL resolve any embedded Roles/Interceptors picker through the config-file-aware read, and SHALL render the platform/asset view read-only — no field on the page SHALL be editable, regardless of the viewer's own admin role. The view SHALL NOT render the ADMIN|CORE format toggle when its JSON editor is opened, since a config-file-sourced entity has no admin-backend "compare with Core" projection of its own to switch to — it already is Core's own view. The entity type's bare/admin-grid detail route SHALL NOT respond to `configFile=true` — it has no config-file branch. + +#### Scenario: A config-file-sourced model detail view is read-only +- **WHEN** a user opens `/platform-models/{id}?configFile=true` +- **THEN** the model is read from `configFileApi`, and every field on the page is disabled + +#### Scenario: The view returns to normal after leaving +- **WHEN** a user navigates away from a `configFile=true` detail view to any other page +- **THEN** that other page is not read-only as a result of having visited the config-file view + +#### Scenario: Direct navigation to a covered detail route with the flag works without the admin API +- **WHEN** `DIAL_ADMIN_API_URL` is unset and a user navigates directly to `/platform-interceptors/{id}?configFile=true` +- **THEN** the page renders the config-file-sourced interceptor read-only, rather than redirecting home + +#### Scenario: A config-file-sourced App Runner detail view is read-only +- **WHEN** a user opens `/platform-app-runners/{id}?configFile=true` +- **THEN** the runner is read from `configFileApi`, and every field on the page is disabled + +#### Scenario: The format toggle is hidden on a config-file-sourced detail view +- **WHEN** a user opens the JSON editor on any `configFile=true` detail view +- **THEN** no ADMIN|CORE format selector is rendered, only the editor itself + +#### Scenario: The format toggle still renders on the equivalent admin-backend view +- **WHEN** a user opens the JSON editor on the platform/asset detail view without `configFile=true` +- **THEN** the ADMIN|CORE format selector renders as before this change + +#### Scenario: The bare admin-grid detail route ignores the config-file flag +- **WHEN** a user navigates to `/models/{id}?configFile=true` +- **THEN** the page behaves exactly as `/models/{id}` without the flag — the admin-grid view, not a config-file read + +### Requirement: App Runners is a covered config-file entity type +The system SHALL treat App Runners (`platform-app-runners` / `application-runners`) as a seventh covered entity type, on equal footing with the original six, reading its config-file population under `ConfigFileEntityType.Schemas`. + +#### Scenario: App Runners' config-file list is reachable the same way as the other six +- **WHEN** a user turns `showConfigFiles` on for `platform-app-runners` +- **THEN** the config-file-backed list renders, fetching App Runner names from Core's config-file `schemas` type + +#### Scenario: An App Runner config-file row opens its detail route with the query flag +- **WHEN** a user clicks a row in the config-file-backed App Runners list +- **THEN** the browser navigates to `/application-runners/{id}?configFile=true` diff --git a/openspec/specs/core-config-file-client/spec.md b/openspec/specs/core-config-file-client/spec.md index d45a6a8602..c21f1707d6 100644 --- a/openspec/specs/core-config-file-client/spec.md +++ b/openspec/specs/core-config-file-client/spec.md @@ -28,7 +28,7 @@ Core does not serve every type on this route family to every caller — reading - **THEN** the client refuses without issuing the request, rather than surfacing Core's refusal as a generic error ### Requirement: The two Core populations of one entity type are read as a union -DIAL Core keeps the entities of a given type in two places, and its merged runtime configuration is the union of both: entities written through its API, listed by the metadata route, and entities defined in configuration files, listed by the config-file route. Core validates a reference against that merged set. The system SHALL therefore compose both reads when offering an entity as a selectable option, so the offered set matches the set Core will accept. The config-file route is the admin console's own configuration surface: when the admin backend is not configured (`DIAL_ADMIN_API_URL` unset), the system SHALL skip that read and resolve it as an empty population rather than issuing the request or reporting a failure. The API-written read is unaffected by that flag and SHALL always be issued. +DIAL Core keeps the entities of a given type in two places, and its merged runtime configuration is the union of both: entities written through its API, listed by the metadata route, and entities defined in configuration files, listed by the config-file route. Core validates a reference against that merged set. The system SHALL therefore compose both reads when offering an entity as a selectable option, so the offered set matches the set Core will accept. The config-file route is the admin console's own configuration surface: when the admin backend is not configured (`DIAL_ADMIN_API_URL` unset), the system SHALL skip that read and resolve it as an empty population rather than issuing the request or reporting a failure. The API-written read is unaffected by that flag and SHALL always be issued. An optional `showOnlyConfigFiles` parameter (default `false`) inverts this behaviour: when `true`, the API-written (metadata) read is skipped entirely and the config-file read is always issued regardless of `DIAL_ADMIN_API_URL`. #### Scenario: Both populations appear as options - **WHEN** options of a given entity type are requested for a picker @@ -56,6 +56,50 @@ DIAL Core keeps the entities of a given type in two places, and its merged runti - **WHEN** `DIAL_ADMIN_API_URL` is set and options of any entity type are requested - **THEN** both the API-written and config-file reads are issued, as before this change +#### Scenario: `showOnlyConfigFiles=true` skips the API-written read +- **WHEN** options of a given entity type are requested with `showOnlyConfigFiles: true` +- **THEN** the API-written (metadata) read is not issued, and the result contains only entries from + the config-file population + +#### Scenario: `showOnlyConfigFiles=true` always issues the config-file read, even without the admin backend +- **WHEN** `DIAL_ADMIN_API_URL` is unset and options are requested with `showOnlyConfigFiles: true` +- **THEN** the config-file read is issued regardless of the missing admin-backend URL +- **AND** the API-written read is still skipped + +### Requirement: Config-file reads are available for Models, Routes, Applications, and Toolsets +The system SHALL include `ConfigFileEntityType.Models`, `ConfigFileEntityType.Routes`, +`ConfigFileEntityType.Applications`, `ConfigFileEntityType.Toolsets`, and `ConfigFileEntityType.Schemas` +in `READABLE_CONFIG_FILE_TYPES`, making them accepted by `listNames` and `getEntity`. +`ConfigFileEntityType.Keys` SHALL remain excluded from the allow-list. + +#### Scenario: Models, Routes, Applications, Toolsets, and Schemas are accepted +- **WHEN** `listNames` or `getEntity` is called for Models, Routes, Applications, Toolsets, or Schemas +- **THEN** the request proceeds normally + +#### Scenario: Keys is still refused +- **WHEN** `listNames` or `getEntity` is called for the Keys type +- **THEN** the client refuses without issuing any request + +### Requirement: A picker read can be scoped to config-file entities only +The `getConfigEntityOptions` and `readConfigEntities` functions SHALL accept an optional +`showOnlyConfigFiles: boolean` parameter (default `false`). When `true`, the function SHALL skip the +API-written (asset-metadata) read and SHALL always issue the config-file read regardless of whether +`DIAL_ADMIN_API_URL` is set. When `false` (or absent), existing behaviour is preserved exactly. + +#### Scenario: `showOnlyConfigFiles: true` skips the asset-metadata read +- **WHEN** `getConfigEntityOptions` or `readConfigEntities` is called with `showOnlyConfigFiles: true` +- **THEN** no asset-metadata (API-written) request is issued for that entity type + +#### Scenario: `showOnlyConfigFiles: true` issues the config-file read even without `DIAL_ADMIN_API_URL` +- **WHEN** `DIAL_ADMIN_API_URL` is unset and `getConfigEntityOptions` is called with + `showOnlyConfigFiles: true` +- **THEN** the config-file read is still issued, rather than being skipped as it normally would be + without the admin backend + +#### Scenario: Omitting the parameter reproduces today's behaviour exactly +- **WHEN** `getConfigEntityOptions` or `readConfigEntities` is called without the parameter +- **THEN** the result is identical to calling it with `showOnlyConfigFiles: false` + ### Requirement: The union normalises to the fields both populations provide The two populations do not carry the same data, and neither carries a description. The metadata route returns per-entry author and timestamps; the config-file listing returns a name and nothing else. The system SHALL normalise an option to the fields available from both — its name and its origin — rather than issuing a per-entity read to fill fields a listing omits. diff --git a/openspec/specs/platform-entity-routes/spec.md b/openspec/specs/platform-entity-routes/spec.md index 4911905bd3..a651ba2996 100644 --- a/openspec/specs/platform-entity-routes/spec.md +++ b/openspec/specs/platform-entity-routes/spec.md @@ -53,3 +53,19 @@ The user-resource (World C) routes `/assets-applications`, `/assets-toolsets`, ` #### Scenario: Asset Applications page remains accessible at its original URL - **WHEN** a user navigates to `//assets-applications` - **THEN** the system renders the Asset Applications list page (no change in behavior) + +### Requirement: Platform entity detail pages are addressed by the [id] segment alone +The six platform entity detail pages SHALL be addressed by the entity's plain `[id]` path segment, +without any additional query parameter carrying the entity's path. The existing `?path=` +pattern from the previous `/assets-*` routes SHALL NOT be carried over. + +#### Scenario: Clean URL produced for a platform entity detail page +- **WHEN** a user navigates to a platform entity detail page (e.g. a Model, Interceptor, Route, + Role, Key, or App Runner) +- **THEN** the browser address bar shows `//platform-/` with no `?path=` query + parameter + +#### Scenario: Stale `?path=` URL is handled +- **WHEN** a user navigates to `//platform-/?path=` (e.g. from a + stale bookmark) +- **THEN** the page renders the entity detail view, ignoring the `path` query parameter diff --git a/openspec/specs/platform-keys/spec.md b/openspec/specs/platform-keys/spec.md index 2a0090d06e..e3576d5ea1 100644 --- a/openspec/specs/platform-keys/spec.md +++ b/openspec/specs/platform-keys/spec.md @@ -42,13 +42,13 @@ Core never returns it on subsequent reads. - **THEN** the key secret is NOT displayed (Core does not return it on GET) ### Requirement: Key detail view -The system SHALL display a detail view at `/assets-keys/[id]` fetched via +The system SHALL display a detail view at `/platform-keys/` fetched via `assetApi.getMergedWithEtag(token, ResourceType.PROJECT_KEY, path, etag)`. The view SHALL include a Properties tab and a Roles tab. #### Scenario: User opens a key detail - **WHEN** the user clicks a key row in the listing -- **THEN** the system navigates to `/assets-keys/[id]?path=` and renders the key +- **THEN** the system navigates to `/platform-keys/` and renders the key detail with Properties and Roles tabs ### Requirement: Key properties editing diff --git a/openspec/specs/platform-translators/spec.md b/openspec/specs/platform-translators/spec.md new file mode 100644 index 0000000000..56e82f34bd --- /dev/null +++ b/openspec/specs/platform-translators/spec.md @@ -0,0 +1,158 @@ +# platform-translators Specification + +## Purpose +The `Catalog > Translators` surface — menu entry, flat asset list with create and delete actions, +and a detail view with a single Properties tab exposing `in`, `out`, and `baseUrl` fields, sourced +entirely from DIAL Core. Translators are flat platform resources with no folder nesting, no +display-name field, no Roles tab, and no dependency on the admin backend. `in` and `out` select +from the shared `DeploymentInterfaceType` values; Core validates the write and surfaces any +rejection message verbatim. Created by archiving change `add-platform-translators`. + +## Requirements + +### Requirement: Catalog > Translators menu entry +The system SHALL add a `Translators` menu item to the Catalog section of the admin menu, directly +after `Interceptors`, linking to a new `/platform-translators` route. + +#### Scenario: Translators follows Interceptors in the Catalog section +- **WHEN** the Catalog section of the menu renders +- **THEN** `Translators` appears immediately after `Interceptors` and before `Routes` + +### Requirement: Translator asset list is flat with create and delete actions +The system SHALL render the translator asset list as a single, non-nested list of entries under the +`platform` root, built on the shared asset list, exposing create, delete, and bulk-delete actions and +no folder-create, rename-folder, move-into-folder, or duplicate control. + +#### Scenario: List shows entries without a folder tree +- **WHEN** a user opens `/platform-translators` +- **THEN** all translator resources are shown as direct entries with no folder-expand affordance + +#### Scenario: No create-folder, move, or duplicate action is present +- **WHEN** a user opens the translator asset list toolbar and row actions +- **THEN** no create-folder, move-to-folder, or duplicate action is offered + +#### Scenario: Create action opens the translator create modal +- **WHEN** a user activates the create action in the list toolbar +- **THEN** a modal opens requesting only the translator's name — no display name or description + field, since `Translator` has neither — and submitting it creates the resource and navigates to its + detail view + +#### Scenario: Bulk delete removes the selected translators +- **WHEN** a user selects several translators and confirms bulk delete +- **THEN** each selected translator is deleted and the list refreshes without them + +#### Scenario: A read-only admin is offered no mutating actions +- **WHEN** a read-only admin opens the translator asset list +- **THEN** no create, delete, or bulk-delete action is offered + +### Requirement: Translator asset list columns are metadata-only +The system SHALL show name, author, created-at, and updated-at columns for translator assets, all +sourced from Core's resource metadata, and SHALL NOT fetch each row's content to populate the list. + +#### Scenario: Listing issues no per-row content request +- **WHEN** the translator asset list loads +- **THEN** only metadata requests are issued, with no content request per row + +#### Scenario: Timestamps come from Core metadata +- **WHEN** the translator asset list renders +- **THEN** the created-at and updated-at columns are populated from the Core metadata node's + `createdAt` and `updatedAt` fields, each rendered as a localized date rather than raw epoch + milliseconds + +### Requirement: Translator names follow Core's plain entity-name rule +The system SHALL treat a translator's name as a plain Core entity name — using the same shared name +field and validation the `Assets > Models` create form already uses — and SHALL NOT apply any +URI-encoding or `$id`-style handling to it. + +#### Scenario: The create form uses the shared name field +- **WHEN** a user opens the translator create form +- **THEN** the same name field and validation `Assets > Models`/`Assets > Routes` use is shown, with + no display-name or description field alongside it, since `Translator` has neither + +#### Scenario: A valid name creates the resource +- **WHEN** a user submits a valid name +- **THEN** the resource is created under `translators/platform/{name}` and the list/detail view + address it by that plain name + +### Requirement: Translator asset detail view tab set +The system SHALL render a translator asset's detail view with exactly one tab, `Properties`, and +SHALL NOT include a `Features`, `Configuration`, `Roles`, or `Audit` tab, a Core-sync status banner, +or any reverse-index tab showing which other entities reference this translator. + +#### Scenario: Detail view renders exactly Properties +- **WHEN** a user opens a translator asset's detail view +- **THEN** the tab list contains exactly `Properties` + +#### Scenario: No Features, Configuration, Roles, or Audit tab +- **WHEN** a user opens a translator asset's detail view +- **THEN** no `Features`, `Configuration`, `Roles`, or `Audit` tab is shown + +### Requirement: No Roles tab, as a structural absence +The system SHALL NOT render a Roles tab on the translator asset detail view, and SHALL NOT add a +`userRoles` field to the translator model. Core's `Translator` class carries no `userRoles` field — +it extends neither `Deployment` nor `RoleBasedEntity` — so there is no membership data for a Roles +tab to bind to. + +#### Scenario: No Roles tab is present +- **WHEN** a user opens a translator asset's detail view +- **THEN** no Roles tab is shown + +### Requirement: Properties tab content +The system SHALL render the translator asset's Properties tab with two fields sourced from the +interface-type population, `in` and `out`, and one URL field, `baseUrl`, composed from the same +individual controls other platform entities' Properties tabs use — no display name, description, +icon, endpoint-list, or topics control, since none of those exist on `Translator`. + +#### Scenario: Properties are editable and persist +- **WHEN** a user edits `in`, `out`, or `baseUrl` and saves +- **THEN** the value is stored on the translator resource and reappears on reload + +### Requirement: `in`/`out` selects share the deployment interface-type population +The system SHALL populate the `in` and `out` selects from the same `DeploymentInterfaceType` values +already used by `Assets > Models`'/`Assets > Interceptors`' `interfaces` field (all four: OpenAI Chat +Completions, OpenAI Responses, Anthropic Messages, OpenAI Embeddings) — introducing no +translator-specific interface-type enum. + +#### Scenario: Both selects offer the same four interface types +- **WHEN** a user opens the `in` or `out` select on a translator's Properties tab +- **THEN** both list exactly the four `DeploymentInterfaceType` values + +### Requirement: Core validates a write; the client adds no meta-schema layer +The system SHALL rely on Core's own server-side validation — Core deserializes a translator write into +its `Translator` entity class and validates it (`ConfigPostProcessor.validateTranslator`) rather than +adding a client-side meta-schema-validation or cross-reference-validation layer (no client-side check +that `in` differs from `out`, that either names a known interface type, or that the deployment +referencing this translator serves `out` pass-through), and SHALL surface Core's rejection message — +including `validationWarnings` field/message pairs, when present — to the user verbatim. + +#### Scenario: A rejected write surfaces Core's message +- **WHEN** a save is rejected by Core with a 422 and `validationWarnings` +- **THEN** an error notification shows Core's message rather than a generic failure, and the client + performs no equivalent check of its own before submitting + +### Requirement: Configuring a translator asset requires no admin-backend call +Every field this surface reads or writes is owned by DIAL Core. The system SHALL NOT require any +admin-backend request in order to view, create, edit, or delete a translator asset. + +#### Scenario: The surface is configurable without the admin backend +- **WHEN** a user opens a translator asset and edits any field this surface exposes +- **THEN** no admin-backend request is required for the edit to be made or saved + +### Requirement: No translator-attach picker widening +The system SHALL leave every existing entity-attach picker unchanged, introducing no +translator-origin dimension or widened picker as part of this capability. A translator is referenced +by name only from a model's or interceptor's own `interfaces..translator` field, a +config-authoring concern this capability does not surface any editor for. + +#### Scenario: No existing picker changes behavior +- **WHEN** any existing entity-attach picker in the admin console renders +- **THEN** its option list and columns are unaffected by the existence of `Catalog > Translators` + +### Requirement: Translators is excluded from the config-file readable-types allow-list +The system SHALL NOT add `translators` to `READABLE_CONFIG_FILE_TYPES` — no existing cross-reference +picker needs to resolve a config-file-declared translator by name through this capability. + +#### Scenario: A config-file translator read is refused +- **WHEN** a caller requests a config-file read for the `translators` type +- **THEN** the read is refused as not readable, the same outcome every other type outside the + allow-list already gets diff --git a/openspec/specs/skills/spec.md b/openspec/specs/skills/spec.md index 59ebd978c4..dfa135fd48 100644 --- a/openspec/specs/skills/spec.md +++ b/openspec/specs/skills/spec.md @@ -15,7 +15,7 @@ archiving change `add-skill-creation`. ### Requirement: Assets > Skills menu entry The system SHALL add a `Skills` menu item to the Assets section of the admin menu, directly after `Files` -(the last existing Assets entry), linking to a new `/assets-skills` route. +(the last existing Assets entry), linking to the `/skills` route. #### Scenario: Skills follows Files in the Assets section - **WHEN** the Assets section of the menu renders @@ -23,7 +23,7 @@ The system SHALL add a `Skills` menu item to the Assets section of the admin men #### Scenario: Selecting Skills navigates to its list - **WHEN** a user selects `Skills` from the Assets menu -- **THEN** the app navigates to `/assets-skills` +- **THEN** the app navigates to `/skills` ### Requirement: Skill asset list is a folder tree with metadata-only columns The system SHALL render the Skills asset list on the shared asset list, browsable as a folder tree (a diff --git a/package.json b/package.json index c87aed88d0..8d5a1d3e00 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "Apache-2.0", "scripts": { "start": "nx serve ai-dial-admin", - "test": "nx run-many -t test --runInBand --coverage", + "test": "NODE_OPTIONS=--max-old-space-size=4096 nx run-many -t test --runInBand --coverage", "build": "nx run-many -t build", "lint": "nx run-many -t lint", "typecheck": "nx run ai-dial-admin:typecheck",