mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-07 21:31:53 +00:00
Extract AI settings runtime owners
This commit is contained in:
@@ -26,28 +26,31 @@ work extends shared components instead of creating new local variants.
|
||||
4. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts`
|
||||
5. `frontend-modern/src/components/Settings/APIAccessPanel.tsx`
|
||||
6. `frontend-modern/src/components/Settings/AISettings.tsx`
|
||||
7. `frontend-modern/src/components/Settings/AuditLogPanel.tsx`
|
||||
8. `frontend-modern/src/components/Settings/AuditWebhookPanel.tsx`
|
||||
9. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
|
||||
10. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx`
|
||||
11. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx`
|
||||
12. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`
|
||||
13. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`
|
||||
14. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`
|
||||
15. `frontend-modern/src/components/Settings/SettingsPageShell.tsx`
|
||||
16. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts`
|
||||
17. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`
|
||||
18. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx`
|
||||
19. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`
|
||||
20. `tests/integration/tests/15-settings-shell-consistency.spec.ts`
|
||||
21. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts`
|
||||
22. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
|
||||
23. `frontend-modern/src/features/`
|
||||
24. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
|
||||
25. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
|
||||
26. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
|
||||
27. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
|
||||
28. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
|
||||
7. `frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx`
|
||||
8. `frontend-modern/src/components/Settings/AISettingsDialogs.tsx`
|
||||
9. `frontend-modern/src/components/Settings/aiSettingsModel.ts`
|
||||
10. `frontend-modern/src/components/Settings/AuditLogPanel.tsx`
|
||||
11. `frontend-modern/src/components/Settings/AuditWebhookPanel.tsx`
|
||||
12. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`
|
||||
13. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx`
|
||||
14. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx`
|
||||
15. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`
|
||||
16. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`
|
||||
17. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`
|
||||
18. `frontend-modern/src/components/Settings/SettingsPageShell.tsx`
|
||||
19. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts`
|
||||
20. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`
|
||||
21. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx`
|
||||
22. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`
|
||||
23. `tests/integration/tests/15-settings-shell-consistency.spec.ts`
|
||||
24. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts`
|
||||
25. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
|
||||
26. `frontend-modern/src/features/`
|
||||
27. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
|
||||
28. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
|
||||
29. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
|
||||
30. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
|
||||
31. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -256,6 +259,12 @@ well: `frontend-modern/src/components/Settings/settingsHeaderMeta.ts` and
|
||||
representative top-level panels such as
|
||||
`frontend-modern/src/components/Settings/APIAccessPanel.tsx`,
|
||||
`frontend-modern/src/components/Settings/AISettings.tsx`,
|
||||
`frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx`,
|
||||
`frontend-modern/src/components/Settings/AISettingsDialogs.tsx`, and
|
||||
`frontend-modern/src/components/Settings/aiSettingsModel.ts` now also define
|
||||
the canonical AI settings runtime boundary: `AISettings.tsx` is the shell and
|
||||
controller, while provider configuration, modal flows, and provider metadata
|
||||
must extend those extracted owners instead of being re-inlined into the shell.
|
||||
`frontend-modern/src/components/Settings/AuditLogPanel.tsx`,
|
||||
`frontend-modern/src/components/Settings/AuditWebhookPanel.tsx`,
|
||||
`frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`,
|
||||
|
||||
@@ -1579,7 +1579,10 @@
|
||||
"frontend-modern/src/components/shared/"
|
||||
],
|
||||
"owned_files": [
|
||||
"frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx",
|
||||
"frontend-modern/src/components/Settings/AISettings.tsx",
|
||||
"frontend-modern/src/components/Settings/AISettingsDialogs.tsx",
|
||||
"frontend-modern/src/components/Settings/aiSettingsModel.ts",
|
||||
"frontend-modern/src/components/Settings/APIAccessPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/AuditLogPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/AuditWebhookPanel.tsx",
|
||||
@@ -1631,7 +1634,10 @@
|
||||
"label": "settings shell framing proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx",
|
||||
"frontend-modern/src/components/Settings/AISettings.tsx",
|
||||
"frontend-modern/src/components/Settings/AISettingsDialogs.tsx",
|
||||
"frontend-modern/src/components/Settings/aiSettingsModel.ts",
|
||||
"frontend-modern/src/components/Settings/APIAccessPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/AuditLogPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/AuditWebhookPanel.tsx",
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { For, Show, type Accessor, type Component, type Setter } from 'solid-js';
|
||||
import type { SetStoreFunction } from 'solid-js/store';
|
||||
import { HelpIcon } from '@/components/shared/HelpIcon';
|
||||
import { controlClass } from '@/components/shared/Form';
|
||||
import { getAIProviderHealthPresentation } from '@/utils/aiProviderHealthPresentation';
|
||||
import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation';
|
||||
import { getAIProviderTestResultTextClass } from '@/utils/aiSettingsPresentation';
|
||||
import type { AIProvider, AISettings as AISettingsType } from '@/types/ai';
|
||||
import {
|
||||
AI_PROVIDER_CONFIGS,
|
||||
AI_PROVIDERS,
|
||||
type AIProviderCredentialsFormState,
|
||||
type ProviderHealthState,
|
||||
type ProviderTestResult,
|
||||
isAIProviderConfigured,
|
||||
} from '@/components/Settings/aiSettingsModel';
|
||||
|
||||
export interface AIProviderConfigurationSectionProps {
|
||||
settings: Accessor<AISettingsType | null>;
|
||||
form: AIProviderCredentialsFormState;
|
||||
setForm: SetStoreFunction<AIProviderCredentialsFormState>;
|
||||
expandedProviders: Accessor<Set<AIProvider>>;
|
||||
setExpandedProviders: Setter<Set<AIProvider>>;
|
||||
providerHealth: Record<AIProvider, ProviderHealthState>;
|
||||
preflightRunning: Accessor<boolean>;
|
||||
preflightLastCheckedAt: Accessor<number | null>;
|
||||
providerIssueCount: Accessor<number>;
|
||||
testingProvider: Accessor<AIProvider | null>;
|
||||
providerTestResult: Accessor<ProviderTestResult | null>;
|
||||
saving: Accessor<boolean>;
|
||||
runProviderPreflight: () => Promise<void>;
|
||||
handleTestProvider: (provider: AIProvider) => Promise<void>;
|
||||
handleClearProvider: (provider: AIProvider) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AIProviderConfigurationSection: Component<AIProviderConfigurationSectionProps> = (
|
||||
props,
|
||||
) => {
|
||||
const toggleProvider = (provider: AIProvider) => {
|
||||
const next = new Set(props.expandedProviders());
|
||||
if (next.has(provider)) {
|
||||
next.delete(provider);
|
||||
} else {
|
||||
next.add(provider);
|
||||
}
|
||||
props.setExpandedProviders(next);
|
||||
};
|
||||
|
||||
const isConfigured = (provider: AIProvider) => isAIProviderConfigured(provider, props.settings());
|
||||
|
||||
const providerIssueProviders = () =>
|
||||
AI_PROVIDERS.filter((provider) => props.providerHealth[provider].status === 'error');
|
||||
|
||||
return (
|
||||
<div class="p-5 rounded-md border border-border bg-surface-alt">
|
||||
<div class="mb-3 space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 class="font-medium text-base-content flex items-center gap-2">
|
||||
<svg
|
||||
class="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
Provider Configuration
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.runProviderPreflight()}
|
||||
disabled={props.preflightRunning() || props.saving()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-3 py-2 text-sm bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50"
|
||||
>
|
||||
{props.preflightRunning() ? 'Checking...' : 'Run Preflight'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
Configure API keys for each provider you want to use. Models from all configured providers
|
||||
will appear in the model selectors.
|
||||
</p>
|
||||
<Show when={props.preflightLastCheckedAt()}>
|
||||
<p class="text-[11px] text-muted">
|
||||
Last checked: {new Date(props.preflightLastCheckedAt()!).toLocaleTimeString()}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={props.providerIssueCount() > 0}>
|
||||
<div class="rounded border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900 px-2 py-1.5">
|
||||
<p class="text-xs text-red-700 dark:text-red-300">
|
||||
{props.providerIssueCount()} provider{props.providerIssueCount() === 1 ? '' : 's'}{' '}
|
||||
configured but currently not usable.
|
||||
</p>
|
||||
<For each={providerIssueProviders()}>
|
||||
{(provider) => (
|
||||
<p class="text-[11px] text-red-600 dark:text-red-300">
|
||||
<span class="font-medium">{getAIProviderDisplayName(provider) || provider}:</span>{' '}
|
||||
{props.providerHealth[provider].message}
|
||||
</p>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<For each={AI_PROVIDER_CONFIGS}>
|
||||
{(config) => {
|
||||
const configured = () => isConfigured(config.provider);
|
||||
const expanded = () => props.expandedProviders().has(config.provider);
|
||||
const health = () => props.providerHealth[config.provider];
|
||||
const testResult = () =>
|
||||
props.providerTestResult()?.provider === config.provider
|
||||
? props.providerTestResult()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`border rounded-md overflow-hidden ${configured() ? 'border-green-300 dark:border-green-700' : 'border-border'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full min-h-10 sm:min-h-9 px-3 py-2.5 flex items-center justify-between bg-surface hover:bg-surface-hover transition-colors"
|
||||
onClick={() => toggleProvider(config.provider)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm">{config.title}</span>
|
||||
<Show when={configured()}>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
|
||||
{config.configuredLabel}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={configured()}>
|
||||
<span
|
||||
class={`px-1.5 py-0.5 text-[10px] font-semibold rounded ${getAIProviderHealthPresentation(health().status).badgeClass}`}
|
||||
>
|
||||
{getAIProviderHealthPresentation(health().status).label}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<svg
|
||||
class={`w-4 h-4 transition-transform ${expanded() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={expanded()}>
|
||||
<div class="px-3 py-3 bg-surface-alt border-t border-border space-y-2">
|
||||
<Show when={config.extraField}>
|
||||
{(extraField) => (
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs text-muted inline-flex items-center gap-1">
|
||||
{extraField().label}
|
||||
<Show when={extraField().helpContentId}>
|
||||
<HelpIcon contentId={extraField().helpContentId!} size="xs" />
|
||||
</Show>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={props.form[extraField().inputField]}
|
||||
onInput={(event) =>
|
||||
props.setForm(extraField().inputField, event.currentTarget.value)
|
||||
}
|
||||
placeholder={extraField().placeholder}
|
||||
class={controlClass()}
|
||||
disabled={props.saving()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={config.provider === 'ollama'}>
|
||||
<label class="text-xs text-muted inline-flex items-center gap-1">
|
||||
Server URL
|
||||
<HelpIcon contentId="ai.ollama.baseUrl" size="xs" />
|
||||
</label>
|
||||
</Show>
|
||||
<input
|
||||
type={config.inputType}
|
||||
value={props.form[config.inputField]}
|
||||
onInput={(event) =>
|
||||
props.setForm(config.inputField, event.currentTarget.value)
|
||||
}
|
||||
placeholder={
|
||||
configured() && config.configuredPlaceholder
|
||||
? config.configuredPlaceholder
|
||||
: config.placeholder
|
||||
}
|
||||
class={controlClass()}
|
||||
disabled={props.saving()}
|
||||
/>
|
||||
|
||||
<Show when={config.helperText}>
|
||||
<p class="text-xs text-slate-500">{config.helperText}</p>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-slate-500">
|
||||
<a
|
||||
href={config.actionLinkHref}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-1 py-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{config.actionLinkLabel}
|
||||
</a>
|
||||
<Show when={config.actionLinkSuffix}>
|
||||
<span class="text-slate-400">{config.actionLinkSuffix}</span>
|
||||
</Show>
|
||||
</p>
|
||||
<Show when={configured()}>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.handleTestProvider(config.provider)}
|
||||
disabled={props.testingProvider() === config.provider || props.saving()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-3 py-2 text-sm bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50"
|
||||
>
|
||||
{props.testingProvider() === config.provider ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.handleClearProvider(config.provider)}
|
||||
disabled={props.saving()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-3 py-2 text-sm bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800 disabled:opacity-50"
|
||||
title={config.clearTitle}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={testResult()}>
|
||||
<p
|
||||
class={`text-xs ${getAIProviderTestResultTextClass(Boolean(testResult()?.success))}`}
|
||||
>
|
||||
{testResult()?.message}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIProviderConfigurationSection;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
import { For, Show, type Accessor, type Component, type Setter } from 'solid-js';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { SelectionCardGroup } from '@/components/shared/SelectionCardGroup';
|
||||
import { getAISessionDiffStatusPresentation } from '@/utils/aiSessionDiffPresentation';
|
||||
import type { FileChange } from '@/api/aiChat';
|
||||
import type { AIProvider } from '@/types/ai';
|
||||
import {
|
||||
AI_SETUP_PROVIDER_OPTIONS,
|
||||
getAIProviderConfig,
|
||||
} from '@/components/Settings/aiSettingsModel';
|
||||
|
||||
export interface AISettingsDialogsProps {
|
||||
showDiffModal: Accessor<boolean>;
|
||||
setShowDiffModal: Setter<boolean>;
|
||||
diffFiles: Accessor<FileChange[]>;
|
||||
diffSummary: Accessor<string>;
|
||||
diffSessionLabel: Accessor<string>;
|
||||
formatDiffStats: (change: FileChange) => string;
|
||||
showSetupModal: Accessor<boolean>;
|
||||
setupProvider: Accessor<AIProvider>;
|
||||
setSetupProvider: Setter<AIProvider>;
|
||||
setupApiKey: Accessor<string>;
|
||||
setSetupApiKey: Setter<string>;
|
||||
setupOllamaUrl: Accessor<string>;
|
||||
setSetupOllamaUrl: Setter<string>;
|
||||
setupSaving: Accessor<boolean>;
|
||||
handleCloseSetupModal: () => void;
|
||||
handleSetupSubmit: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AISettingsDialogs: Component<AISettingsDialogsProps> = (props) => {
|
||||
const setupProviderConfig = () => getAIProviderConfig(props.setupProvider());
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={props.showDiffModal()}>
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
onClose={() => props.setShowDiffModal(false)}
|
||||
panelClass="max-w-2xl"
|
||||
ariaLabel="Session file changes"
|
||||
>
|
||||
<div class="w-full overflow-hidden">
|
||||
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-border">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-base-content">Session File Changes</h3>
|
||||
<p class="text-xs text-muted">{props.diffSessionLabel() || 'Selected session'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-muted hover:text-base-content"
|
||||
onClick={() => props.setShowDiffModal(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<Show when={props.diffSummary()}>
|
||||
<div class="rounded-md border border-border bg-surface-alt p-3">
|
||||
<p class="text-xs font-semibold text-base-content">Summary</p>
|
||||
<p class="text-xs text-muted mt-1 whitespace-pre-wrap">{props.diffSummary()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="space-y-2">
|
||||
<For each={props.diffFiles()}>
|
||||
{(file) => {
|
||||
const diffStatus = getAISessionDiffStatusPresentation(file.status);
|
||||
return (
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between rounded-md border border-border px-3 py-2 text-xs">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase ${diffStatus.badgeClasses}`}
|
||||
>
|
||||
{diffStatus.label}
|
||||
</span>
|
||||
<span class="text-base-content truncate">{file.path}</span>
|
||||
</div>
|
||||
<span class="text-muted sm:flex-shrink-0">
|
||||
{props.formatDiffStats(file)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showSetupModal()}>
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
onClose={props.handleCloseSetupModal}
|
||||
panelClass="max-w-md"
|
||||
closeOnBackdrop={false}
|
||||
ariaLabel="Set up Pulse Assistant"
|
||||
>
|
||||
<div class="w-full overflow-hidden">
|
||||
<div class="bg-blue-600 px-6 py-4">
|
||||
<h3 class="text-lg font-semibold text-white">Set Up Pulse Assistant</h3>
|
||||
<p class="text-blue-100 text-sm mt-1">Choose a provider to get started</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<SelectionCardGroup
|
||||
options={AI_SETUP_PROVIDER_OPTIONS}
|
||||
value={props.setupProvider()}
|
||||
onChange={props.setSetupProvider}
|
||||
variant="compact"
|
||||
/>
|
||||
|
||||
<Show
|
||||
when={props.setupProvider() === 'ollama'}
|
||||
fallback={
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content mb-1.5">
|
||||
{setupProviderConfig().title} API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={props.setupApiKey()}
|
||||
onInput={(event) => props.setSetupApiKey(event.currentTarget.value)}
|
||||
placeholder={setupProviderConfig().placeholder}
|
||||
class="w-full px-3 py-2 border border-border rounded-md bg-surface focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-slate-500 mt-1.5">
|
||||
<a
|
||||
href={setupProviderConfig().actionLinkHref}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-blue-600 hover:underline"
|
||||
>
|
||||
Get your API key →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content mb-1.5">
|
||||
Ollama Server URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={props.setupOllamaUrl()}
|
||||
onInput={(event) => props.setSetupOllamaUrl(event.currentTarget.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
class="w-full px-3 py-2 border border-border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-slate-500 mt-1.5">
|
||||
Ollama runs locally - no API key needed
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-surface-alt border-t border-border flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.handleCloseSetupModal}
|
||||
class="px-4 py-2 text-base-content hover:bg-surface-hover rounded-md"
|
||||
disabled={props.setupSaving()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void props.handleSetupSubmit()}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
|
||||
disabled={
|
||||
props.setupSaving() ||
|
||||
(props.setupProvider() !== 'ollama' && !props.setupApiKey().trim()) ||
|
||||
(props.setupProvider() === 'ollama' && !props.setupOllamaUrl().trim())
|
||||
}
|
||||
>
|
||||
{props.setupSaving() && (
|
||||
<span class="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
Enable Pulse Assistant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AISettingsDialogs;
|
||||
+66
-68
@@ -26,7 +26,10 @@ import alertThresholdsPresentationSource from '@/utils/alertThresholdsPresentati
|
||||
import alertThresholdsSectionPresentationSource from '@/utils/alertThresholdsSectionPresentation.ts?raw';
|
||||
import loginSource from '@/components/Login.tsx?raw';
|
||||
import settingsSource from '../Settings.tsx?raw';
|
||||
import aiSettingsSource from '../AISettings.tsx?raw';
|
||||
import aiSettingsShellSource from '../AISettings.tsx?raw';
|
||||
import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSection.tsx?raw';
|
||||
import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw';
|
||||
import aiSettingsModelSource from '../aiSettingsModel.ts?raw';
|
||||
import reportingPanelSource from '../ReportingPanel.tsx?raw';
|
||||
import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw';
|
||||
import suggestProfileModalSource from '../SuggestProfileModal.tsx?raw';
|
||||
@@ -78,6 +81,13 @@ import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.t
|
||||
import problemResourcesTableSource from '@/pages/DashboardPanels/ProblemResourcesTable.tsx?raw';
|
||||
import workloadTypeBadgesSource from '@/components/shared/workloadTypeBadges.ts?raw';
|
||||
import workloadTypePresentationSource from '@/utils/workloadTypePresentation.ts?raw';
|
||||
|
||||
const aiSettingsSource = [
|
||||
aiSettingsShellSource,
|
||||
aiProviderConfigurationSectionSource,
|
||||
aiSettingsDialogsSource,
|
||||
aiSettingsModelSource,
|
||||
].join('\n');
|
||||
import dashboardGuestPresentationSource from '@/utils/dashboardGuestPresentation.ts?raw';
|
||||
import containerUpdatesSource from '@/stores/containerUpdates.ts?raw';
|
||||
import websocketStoreSource from '@/stores/websocket.ts?raw';
|
||||
@@ -127,9 +137,7 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(agentProfilesPanelSource).not.toContain(
|
||||
'No agents connected. Install an agent to assign profiles.',
|
||||
);
|
||||
expect(agentProfilesPresentationSource).toContain(
|
||||
'export function getAgentProfilesEmptyState',
|
||||
);
|
||||
expect(agentProfilesPresentationSource).toContain('export function getAgentProfilesEmptyState');
|
||||
expect(agentProfilesPresentationSource).toContain(
|
||||
'export function getAgentProfileAssignmentsEmptyState',
|
||||
);
|
||||
@@ -162,7 +170,9 @@ describe('monitored-system model guardrails', () => {
|
||||
"const hostResources = createMemo(() => byType('host'))",
|
||||
);
|
||||
expect(apiTokenManagerSource).not.toContain('isAppContainerDiscoveryResourceType');
|
||||
expect(apiTokenManagerSource).not.toContain("notificationStore.error('Failed to load API tokens')");
|
||||
expect(apiTokenManagerSource).not.toContain(
|
||||
"notificationStore.error('Failed to load API tokens')",
|
||||
);
|
||||
expect(apiTokenManagerSource).not.toContain(
|
||||
"notificationStore.error('Failed to generate API token')",
|
||||
);
|
||||
@@ -176,7 +186,9 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(infrastructureOperationsStateSource).not.toContain('previousHostTypes');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const allHosts = createMemo(');
|
||||
expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const MONITORING_STOPPED_STATUS_LABEL =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain(
|
||||
'const MONITORING_STOPPED_STATUS_LABEL =',
|
||||
);
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const ALLOW_RECONNECT_LABEL =');
|
||||
expect(infrastructureOperationsStateSource).toContain('withPrivilegeEscalation');
|
||||
expect(infrastructureOperationsStateSource).toContain('@/utils/agentCapabilityPresentation');
|
||||
@@ -195,7 +207,9 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClass =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const statusBadgeClasses =');
|
||||
expect(infrastructureOperationsStateSource).toContain('getUnifiedAgentLookupStatusPresentation');
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
'getUnifiedAgentLookupStatusPresentation',
|
||||
);
|
||||
expect(unifiedAgentStatusPresentationSource).toContain(
|
||||
'export function getUnifiedAgentStatusPresentation',
|
||||
);
|
||||
@@ -338,34 +352,20 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(aiSettingsPresentationSource).toContain(
|
||||
'export function getAISettingsReadinessPresentation',
|
||||
);
|
||||
expect(aiSettingsPresentationSource).toContain(
|
||||
'export function getAISettingsLoadingState',
|
||||
);
|
||||
expect(aiSettingsPresentationSource).toContain(
|
||||
'export function getAISettingsLoadErrorMessage',
|
||||
);
|
||||
expect(aiSettingsPresentationSource).toContain(
|
||||
'export function getAISettingsRetryLabel',
|
||||
);
|
||||
expect(aiSettingsPresentationSource).toContain('export function getAISettingsLoadingState');
|
||||
expect(aiSettingsPresentationSource).toContain('export function getAISettingsLoadErrorMessage');
|
||||
expect(aiSettingsPresentationSource).toContain('export function getAISettingsRetryLabel');
|
||||
expect(aiSettingsSource).toContain('SelectionCardGroup');
|
||||
expect(aiSettingsSource).toContain('variant="compact"');
|
||||
expect(updatesSettingsPanelSource).toContain('SelectionCardGroup');
|
||||
expect(updatesSettingsPanelSource).toContain('variant="detail"');
|
||||
expect(aiSettingsPresentationSource).toContain(
|
||||
'export function getAIChatSessionsLoadingState',
|
||||
);
|
||||
expect(aiSettingsPresentationSource).toContain(
|
||||
'export function getAIChatSessionsEmptyState',
|
||||
);
|
||||
expect(aiSettingsPresentationSource).toContain(
|
||||
'export function getAIModelsLoadErrorMessage',
|
||||
);
|
||||
expect(aiSettingsPresentationSource).toContain('export function getAIChatSessionsLoadingState');
|
||||
expect(aiSettingsPresentationSource).toContain('export function getAIChatSessionsEmptyState');
|
||||
expect(aiSettingsPresentationSource).toContain('export function getAIModelsLoadErrorMessage');
|
||||
expect(aiSettingsPresentationSource).toContain(
|
||||
'export function getAIChatSessionsLoadErrorMessage',
|
||||
);
|
||||
expect(aiSettingsPresentationSource).toContain(
|
||||
'export function getAIOAuthErrorMessage',
|
||||
);
|
||||
expect(aiSettingsPresentationSource).toContain('export function getAIOAuthErrorMessage');
|
||||
expect(suggestProfileModalSource).toContain('@/utils/agentProfileSuggestionPresentation');
|
||||
expect(suggestProfileModalSource).toContain('AGENT_PROFILE_SUGGESTION_EXAMPLE_PROMPTS');
|
||||
expect(suggestProfileModalSource).toContain('getAgentProfileSuggestionLoadingState');
|
||||
@@ -494,7 +494,9 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(organizationOverviewPanelSource).toContain('@/utils/organizationSettingsPresentation');
|
||||
expect(organizationAccessPanelSource).toContain('ORGANIZATION_MEMBER_ROLE_OPTIONS');
|
||||
expect(organizationAccessPanelSource).not.toContain("{ value: 'viewer', label: 'Viewer' }");
|
||||
expect(organizationAccessPanelSource).not.toContain("notificationStore.error('User ID is required')");
|
||||
expect(organizationAccessPanelSource).not.toContain(
|
||||
"notificationStore.error('User ID is required')",
|
||||
);
|
||||
expect(organizationAccessPanelSource).not.toContain(
|
||||
"notificationStore.error(error instanceof Error ? error.message : 'Failed to add member')",
|
||||
);
|
||||
@@ -512,7 +514,7 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(organizationSharingPanelSource).toContain('ORGANIZATION_SHARE_ROLE_OPTIONS');
|
||||
expect(organizationSharingPanelSource).toContain('normalizeOrganizationShareRole');
|
||||
expect(organizationSharingPanelSource).not.toContain(
|
||||
"const accessRoleOptions: Array<{ value: ShareAccessRole; label: string }> = [",
|
||||
'const accessRoleOptions: Array<{ value: ShareAccessRole; label: string }> = [',
|
||||
);
|
||||
expect(organizationSharingPanelSource).not.toContain('const normalizeShareRole =');
|
||||
expect(organizationSharingPanelSource).not.toContain(
|
||||
@@ -701,15 +703,11 @@ describe('monitored-system model guardrails', () => {
|
||||
);
|
||||
expect(resourcePickerSource).not.toContain('No resources match your filters');
|
||||
expect(reportableResourceTypesSource).toContain('export const REPORTABLE_RESOURCE_TYPES =');
|
||||
expect(reportableResourceTypesSource).toContain(
|
||||
'export const RESOURCE_PICKER_TYPE_FILTERS',
|
||||
);
|
||||
expect(reportableResourceTypesSource).toContain('export const RESOURCE_PICKER_TYPE_FILTERS');
|
||||
expect(reportableResourceTypesSource).toContain(
|
||||
'export const getResourcePickerTypeFilterLabel',
|
||||
);
|
||||
expect(reportableResourceTypesSource).toContain(
|
||||
'export const getResourcePickerEmptyState',
|
||||
);
|
||||
expect(reportableResourceTypesSource).toContain('export const getResourcePickerEmptyState');
|
||||
expect(reportableResourceTypesSource).toContain(
|
||||
'export const matchesReportableResourceTypeFilter =',
|
||||
);
|
||||
@@ -758,9 +756,7 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(systemSettingsPresentationSource).toContain(
|
||||
'export function getBackupIntervalSelectValue',
|
||||
);
|
||||
expect(systemSettingsPresentationSource).toContain(
|
||||
'export function getBackupIntervalSummary',
|
||||
);
|
||||
expect(systemSettingsPresentationSource).toContain('export function getBackupIntervalSummary');
|
||||
expect(systemSettingsPresentationSource).toContain(
|
||||
'export function getSystemSettingsSaveErrorMessage',
|
||||
);
|
||||
@@ -787,15 +783,25 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(ssoProvidersPanelSource).toContain('SSOProviderTypeIcon');
|
||||
expect(ssoProvidersPanelSource).not.toContain('No SSO providers configured');
|
||||
expect(ssoProvidersPanelSource).not.toContain('Loading SSO providers...');
|
||||
expect(ssoProvidersPanelSource).not.toContain("notificationStore.error('Failed to load SSO providers')");
|
||||
expect(ssoProvidersPanelSource).not.toContain(
|
||||
"notificationStore.error('Failed to load SSO providers')",
|
||||
);
|
||||
expect(ssoProvidersPanelSource).not.toContain(
|
||||
"notificationStore.error('Failed to load provider details')",
|
||||
);
|
||||
expect(ssoProvidersPanelSource).not.toContain("notificationStore.success('Provider deleted')");
|
||||
expect(ssoProvidersPanelSource).not.toContain("notificationStore.error('Failed to delete provider')");
|
||||
expect(ssoProvidersPanelSource).not.toContain("notificationStore.success('Connection test successful')");
|
||||
expect(ssoProvidersPanelSource).not.toContain("notificationStore.error('Failed to test connection')");
|
||||
expect(ssoProvidersPanelSource).not.toContain("notificationStore.error('Please enter an IdP Metadata URL')");
|
||||
expect(ssoProvidersPanelSource).not.toContain(
|
||||
"notificationStore.error('Failed to delete provider')",
|
||||
);
|
||||
expect(ssoProvidersPanelSource).not.toContain(
|
||||
"notificationStore.success('Connection test successful')",
|
||||
);
|
||||
expect(ssoProvidersPanelSource).not.toContain(
|
||||
"notificationStore.error('Failed to test connection')",
|
||||
);
|
||||
expect(ssoProvidersPanelSource).not.toContain(
|
||||
"notificationStore.error('Please enter an IdP Metadata URL')",
|
||||
);
|
||||
expect(ssoProvidersPanelSource).not.toContain('provider.type.toUpperCase()');
|
||||
expect(ssoProvidersPanelSource).not.toContain("provider.type === 'oidc' ? (");
|
||||
expect(ssoProvidersPanelSource).not.toContain(
|
||||
@@ -807,9 +813,7 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderTypeLabel');
|
||||
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderSummary');
|
||||
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderCardClass');
|
||||
expect(ssoProviderPresentationSource).toContain(
|
||||
'export function getSSOProviderAddButtonLabel',
|
||||
);
|
||||
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderAddButtonLabel');
|
||||
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderModalTitle');
|
||||
expect(ssoProviderPresentationSource).toContain(
|
||||
'export function getSSOProviderEmptyStateTitle',
|
||||
@@ -817,12 +821,8 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(ssoProviderPresentationSource).toContain(
|
||||
'export function getSSOProviderEmptyStateDescription',
|
||||
);
|
||||
expect(ssoProviderPresentationSource).toContain(
|
||||
'export function getSSOProvidersLoadingState',
|
||||
);
|
||||
expect(ssoProviderPresentationSource).toContain(
|
||||
'export function getSSOTestResultPresentation',
|
||||
);
|
||||
expect(ssoProviderPresentationSource).toContain('export function getSSOProvidersLoadingState');
|
||||
expect(ssoProviderPresentationSource).toContain('export function getSSOTestResultPresentation');
|
||||
expect(ssoProviderPresentationSource).toContain(
|
||||
'export function getSSOCertificatePresentation',
|
||||
);
|
||||
@@ -854,8 +854,12 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(rolesPanelSource).toContain('createDefaultRBACPermission');
|
||||
expect(rolesPanelSource).toContain('getRBACFeatureGateCopy');
|
||||
expect(rolesPanelSource).toContain('getRolesEmptyState');
|
||||
expect(rolesPanelSource).not.toContain("const ACTIONS = ['read', 'write', 'delete', 'admin', '*']");
|
||||
expect(rolesPanelSource).not.toContain("const RESOURCES = ['settings', 'audit_logs', 'nodes', 'users', 'license', '*']");
|
||||
expect(rolesPanelSource).not.toContain(
|
||||
"const ACTIONS = ['read', 'write', 'delete', 'admin', '*']",
|
||||
);
|
||||
expect(rolesPanelSource).not.toContain(
|
||||
"const RESOURCES = ['settings', 'audit_logs', 'nodes', 'users', 'license', '*']",
|
||||
);
|
||||
expect(rolesPanelSource).not.toContain('Custom Roles (Pro)');
|
||||
expect(rolesPanelSource).not.toContain('No roles available.');
|
||||
expect(rbacPermissionsSource).toContain('export const RBAC_PERMISSION_ACTIONS');
|
||||
@@ -865,12 +869,8 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(rbacPresentationSource).toContain('export function getRolesEmptyState');
|
||||
expect(rbacPresentationSource).toContain('export function getRolesLoadErrorMessage');
|
||||
expect(rbacPresentationSource).toContain('export function getRolesSaveErrorMessage');
|
||||
expect(rbacPresentationSource).toContain(
|
||||
'export function getUserAssignmentsLoadErrorMessage',
|
||||
);
|
||||
expect(rbacPresentationSource).toContain(
|
||||
'export function getUserAssignmentsEmptyStateCopy',
|
||||
);
|
||||
expect(rbacPresentationSource).toContain('export function getUserAssignmentsLoadErrorMessage');
|
||||
expect(rbacPresentationSource).toContain('export function getUserAssignmentsEmptyStateCopy');
|
||||
expect(userAssignmentsPanelSource).toContain('@/utils/rbacPresentation');
|
||||
expect(userAssignmentsPanelSource).toContain('getRBACFeatureGateCopy');
|
||||
expect(userAssignmentsPanelSource).toContain('getUserAssignmentsEmptyStateCopy');
|
||||
@@ -924,9 +924,7 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(thresholdsTableSource).toContain('CONTAINERS_FILTER_EMPTY_STATE');
|
||||
expect(thresholdsTableSource).toContain('getAlertThresholdsGuestFilterPresentation');
|
||||
expect(thresholdsTableSource).toContain('getAlertThresholdsBackupOrphanedPresentation');
|
||||
expect(thresholdsTableSource).toContain(
|
||||
'getAlertThresholdsDockerIgnoredPrefixesPresentation',
|
||||
);
|
||||
expect(thresholdsTableSource).toContain('getAlertThresholdsDockerIgnoredPrefixesPresentation');
|
||||
expect(thresholdsTableSource).not.toContain('No PBS servers configured.');
|
||||
expect(thresholdsTableSource).not.toContain('No VMs or containers found.');
|
||||
expect(thresholdsTableSource).not.toContain('No nodes match the current filters.');
|
||||
@@ -994,9 +992,7 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(alertThresholdsPresentationSource).toContain(
|
||||
'export const NODE_THRESHOLDS_FILTER_EMPTY_STATE',
|
||||
);
|
||||
expect(alertThresholdsPresentationSource).toContain(
|
||||
'export const PMG_THRESHOLDS_EMPTY_STATE',
|
||||
);
|
||||
expect(alertThresholdsPresentationSource).toContain('export const PMG_THRESHOLDS_EMPTY_STATE');
|
||||
expect(alertThresholdsPresentationSource).toContain(
|
||||
'export const ALERT_THRESHOLDS_SEARCH_PLACEHOLDER',
|
||||
);
|
||||
@@ -1120,7 +1116,9 @@ describe('monitored-system model guardrails', () => {
|
||||
expect(settingsSource).not.toContain(
|
||||
"stateHosts={(state.resources ?? []).filter((r) => r.type === 'host')}",
|
||||
);
|
||||
expect(infrastructureSettingsStateSource).toContain('@/utils/infrastructureSettingsPresentation');
|
||||
expect(infrastructureSettingsStateSource).toContain(
|
||||
'@/utils/infrastructureSettingsPresentation',
|
||||
);
|
||||
expect(infrastructureSettingsStateSource).toContain('getDiscoveryScanStartErrorMessage');
|
||||
expect(infrastructureSettingsStateSource).toContain('getDiscoverySettingUpdateErrorMessage');
|
||||
expect(infrastructureSettingsStateSource).toContain('getDiscoverySubnetUpdateErrorMessage');
|
||||
|
||||
@@ -13,6 +13,9 @@ import auditWebhookPanelSource from '../AuditWebhookPanel.tsx?raw';
|
||||
import billingAdminPanelSource from '../BillingAdminPanel.tsx?raw';
|
||||
import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw';
|
||||
import aiSettingsPanelSource from '../AISettings.tsx?raw';
|
||||
import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSection.tsx?raw';
|
||||
import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw';
|
||||
import aiSettingsModelSource from '../aiSettingsModel.ts?raw';
|
||||
import networkSettingsPanelSource from '../NetworkSettingsPanel.tsx?raw';
|
||||
import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw';
|
||||
import recoverySettingsPanelSource from '../RecoverySettingsPanel.tsx?raw';
|
||||
@@ -45,6 +48,9 @@ const extractedModules = [
|
||||
'../InfrastructureWorkspace.tsx',
|
||||
'../InfrastructureInstallPanel.tsx',
|
||||
'../InfrastructureReportingPanel.tsx',
|
||||
'../AIProviderConfigurationSection.tsx',
|
||||
'../AISettingsDialogs.tsx',
|
||||
'../aiSettingsModel.ts',
|
||||
'../ProxmoxSettingsPanel.tsx',
|
||||
'../SettingsDialogs.tsx',
|
||||
'../SettingsPageShell.tsx',
|
||||
@@ -222,8 +228,8 @@ describe('Settings architecture guardrails', () => {
|
||||
expect(commercialBillingModelSource).toContain('buildSelfHostedCommercialPlanModel');
|
||||
expect(commercialBillingModelSource).toContain('buildHostedCommercialPlanModel');
|
||||
expect(commercialBillingModelSource).toContain('buildHostedCommercialUsageModel');
|
||||
expect(proLicensePanelSource).toContain("./CommercialBillingSections");
|
||||
expect(proLicensePanelSource).toContain("buildSelfHostedCommercialPlanModel");
|
||||
expect(proLicensePanelSource).toContain('./CommercialBillingSections');
|
||||
expect(proLicensePanelSource).toContain('buildSelfHostedCommercialPlanModel');
|
||||
expect(proLicensePanelSource).toContain('SelfHostedCommercialActivationSection');
|
||||
expect(proLicensePanelSource).toContain('MonitoredSystemLedgerPanel');
|
||||
expect(proLicensePanelSource).toContain('CommercialBillingShell');
|
||||
@@ -231,9 +237,9 @@ describe('Settings architecture guardrails', () => {
|
||||
expect(proLicensePanelSource).toContain('CommercialStatGrid');
|
||||
expect(selfHostedCommercialActivationSectionSource).toContain('License / Activation Key');
|
||||
expect(selfHostedCommercialActivationSectionSource).toContain('Start 14-day Pro Trial');
|
||||
expect(organizationBillingPanelSource).toContain("./CommercialBillingSections");
|
||||
expect(organizationBillingPanelSource).toContain("buildHostedCommercialPlanModel");
|
||||
expect(organizationBillingPanelSource).toContain("buildHostedCommercialUsageModel");
|
||||
expect(organizationBillingPanelSource).toContain('./CommercialBillingSections');
|
||||
expect(organizationBillingPanelSource).toContain('buildHostedCommercialPlanModel');
|
||||
expect(organizationBillingPanelSource).toContain('buildHostedCommercialUsageModel');
|
||||
expect(organizationBillingPanelSource).toContain('CommercialBillingShell');
|
||||
expect(organizationBillingPanelSource).toContain('CommercialSection');
|
||||
expect(organizationBillingPanelSource).toContain('CommercialUsageMeters');
|
||||
@@ -284,13 +290,15 @@ describe('Settings architecture guardrails', () => {
|
||||
expect(infrastructureInstallPanelSource).toContain('useInfrastructureOperationsState');
|
||||
expect(infrastructureReportingPanelSource).toContain('useInfrastructureOperationsState');
|
||||
expect(infrastructureOperationsControllerSource).toContain('useInfrastructureOperationsState');
|
||||
expect(infrastructureOperationsStateSource).toContain('export const useInfrastructureOperationsState');
|
||||
expect(infrastructureOperationsStateSource).toContain(
|
||||
'export const useInfrastructureOperationsState',
|
||||
);
|
||||
expect(infrastructureInstallPanelSource).not.toContain('<PageHeader');
|
||||
expect(infrastructureReportingPanelSource).not.toContain('<PageHeader');
|
||||
});
|
||||
|
||||
it('keeps the infrastructure operations model extracted from the reactive hook owner', () => {
|
||||
expect(infrastructureOperationsStateSource).toContain("./infrastructureOperationsModel");
|
||||
expect(infrastructureOperationsStateSource).toContain('./infrastructureOperationsModel');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const INSTALL_PROFILE_OPTIONS');
|
||||
expect(infrastructureOperationsStateSource).not.toContain('const buildCommandsByPlatform =');
|
||||
expect(infrastructureOperationsStateSource).not.toContain(
|
||||
@@ -303,6 +311,16 @@ describe('Settings architecture guardrails', () => {
|
||||
expect(infrastructureOperationsModelSource).toContain('export const buildCommandsByPlatform');
|
||||
});
|
||||
|
||||
it('keeps AI settings sub-surfaces behind extracted runtime owners', () => {
|
||||
expect(aiSettingsPanelSource).toContain('@/components/Settings/AIProviderConfigurationSection');
|
||||
expect(aiSettingsPanelSource).toContain('@/components/Settings/AISettingsDialogs');
|
||||
expect(aiSettingsPanelSource).toContain('@/components/Settings/aiSettingsModel');
|
||||
expect(aiProviderConfigurationSectionSource).toContain('@/components/Settings/aiSettingsModel');
|
||||
expect(aiSettingsDialogsSource).toContain('@/components/Settings/aiSettingsModel');
|
||||
expect(aiSettingsModelSource).toContain('export const AI_PROVIDER_CONFIGS');
|
||||
expect(aiSettingsModelSource).toContain('export const AI_SETUP_PROVIDER_OPTIONS');
|
||||
});
|
||||
|
||||
it('routes every top-level settings surface through the canonical panel shell framing', () => {
|
||||
for (const [panelName, source] of Object.entries(topLevelSettingsPanelSources)) {
|
||||
const usesCanonicalShell =
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { SelectionCardOption } from '@/components/shared/SelectionCardGroup';
|
||||
import type { AIProviderHealthStatus } from '@/utils/aiProviderHealthPresentation';
|
||||
import { getProviderFromModelId } from '@/utils/aiProviderPresentation';
|
||||
import type { AIProvider, AISettings as AISettingsType } from '@/types/ai';
|
||||
|
||||
export type AIProviderCredentialsFormState = {
|
||||
anthropicApiKey: string;
|
||||
openaiApiKey: string;
|
||||
openrouterApiKey: string;
|
||||
deepseekApiKey: string;
|
||||
geminiApiKey: string;
|
||||
ollamaBaseUrl: string;
|
||||
openaiBaseUrl: string;
|
||||
};
|
||||
|
||||
export type AIProviderConfig = {
|
||||
provider: AIProvider;
|
||||
title: string;
|
||||
configuredLabel?: string;
|
||||
inputType: 'password' | 'url';
|
||||
inputField: keyof AIProviderCredentialsFormState;
|
||||
placeholder: string;
|
||||
configuredPlaceholder?: string;
|
||||
actionLinkLabel: string;
|
||||
actionLinkHref: string;
|
||||
actionLinkSuffix?: string;
|
||||
helperText?: string;
|
||||
extraField?: {
|
||||
label: string;
|
||||
helpContentId?: string;
|
||||
inputField: 'openaiBaseUrl';
|
||||
placeholder: string;
|
||||
};
|
||||
clearTitle: string;
|
||||
};
|
||||
|
||||
export type ProviderHealthState = {
|
||||
status: AIProviderHealthStatus;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ProviderTestResult = {
|
||||
provider: AIProvider;
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AIAvailableModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const AI_PROVIDERS: AIProvider[] = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'openrouter',
|
||||
'deepseek',
|
||||
'gemini',
|
||||
'ollama',
|
||||
];
|
||||
|
||||
export const AI_SETUP_PROVIDER_OPTIONS: SelectionCardOption<AIProvider>[] = [
|
||||
{ value: 'anthropic', title: 'Anthropic', description: 'Claude' },
|
||||
{ value: 'openai', title: 'OpenAI', description: 'ChatGPT' },
|
||||
{ value: 'openrouter', title: 'OpenRouter', description: 'Gateway' },
|
||||
{ value: 'deepseek', title: 'DeepSeek', description: 'V3' },
|
||||
{ value: 'gemini', title: 'Gemini', description: 'Google' },
|
||||
{ value: 'ollama', title: 'Ollama', description: 'Local' },
|
||||
];
|
||||
|
||||
export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
|
||||
{
|
||||
provider: 'anthropic',
|
||||
title: 'Anthropic',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'anthropicApiKey',
|
||||
placeholder: 'sk-ant-...',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://console.anthropic.com/settings/keys',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'openai',
|
||||
title: 'OpenAI',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'openaiApiKey',
|
||||
placeholder: 'sk-...',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://platform.openai.com/api-keys',
|
||||
clearTitle: 'Clear API key',
|
||||
extraField: {
|
||||
label: 'Custom Base URL',
|
||||
helpContentId: 'ai.openai.baseUrl',
|
||||
inputField: 'openaiBaseUrl',
|
||||
placeholder: 'https://api.together.xyz/v1 (optional)',
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: 'openrouter',
|
||||
title: 'OpenRouter',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'openrouterApiKey',
|
||||
placeholder: 'sk-or-...',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://openrouter.ai/keys',
|
||||
helperText: 'Uses https://openrouter.ai/api/v1 automatically.',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'deepseek',
|
||||
title: 'DeepSeek',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'deepseekApiKey',
|
||||
placeholder: 'sk-...',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://platform.deepseek.com/api_keys',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'gemini',
|
||||
title: 'Google Gemini',
|
||||
configuredLabel: 'Configured',
|
||||
inputType: 'password',
|
||||
inputField: 'geminiApiKey',
|
||||
placeholder: 'AIza...',
|
||||
configuredPlaceholder: '••••••••••• (configured)',
|
||||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://aistudio.google.com/app/apikey',
|
||||
clearTitle: 'Clear API key',
|
||||
},
|
||||
{
|
||||
provider: 'ollama',
|
||||
title: 'Ollama',
|
||||
configuredLabel: 'Available',
|
||||
inputType: 'url',
|
||||
inputField: 'ollamaBaseUrl',
|
||||
placeholder: 'http://localhost:11434',
|
||||
actionLinkLabel: 'Learn about Ollama →',
|
||||
actionLinkHref: 'https://ollama.ai',
|
||||
actionLinkSuffix: ' · Free & local',
|
||||
clearTitle: 'Clear Ollama URL',
|
||||
},
|
||||
];
|
||||
|
||||
export const createInitialProviderHealth = (): Record<AIProvider, ProviderHealthState> => ({
|
||||
anthropic: { status: 'not_configured', message: '' },
|
||||
openai: { status: 'not_configured', message: '' },
|
||||
openrouter: { status: 'not_configured', message: '' },
|
||||
deepseek: { status: 'not_configured', message: '' },
|
||||
gemini: { status: 'not_configured', message: '' },
|
||||
ollama: { status: 'not_configured', message: '' },
|
||||
});
|
||||
|
||||
export function getAIProviderConfig(provider: AIProvider): AIProviderConfig {
|
||||
const config = AI_PROVIDER_CONFIGS.find((entry) => entry.provider === provider);
|
||||
if (!config) {
|
||||
throw new Error(`Unknown AI provider: ${provider}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function isAIProviderConfigured(
|
||||
provider: AIProvider | string,
|
||||
settings: AISettingsType | null,
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
return settings.anthropic_configured;
|
||||
case 'openai':
|
||||
return settings.openai_configured;
|
||||
case 'openrouter':
|
||||
return settings.openrouter_configured;
|
||||
case 'deepseek':
|
||||
return settings.deepseek_configured;
|
||||
case 'gemini':
|
||||
return settings.gemini_configured;
|
||||
case 'ollama':
|
||||
return settings.ollama_configured;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isModelProviderConfigured(
|
||||
modelId: string,
|
||||
settings: AISettingsType | null,
|
||||
): boolean {
|
||||
const provider = getProviderFromModelId(modelId);
|
||||
return isAIProviderConfigured(provider, settings);
|
||||
}
|
||||
|
||||
export function groupModelsByProvider(models: AIAvailableModel[]): Map<string, AIAvailableModel[]> {
|
||||
const grouped = new Map<string, AIAvailableModel[]>();
|
||||
|
||||
for (const model of models) {
|
||||
const provider = getProviderFromModelId(model.id);
|
||||
const existing = grouped.get(provider) || [];
|
||||
existing.push(model);
|
||||
grouped.set(provider, existing);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user