diff --git a/.agents/skills/component-refactoring/SKILL.md b/.agents/skills/component-refactoring/SKILL.md deleted file mode 100644 index a7cae67e8f..0000000000 --- a/.agents/skills/component-refactoring/SKILL.md +++ /dev/null @@ -1,440 +0,0 @@ ---- -name: component-refactoring -description: Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component --json` shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction, or when `pnpm analyze-component` warns to refactor before testing; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring. ---- - -# Dify Component Refactoring Skill - -Refactor high-complexity React components in the Dify frontend codebase with the patterns and workflow below. - -> **Complexity Threshold**: Components with complexity > 50 (measured by `pnpm analyze-component`) should be refactored before testing. - -## Quick Reference - -### Commands (run from `web/`) - -Use paths relative to `web/` (e.g., `app/components/...`). -Use `refactor-component` for refactoring prompts and `analyze-component` for testing prompts and metrics. - -```bash -cd web - -# Generate refactoring prompt -pnpm refactor-component - -# Output refactoring analysis as JSON -pnpm refactor-component --json - -# Generate testing prompt (after refactoring) -pnpm analyze-component - -# Output testing analysis as JSON -pnpm analyze-component --json -``` - -### Complexity Analysis - -```bash -# Analyze component complexity -pnpm analyze-component --json - -# Key metrics to check: -# - complexity: normalized score 0-100 (target < 50) -# - maxComplexity: highest single function complexity -# - lineCount: total lines (target < 300) -``` - -### Complexity Score Interpretation - -| Score | Level | Action | -|-------|-------|--------| -| 0-25 | 🟢 Simple | Ready for testing | -| 26-50 | 🟡 Medium | Consider minor refactoring | -| 51-75 | 🟠 Complex | **Refactor before testing** | -| 76-100 | 🔴 Very Complex | **Must refactor** | - -## Core Refactoring Patterns - -### Pattern 1: Extract Custom Hooks - -**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI. - -**Dify Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-.ts`. - -```typescript -// ❌ Before: Complex state logic in component -function Configuration() { - const [modelConfig, setModelConfig] = useState(...) - const [datasetConfigs, setDatasetConfigs] = useState(...) - const [completionParams, setCompletionParams] = useState({}) - - // 50+ lines of state management logic... - - return
...
-} - -// ✅ After: Extract to custom hook -// hooks/use-model-config.ts -export const useModelConfig = (appId: string) => { - const [modelConfig, setModelConfig] = useState(...) - const [completionParams, setCompletionParams] = useState({}) - - // Related state management logic here - - return { modelConfig, setModelConfig, completionParams, setCompletionParams } -} - -// Component becomes cleaner -function Configuration() { - const { modelConfig, setModelConfig } = useModelConfig(appId) - return
...
-} -``` - -**Dify Examples**: -- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts` -- `web/app/components/app/configuration/debug/hooks.tsx` -- `web/app/components/workflow/hooks/use-workflow.ts` - -### Pattern 2: Extract Sub-Components - -**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns. - -**Dify Convention**: Place sub-components in subdirectories or as separate files in the same directory. - -```typescript -// ❌ Before: Monolithic JSX with multiple sections -const AppInfo = () => { - return ( -
- {/* 100 lines of header UI */} - {/* 100 lines of operations UI */} - {/* 100 lines of modals */} -
- ) -} - -// ✅ After: Split into focused components -// app-info/ -// ├── index.tsx (orchestration only) -// ├── app-header.tsx (header UI) -// ├── app-operations.tsx (operations UI) -// └── app-modals.tsx (modal management) - -const AppInfo = () => { - const { showModal, setShowModal } = useAppInfoModals() - - return ( -
- - - setShowModal(null)} /> -
- ) -} -``` - -**Dify Examples**: -- `web/app/components/app/configuration/` directory structure -- `web/app/components/workflow/nodes/` per-node organization - -### Pattern 3: Simplify Conditional Logic - -**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains. - -```typescript -// ❌ Before: Deeply nested conditionals -const Template = useMemo(() => { - if (appDetail?.mode === AppModeEnum.CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { - // Another 15 lines... - } - // More conditions... -}, [appDetail, locale]) - -// ✅ After: Use lookup tables + early returns -const TEMPLATE_MAP = { - [AppModeEnum.CHAT]: { - [LanguagesSupported[1]]: TemplateChatZh, - [LanguagesSupported[7]]: TemplateChatJa, - default: TemplateChatEn, - }, - [AppModeEnum.ADVANCED_CHAT]: { - [LanguagesSupported[1]]: TemplateAdvancedChatZh, - // ... - }, -} - -const Template = useMemo(() => { - const modeTemplates = TEMPLATE_MAP[appDetail?.mode] - if (!modeTemplates) return null - - const TemplateComponent = modeTemplates[locale] || modeTemplates.default - return -}, [appDetail, locale]) -``` - -### Pattern 4: Extract API/Data Logic - -**When**: Component directly handles API calls, data transformation, or complex async operations. - -**Dify Convention**: -- This skill is for component decomposition, not query/mutation design. -- Do not introduce deprecated `useInvalid` / `useReset`. -- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state. - -**Dify Examples**: -- `web/service/use-workflow.ts` -- `web/service/use-common.ts` -- `web/service/knowledge/use-dataset.ts` -- `web/service/knowledge/use-document.ts` - -### Pattern 5: Extract Modal/Dialog Management - -**When**: Component manages multiple modals with complex open/close states. - -**Dify Convention**: Modals should be extracted with their state management. - -```typescript -// ❌ Before: Multiple modal states in component -const AppInfo = () => { - const [showEditModal, setShowEditModal] = useState(false) - const [showDuplicateModal, setShowDuplicateModal] = useState(false) - const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const [showSwitchModal, setShowSwitchModal] = useState(false) - const [showImportDSLModal, setShowImportDSLModal] = useState(false) - // 5+ more modal states... -} - -// ✅ After: Extract to modal management hook -type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null - -const useAppInfoModals = () => { - const [activeModal, setActiveModal] = useState(null) - - const openModal = useCallback((type: ModalType) => setActiveModal(type), []) - const closeModal = useCallback(() => setActiveModal(null), []) - - return { - activeModal, - openModal, - closeModal, - isOpen: (type: ModalType) => activeModal === type, - } -} -``` - -### Pattern 6: Extract Form Logic - -**When**: Complex form validation, submission handling, or field transformation. - -**Dify Convention**: Use `@tanstack/react-form` patterns from `web/app/components/base/form/`. - -```typescript -// ✅ Use existing form infrastructure -import { useAppForm } from '@/app/components/base/form' - -const ConfigForm = () => { - const form = useAppForm({ - defaultValues: { name: '', description: '' }, - onSubmit: handleSubmit, - }) - - return ... -} -``` - -## Dify-Specific Refactoring Guidelines - -### 1. Context Provider Extraction - -**When**: Component provides complex context values with multiple states. - -```typescript -// ❌ Before: Large context value object -const value = { - appId, isAPIKeySet, isTrailFinished, mode, modelModeType, - promptMode, isAdvancedMode, isAgent, isOpenAI, isFunctionCall, - // 50+ more properties... -} -return ... - -// ✅ After: Split into domain-specific contexts - - - - {children} - - - -``` - -**Dify Reference**: `web/context/` directory structure - -### 2. Workflow Node Components - -**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`). - -**Conventions**: -- Keep node logic in `use-interactions.ts` -- Extract panel UI to separate files -- Use `_base` components for common patterns - -``` -nodes// - ├── index.tsx # Node registration - ├── node.tsx # Node visual component - ├── panel.tsx # Configuration panel - ├── use-interactions.ts # Node-specific hooks - └── types.ts # Type definitions -``` - -### 3. Configuration Components - -**When**: Refactoring app configuration components. - -**Conventions**: -- Separate config sections into subdirectories -- Use existing patterns from `web/app/components/app/configuration/` -- Keep feature toggles in dedicated components - -### 4. Tool/Plugin Components - -**When**: Refactoring tool-related components (`web/app/components/tools/`). - -**Conventions**: -- Follow existing modal patterns -- Use service hooks from `web/service/use-tools.ts` -- Keep provider-specific logic isolated - -## Refactoring Workflow - -### Step 1: Generate Refactoring Prompt - -```bash -pnpm refactor-component -``` - -This command will: -- Analyze component complexity and features -- Identify specific refactoring actions needed -- Generate a prompt for AI assistant (auto-copied to clipboard on macOS) -- Provide detailed requirements based on detected patterns - -### Step 2: Analyze Details - -```bash -pnpm analyze-component --json -``` - -Identify: -- Total complexity score -- Max function complexity -- Line count -- Features detected (state, effects, API, etc.) - -### Step 3: Plan - -Create a refactoring plan based on detected features: - -| Detected Feature | Refactoring Action | -|------------------|-------------------| -| `hasState: true` + `hasEffects: true` | Extract custom hook | -| `hasAPI: true` | Extract data/service hook | -| `hasEvents: true` (many) | Extract event handlers | -| `lineCount > 300` | Split into sub-components | -| `maxComplexity > 50` | Simplify conditional logic | - -### Step 4: Execute Incrementally - -1. **Extract one piece at a time** -2. **Run lint, type-check, and tests after each extraction** -3. **Verify functionality before next step** - -``` -For each extraction: - ┌────────────────────────────────────────┐ - │ 1. Extract code │ - │ 2. Run: pnpm lint:fix │ - │ 3. Run: pnpm type-check │ - │ 4. Run: pnpm test │ - │ 5. Test functionality manually │ - │ 6. PASS? → Next extraction │ - │ FAIL? → Fix before continuing │ - └────────────────────────────────────────┘ -``` - -### Step 5: Verify - -After refactoring: - -```bash -# Re-run refactor command to verify improvements -pnpm refactor-component - -# If complexity < 25 and lines < 200, you'll see: -# ✅ COMPONENT IS WELL-STRUCTURED - -# For detailed metrics: -pnpm analyze-component --json - -# Target metrics: -# - complexity < 50 -# - lineCount < 300 -# - maxComplexity < 30 -``` - -## Common Mistakes to Avoid - -### ❌ Over-Engineering - -```typescript -// ❌ Too many tiny hooks -const useButtonText = () => useState('Click') -const useButtonDisabled = () => useState(false) -const useButtonLoading = () => useState(false) - -// ✅ Cohesive hook with related state -const useButtonState = () => { - const [text, setText] = useState('Click') - const [disabled, setDisabled] = useState(false) - const [loading, setLoading] = useState(false) - return { text, setText, disabled, setDisabled, loading, setLoading } -} -``` - -### ❌ Breaking Existing Patterns - -- Follow existing directory structures -- Maintain naming conventions -- Preserve export patterns for compatibility - -### ❌ Premature Abstraction - -- Only extract when there's clear complexity benefit -- Don't create abstractions for single-use code -- Keep refactored code in the same domain area - -## References - -### Dify Codebase Examples - -- **Hook extraction**: `web/app/components/app/configuration/hooks/` -- **Component splitting**: `web/app/components/app/configuration/` -- **Service hooks**: `web/service/use-*.ts` -- **Workflow patterns**: `web/app/components/workflow/hooks/` -- **Form patterns**: `web/app/components/base/form/` - -### Related Skills - -- `frontend-testing` - For testing refactored components -- `web/docs/test.md` - Testing specification diff --git a/.agents/skills/component-refactoring/references/complexity-patterns.md b/.agents/skills/component-refactoring/references/complexity-patterns.md deleted file mode 100644 index 2873630d4b..0000000000 --- a/.agents/skills/component-refactoring/references/complexity-patterns.md +++ /dev/null @@ -1,495 +0,0 @@ -# Complexity Reduction Patterns - -This document provides patterns for reducing cognitive complexity in Dify React components. - -## Understanding Complexity - -### SonarJS Cognitive Complexity - -The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics: - -- **Total Complexity**: Sum of all functions' complexity in the file -- **Max Complexity**: Highest single function complexity - -### What Increases Complexity - -| Pattern | Complexity Impact | -|---------|-------------------| -| `if/else` | +1 per branch | -| Nested conditions | +1 per nesting level | -| `switch/case` | +1 per case | -| `for/while/do` | +1 per loop | -| `&&`/`||` chains | +1 per operator | -| Nested callbacks | +1 per nesting level | -| `try/catch` | +1 per catch | -| Ternary expressions | +1 per nesting | - -## Pattern 1: Replace Conditionals with Lookup Tables - -**Before** (complexity: ~15): - -```typescript -const Template = useMemo(() => { - if (appDetail?.mode === AppModeEnum.CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.WORKFLOW) { - // Similar pattern... - } - return null -}, [appDetail, locale]) -``` - -**After** (complexity: ~3): - -```typescript -import type { ComponentType } from 'react' - -// Define lookup table outside component -const TEMPLATE_MAP: Record>> = { - [AppModeEnum.CHAT]: { - [LanguagesSupported[1]]: TemplateChatZh, - [LanguagesSupported[7]]: TemplateChatJa, - default: TemplateChatEn, - }, - [AppModeEnum.ADVANCED_CHAT]: { - [LanguagesSupported[1]]: TemplateAdvancedChatZh, - [LanguagesSupported[7]]: TemplateAdvancedChatJa, - default: TemplateAdvancedChatEn, - }, - [AppModeEnum.WORKFLOW]: { - [LanguagesSupported[1]]: TemplateWorkflowZh, - [LanguagesSupported[7]]: TemplateWorkflowJa, - default: TemplateWorkflowEn, - }, - // ... -} - -// Clean component logic -const Template = useMemo(() => { - if (!appDetail?.mode) return null - - const templates = TEMPLATE_MAP[appDetail.mode] - if (!templates) return null - - const TemplateComponent = templates[locale] ?? templates.default - return -}, [appDetail, locale]) -``` - -## Pattern 2: Use Early Returns - -**Before** (complexity: ~10): - -```typescript -const handleSubmit = () => { - if (isValid) { - if (hasChanges) { - if (isConnected) { - submitData() - } else { - showConnectionError() - } - } else { - showNoChangesMessage() - } - } else { - showValidationError() - } -} -``` - -**After** (complexity: ~4): - -```typescript -const handleSubmit = () => { - if (!isValid) { - showValidationError() - return - } - - if (!hasChanges) { - showNoChangesMessage() - return - } - - if (!isConnected) { - showConnectionError() - return - } - - submitData() -} -``` - -## Pattern 3: Extract Complex Conditions - -**Before** (complexity: high): - -```typescript -const canPublish = (() => { - if (mode !== AppModeEnum.COMPLETION) { - if (!isAdvancedMode) - return true - - if (modelModeType === ModelModeType.completion) { - if (!hasSetBlockStatus.history || !hasSetBlockStatus.query) - return false - return true - } - return true - } - return !promptEmpty -})() -``` - -**After** (complexity: lower): - -```typescript -// Extract to named functions -const canPublishInCompletionMode = () => !promptEmpty - -const canPublishInChatMode = () => { - if (!isAdvancedMode) return true - if (modelModeType !== ModelModeType.completion) return true - return hasSetBlockStatus.history && hasSetBlockStatus.query -} - -// Clean main logic -const canPublish = mode === AppModeEnum.COMPLETION - ? canPublishInCompletionMode() - : canPublishInChatMode() -``` - -## Pattern 4: Replace Chained Ternaries - -**Before** (complexity: ~5): - -```typescript -const statusText = serverActivated - ? t('status.running') - : serverPublished - ? t('status.inactive') - : appUnpublished - ? t('status.unpublished') - : t('status.notConfigured') -``` - -**After** (complexity: ~2): - -```typescript -const getStatusText = () => { - if (serverActivated) return t('status.running') - if (serverPublished) return t('status.inactive') - if (appUnpublished) return t('status.unpublished') - return t('status.notConfigured') -} - -const statusText = getStatusText() -``` - -Or use lookup: - -```typescript -const STATUS_TEXT_MAP = { - running: 'status.running', - inactive: 'status.inactive', - unpublished: 'status.unpublished', - notConfigured: 'status.notConfigured', -} as const - -const getStatusKey = (): keyof typeof STATUS_TEXT_MAP => { - if (serverActivated) return 'running' - if (serverPublished) return 'inactive' - if (appUnpublished) return 'unpublished' - return 'notConfigured' -} - -const statusText = t(STATUS_TEXT_MAP[getStatusKey()]) -``` - -## Pattern 5: Flatten Nested Loops - -**Before** (complexity: high): - -```typescript -const processData = (items: Item[]) => { - const results: ProcessedItem[] = [] - - for (const item of items) { - if (item.isValid) { - for (const child of item.children) { - if (child.isActive) { - for (const prop of child.properties) { - if (prop.value !== null) { - results.push({ - itemId: item.id, - childId: child.id, - propValue: prop.value, - }) - } - } - } - } - } - } - - return results -} -``` - -**After** (complexity: lower): - -```typescript -// Use functional approach -const processData = (items: Item[]) => { - return items - .filter(item => item.isValid) - .flatMap(item => - item.children - .filter(child => child.isActive) - .flatMap(child => - child.properties - .filter(prop => prop.value !== null) - .map(prop => ({ - itemId: item.id, - childId: child.id, - propValue: prop.value, - })) - ) - ) -} -``` - -## Pattern 6: Extract Event Handler Logic - -**Before** (complexity: high in component): - -```typescript -const Component = () => { - const handleSelect = (data: DataSet[]) => { - if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) { - hideSelectDataSet() - return - } - - formattingChangedDispatcher() - let newDatasets = data - if (data.find(item => !item.name)) { - const newSelected = produce(data, (draft) => { - data.forEach((item, index) => { - if (!item.name) { - const newItem = dataSets.find(i => i.id === item.id) - if (newItem) - draft[index] = newItem - } - }) - }) - setDataSets(newSelected) - newDatasets = newSelected - } - else { - setDataSets(data) - } - hideSelectDataSet() - - // 40 more lines of logic... - } - - return
...
-} -``` - -**After** (complexity: lower): - -```typescript -// Extract to hook or utility -const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState) => { - const normalizeSelection = (data: DataSet[]) => { - const hasUnloadedItem = data.some(item => !item.name) - if (!hasUnloadedItem) return data - - return produce(data, (draft) => { - data.forEach((item, index) => { - if (!item.name) { - const existing = dataSets.find(i => i.id === item.id) - if (existing) draft[index] = existing - } - }) - }) - } - - const hasSelectionChanged = (newData: DataSet[]) => { - return !isEqual( - newData.map(item => item.id), - dataSets.map(item => item.id) - ) - } - - return { normalizeSelection, hasSelectionChanged } -} - -// Component becomes cleaner -const Component = () => { - const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets) - - const handleSelect = (data: DataSet[]) => { - if (!hasSelectionChanged(data)) { - hideSelectDataSet() - return - } - - formattingChangedDispatcher() - const normalized = normalizeSelection(data) - setDataSets(normalized) - hideSelectDataSet() - } - - return
...
-} -``` - -## Pattern 7: Reduce Boolean Logic Complexity - -**Before** (complexity: ~8): - -```typescript -const toggleDisabled = hasInsufficientPermissions - || appUnpublished - || missingStartNode - || triggerModeDisabled - || (isAdvancedApp && !currentWorkflow?.graph) - || (isBasicApp && !basicAppConfig.updated_at) -``` - -**After** (complexity: ~3): - -```typescript -// Extract meaningful boolean functions -const isAppReady = () => { - if (isAdvancedApp) return !!currentWorkflow?.graph - return !!basicAppConfig.updated_at -} - -const hasRequiredPermissions = () => { - return isCurrentWorkspaceEditor && !hasInsufficientPermissions -} - -const canToggle = () => { - if (!hasRequiredPermissions()) return false - if (!isAppReady()) return false - if (missingStartNode) return false - if (triggerModeDisabled) return false - return true -} - -const toggleDisabled = !canToggle() -``` - -## Pattern 8: Simplify useMemo/useCallback Dependencies - -**Before** (complexity: multiple recalculations): - -```typescript -const payload = useMemo(() => { - let parameters: Parameter[] = [] - let outputParameters: OutputParameter[] = [] - - if (!published) { - parameters = (inputs || []).map((item) => ({ - name: item.variable, - description: '', - form: 'llm', - required: item.required, - type: item.type, - })) - outputParameters = (outputs || []).map((item) => ({ - name: item.variable, - description: '', - type: item.value_type, - })) - } - else if (detail && detail.tool) { - parameters = (inputs || []).map((item) => ({ - // Complex transformation... - })) - outputParameters = (outputs || []).map((item) => ({ - // Complex transformation... - })) - } - - return { - icon: detail?.icon || icon, - label: detail?.label || name, - // ...more fields - } -}, [detail, published, workflowAppId, icon, name, description, inputs, outputs]) -``` - -**After** (complexity: separated concerns): - -```typescript -// Separate transformations -const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => { - return useMemo(() => { - if (!published) { - return inputs.map(item => ({ - name: item.variable, - description: '', - form: 'llm', - required: item.required, - type: item.type, - })) - } - - if (!detail?.tool) return [] - - return inputs.map(item => ({ - name: item.variable, - required: item.required, - type: item.type === 'paragraph' ? 'string' : item.type, - description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '', - form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm', - })) - }, [inputs, detail, published]) -} - -// Component uses hook -const parameters = useParameterTransform(inputs, detail, published) -const outputParameters = useOutputTransform(outputs, detail, published) - -const payload = useMemo(() => ({ - icon: detail?.icon || icon, - label: detail?.label || name, - parameters, - outputParameters, - // ... -}), [detail, icon, name, parameters, outputParameters]) -``` - -## Target Metrics After Refactoring - -| Metric | Target | -|--------|--------| -| Total Complexity | < 50 | -| Max Function Complexity | < 30 | -| Function Length | < 30 lines | -| Nesting Depth | ≤ 3 levels | -| Conditional Chains | ≤ 3 conditions | diff --git a/.agents/skills/component-refactoring/references/component-splitting.md b/.agents/skills/component-refactoring/references/component-splitting.md deleted file mode 100644 index 81c007e005..0000000000 --- a/.agents/skills/component-refactoring/references/component-splitting.md +++ /dev/null @@ -1,477 +0,0 @@ -# Component Splitting Patterns - -This document provides detailed guidance on splitting large components into smaller, focused components in Dify. - -## When to Split Components - -Split a component when you identify: - -1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently -1. **Conditional rendering blocks** - Large `{condition && }` blocks -1. **Repeated patterns** - Similar UI structures used multiple times -1. **300+ lines** - Component exceeds manageable size -1. **Modal clusters** - Multiple modals rendered in one component - -## Splitting Strategies - -### Strategy 1: Section-Based Splitting - -Identify visual sections and extract each as a component. - -```typescript -// ❌ Before: Monolithic component (500+ lines) -const ConfigurationPage = () => { - return ( -
- {/* Header Section - 50 lines */} -
-

{t('configuration.title')}

-
- {isAdvancedMode && Advanced} - - -
-
- - {/* Config Section - 200 lines */} -
- -
- - {/* Debug Section - 150 lines */} -
- -
- - {/* Modals Section - 100 lines */} - {showSelectDataSet && } - {showHistoryModal && } - {showUseGPT4Confirm && } -
- ) -} - -// ✅ After: Split into focused components -// configuration/ -// ├── index.tsx (orchestration) -// ├── configuration-header.tsx -// ├── configuration-content.tsx -// ├── configuration-debug.tsx -// └── configuration-modals.tsx - -// configuration-header.tsx -interface ConfigurationHeaderProps { - isAdvancedMode: boolean - onPublish: () => void -} - -function ConfigurationHeader({ - isAdvancedMode, - onPublish, -}: ConfigurationHeaderProps) { - const { t } = useTranslation() - - return ( -
-

{t('configuration.title')}

-
- {isAdvancedMode && Advanced} - - -
-
- ) -} - -// index.tsx (orchestration only) -const ConfigurationPage = () => { - const { modelConfig, setModelConfig } = useModelConfig() - const { activeModal, openModal, closeModal } = useModalState() - - return ( -
- - - {!isMobile && ( - - )} - -
- ) -} -``` - -### Strategy 2: Conditional Block Extraction - -Extract large conditional rendering blocks. - -```typescript -// ❌ Before: Large conditional blocks -const AppInfo = () => { - return ( -
- {expand ? ( -
- {/* 100 lines of expanded view */} -
- ) : ( -
- {/* 50 lines of collapsed view */} -
- )} -
- ) -} - -// ✅ After: Separate view components -function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) { - return ( -
- {/* Clean, focused expanded view */} -
- ) -} - -function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) { - return ( -
- {/* Clean, focused collapsed view */} -
- ) -} - -const AppInfo = () => { - return ( -
- {expand - ? - : - } -
- ) -} -``` - -### Strategy 3: Modal Extraction - -Extract modals with their trigger logic. - -```typescript -// ❌ Before: Multiple modals in one component -const AppInfo = () => { - const [showEdit, setShowEdit] = useState(false) - const [showDuplicate, setShowDuplicate] = useState(false) - const [showDelete, setShowDelete] = useState(false) - const [showSwitch, setShowSwitch] = useState(false) - - const onEdit = async (data) => { /* 20 lines */ } - const onDuplicate = async (data) => { /* 20 lines */ } - const onDelete = async () => { /* 15 lines */ } - - return ( -
- {/* Main content */} - - {showEdit && setShowEdit(false)} />} - {showDuplicate && setShowDuplicate(false)} />} - {showDelete && setShowDelete(false)} />} - {showSwitch && } -
- ) -} - -// ✅ After: Modal manager component -// app-info-modals.tsx -type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | null - -interface AppInfoModalsProps { - appDetail: AppDetail - activeModal: ModalType - onClose: () => void - onSuccess: () => void -} - -function AppInfoModals({ - appDetail, - activeModal, - onClose, - onSuccess, -}: AppInfoModalsProps) { - const handleEdit = async (data) => { /* logic */ } - const handleDuplicate = async (data) => { /* logic */ } - const handleDelete = async () => { /* logic */ } - - return ( - <> - {activeModal === 'edit' && ( - - )} - {activeModal === 'duplicate' && ( - - )} - {activeModal === 'delete' && ( - - )} - {activeModal === 'switch' && ( - - )} - - ) -} - -// Parent component -const AppInfo = () => { - const { activeModal, openModal, closeModal } = useModalState() - - return ( -
- {/* Main content with openModal triggers */} - - - -
- ) -} -``` - -### Strategy 4: List Item Extraction - -Extract repeated item rendering. - -```typescript -// ❌ Before: Inline item rendering -const OperationsList = () => { - return ( -
- {operations.map(op => ( -
- {op.icon} - {op.title} - {op.description} - - {op.badge && {op.badge}} - {/* More complex rendering... */} -
- ))} -
- ) -} - -// ✅ After: Extracted item component -interface OperationItemProps { - operation: Operation - onAction: (id: string) => void -} - -function OperationItem({ operation, onAction }: OperationItemProps) { - return ( -
- {operation.icon} - {operation.title} - {operation.description} - - {operation.badge && {operation.badge}} -
- ) -} - -const OperationsList = () => { - const handleAction = useCallback((id: string) => { - const op = operations.find(o => o.id === id) - op?.onClick() - }, [operations]) - - return ( -
- {operations.map(op => ( - - ))} -
- ) -} -``` - -## Directory Structure Patterns - -### Pattern A: Flat Structure (Simple Components) - -For components with 2-3 sub-components: - -``` -component-name/ - ├── index.tsx # Main component - ├── sub-component-a.tsx - ├── sub-component-b.tsx - └── types.ts # Shared types -``` - -### Pattern B: Nested Structure (Complex Components) - -For components with many sub-components: - -``` -component-name/ - ├── index.tsx # Main orchestration - ├── types.ts # Shared types - ├── hooks/ - │ ├── use-feature-a.ts - │ └── use-feature-b.ts - ├── components/ - │ ├── header/ - │ │ └── index.tsx - │ ├── content/ - │ │ └── index.tsx - │ └── modals/ - │ └── index.tsx - └── utils/ - └── helpers.ts -``` - -### Pattern C: Feature-Based Structure (Dify Standard) - -Following Dify's existing patterns: - -``` -configuration/ - ├── index.tsx # Main page component - ├── base/ # Base/shared components - │ ├── feature-panel/ - │ ├── group-name/ - │ └── operation-btn/ - ├── config/ # Config section - │ ├── index.tsx - │ ├── agent/ - │ └── automatic/ - ├── dataset-config/ # Dataset section - │ ├── index.tsx - │ ├── card-item/ - │ └── params-config/ - ├── debug/ # Debug section - │ ├── index.tsx - │ └── hooks.tsx - └── hooks/ # Shared hooks - └── use-advanced-prompt-config.ts -``` - -## Props Design - -### Minimal Props Principle - -Pass only what's needed: - -```typescript -// ❌ Bad: Passing entire objects when only some fields needed - - -// ✅ Good: Destructure to minimum required - -``` - -### Callback Props Pattern - -Use callbacks for child-to-parent communication: - -```typescript -// Parent -const Parent = () => { - const [value, setValue] = useState('') - - return ( - - ) -} - -// Child -interface ChildProps { - value: string - onChange: (value: string) => void - onSubmit: () => void -} - -function Child({ value, onChange, onSubmit }: ChildProps) { - return ( -
- onChange(e.target.value)} /> - -
- ) -} -``` - -### Render Props for Flexibility - -When sub-components need parent context: - -```typescript -interface ListProps { - items: T[] - renderItem: (item: T, index: number) => React.ReactNode - renderEmpty?: () => React.ReactNode -} - -function List({ items, renderItem, renderEmpty }: ListProps) { - if (items.length === 0 && renderEmpty) { - return <>{renderEmpty()} - } - - return ( -
- {items.map((item, index) => renderItem(item, index))} -
- ) -} - -// Usage - } - renderEmpty={() => } -/> -``` diff --git a/.agents/skills/component-refactoring/references/hook-extraction.md b/.agents/skills/component-refactoring/references/hook-extraction.md deleted file mode 100644 index 6fad2c8885..0000000000 --- a/.agents/skills/component-refactoring/references/hook-extraction.md +++ /dev/null @@ -1,281 +0,0 @@ -# Hook Extraction Patterns - -This document provides detailed guidance on extracting custom hooks from complex components in Dify. - -## When to Extract Hooks - -Extract a custom hook when you identify: - -1. **Coupled state groups** - Multiple `useState` hooks that are always used together -1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic -1. **Business logic** - Data transformations, validations, or calculations -1. **Reusable patterns** - Logic that appears in multiple components - -## Extraction Process - -### Step 1: Identify State Groups - -Look for state variables that are logically related: - -```typescript -// ❌ These belong together - extract to hook -const [modelConfig, setModelConfig] = useState(...) -const [completionParams, setCompletionParams] = useState({}) -const [modelModeType, setModelModeType] = useState(...) - -// These are model-related state that should be in useModelConfig() -``` - -### Step 2: Identify Related Effects - -Find effects that modify the grouped state: - -```typescript -// ❌ These effects belong with the state above -useEffect(() => { - if (hasFetchedDetail && !modelModeType) { - const mode = currModel?.model_properties.mode - if (mode) { - const newModelConfig = produce(modelConfig, (draft) => { - draft.mode = mode - }) - setModelConfig(newModelConfig) - } - } -}, [textGenerationModelList, hasFetchedDetail, modelModeType, currModel]) -``` - -### Step 3: Create the Hook - -```typescript -// hooks/use-model-config.ts -import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { ModelConfig } from '@/models/debug' -import { produce } from 'immer' -import { useEffect, useState } from 'react' -import { ModelModeType } from '@/types/app' - -interface UseModelConfigParams { - initialConfig?: Partial - currModel?: { model_properties?: { mode?: ModelModeType } } - hasFetchedDetail: boolean -} - -interface UseModelConfigReturn { - modelConfig: ModelConfig - setModelConfig: (config: ModelConfig) => void - completionParams: FormValue - setCompletionParams: (params: FormValue) => void - modelModeType: ModelModeType -} - -export const useModelConfig = ({ - initialConfig, - currModel, - hasFetchedDetail, -}: UseModelConfigParams): UseModelConfigReturn => { - const [modelConfig, setModelConfig] = useState({ - provider: 'langgenius/openai/openai', - model_id: 'gpt-3.5-turbo', - mode: ModelModeType.unset, - // ... default values - ...initialConfig, - }) - - const [completionParams, setCompletionParams] = useState({}) - - const modelModeType = modelConfig.mode - - // Fill old app data missing model mode - useEffect(() => { - if (hasFetchedDetail && !modelModeType) { - const mode = currModel?.model_properties?.mode - if (mode) { - setModelConfig(produce(modelConfig, (draft) => { - draft.mode = mode - })) - } - } - }, [hasFetchedDetail, modelModeType, currModel]) - - return { - modelConfig, - setModelConfig, - completionParams, - setCompletionParams, - modelModeType, - } -} -``` - -### Step 4: Update Component - -```typescript -// Before: 50+ lines of state management -function Configuration() { - const [modelConfig, setModelConfig] = useState(...) - // ... lots of related state and effects -} - -// After: Clean component -function Configuration() { - const { - modelConfig, - setModelConfig, - completionParams, - setCompletionParams, - modelModeType, - } = useModelConfig({ - currModel, - hasFetchedDetail, - }) - - // Component now focuses on UI -} -``` - -## Naming Conventions - -### Hook Names - -- Use `use` prefix: `useModelConfig`, `useDatasetConfig` -- Be specific: `useAdvancedPromptConfig` not `usePrompt` -- Include domain: `useWorkflowVariables`, `useMCPServer` - -### File Names - -- Kebab-case: `use-model-config.ts` -- Place in `hooks/` subdirectory when multiple hooks exist -- Place alongside component for single-use hooks - -### Return Type Names - -- Suffix with `Return`: `UseModelConfigReturn` -- Suffix params with `Params`: `UseModelConfigParams` - -## Common Hook Patterns in Dify - -### 1. Data Fetching / Mutation Hooks - -When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns. - -- Do not introduce deprecated `useInvalid` / `useReset`. -- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks. - -### 2. Form State Hook - -```typescript -// Pattern: Form state + validation + submission -export const useConfigForm = (initialValues: ConfigFormValues) => { - const [values, setValues] = useState(initialValues) - const [errors, setErrors] = useState>({}) - const [isSubmitting, setIsSubmitting] = useState(false) - - const validate = useCallback(() => { - const newErrors: Record = {} - if (!values.name) newErrors.name = 'Name is required' - setErrors(newErrors) - return Object.keys(newErrors).length === 0 - }, [values]) - - const handleChange = useCallback((field: string, value: any) => { - setValues(prev => ({ ...prev, [field]: value })) - }, []) - - const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise) => { - if (!validate()) return - setIsSubmitting(true) - try { - await onSubmit(values) - } finally { - setIsSubmitting(false) - } - }, [values, validate]) - - return { values, errors, isSubmitting, handleChange, handleSubmit } -} -``` - -### 3. Modal State Hook - -```typescript -// Pattern: Multiple modal management -type ModalType = 'edit' | 'delete' | 'duplicate' | null - -export const useModalState = () => { - const [activeModal, setActiveModal] = useState(null) - const [modalData, setModalData] = useState(null) - - const openModal = useCallback((type: ModalType, data?: any) => { - setActiveModal(type) - setModalData(data) - }, []) - - const closeModal = useCallback(() => { - setActiveModal(null) - setModalData(null) - }, []) - - return { - activeModal, - modalData, - openModal, - closeModal, - isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]), - } -} -``` - -### 4. Toggle/Boolean Hook - -```typescript -// Pattern: Boolean state with convenience methods -export const useToggle = (initialValue = false) => { - const [value, setValue] = useState(initialValue) - - const toggle = useCallback(() => setValue(v => !v), []) - const setTrue = useCallback(() => setValue(true), []) - const setFalse = useCallback(() => setValue(false), []) - - return [value, { toggle, setTrue, setFalse, set: setValue }] as const -} - -// Usage -const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle() -``` - -## Testing Extracted Hooks - -After extraction, test hooks in isolation: - -```typescript -// use-model-config.spec.ts -import { renderHook, act } from '@testing-library/react' -import { useModelConfig } from './use-model-config' - -describe('useModelConfig', () => { - it('should initialize with default values', () => { - const { result } = renderHook(() => useModelConfig({ - hasFetchedDetail: false, - })) - - expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai') - expect(result.current.modelModeType).toBe(ModelModeType.unset) - }) - - it('should update model config', () => { - const { result } = renderHook(() => useModelConfig({ - hasFetchedDetail: true, - })) - - act(() => { - result.current.setModelConfig({ - ...result.current.modelConfig, - model_id: 'gpt-4', - }) - }) - - expect(result.current.modelConfig.model_id).toBe('gpt-4') - }) -}) -``` diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 55ad08941c..738ec9de95 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -1,6 +1,6 @@ --- name: how-to-write-component -description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling. +description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling. --- # How To Write A Component @@ -12,26 +12,79 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit. - Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them. - Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature. +- Prefer local code and purpose-named helpers over catch-all utility modules; do not group workflow-specific defaults, validation, payload shaping, or metadata merging in a generic utils file just because they share a DTO. +- Keep source/default selection, validation, and payload shaping close to the workflow that owns the behavior. Do not extract a shared helper just because two flows read the same DTO when their priority order, fallback behavior, or submit semantics differ. +- Prefer direct, readable conditionals at the use site for small branch-specific decisions, especially form source selection and request payload assembly. Extract only when the helper name captures a stable domain rule and removes repeated complexity without hiding flow-specific behavior. +- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together. - Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance. +## Feature Workflow Layout + +- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI. +- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions. +- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region. +- Folders represent groups of related files with a shared owner and a stable reason to change together. +- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching. + ## Ownership - Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home. - Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing. - Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children. +- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state. +- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon. - Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow. +- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth. +- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need. - Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action. - Prefer uncontrolled DOM state and CSS variables before adding controlled props. +## Feature-Scoped Jotai State + +- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration. +- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports. +- Derived atom names read as business facts. Write atom names read as user or workflow commands. +- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms. +- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract. +- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow. +- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface. +- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient. + ## Components, Props, And Types - Type component signatures directly; do not use `FC` or `React.FC`. - Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs. - Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files. - Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer. -- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them. -- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary. -- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks. +- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them. +- Do not create type aliases that only rename another type. Use an alias only when it encodes a real UI concept, refinement, or reusable local contract. +- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary. +- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states. +- Do not extract fallback helpers whose only behavior is hiding missing display data. The component that renders the surface owns the empty, disabled, hidden, or placeholder state. + +## Generated API Contracts + +- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`. +- Do not hand-write local request/response/reply/page/cache-data types that mirror generated DTOs. Import or infer the generated type. +- Do not widen generated fields or enums for compatibility. Normalize legacy input at the boundary, then return the generated field type. +- Do not repair generated or API-returned contract fields in components unless the API contract or product requirement says they need normalization. Treat enums, statuses, and presence flags as exact contract values. +- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum. +- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful. +- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string. +- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors. +- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data. +- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers. + +## Nullable API Data + +- Prefer nullable-tolerant call boundaries. Pass API-returned types through for render-only rows, and let the component render fallback, disabled state, or nothing. +- Narrow only where a real value is required, such as mutation params, route hrefs, select values, or query input. Build that target model with `flatMap`, a local loop, or an early return so the required value is captured in the same branch. +- If design says a field is the display value, use that field. Only the final component should decide whether a nullable display value renders a placeholder, hides content, or disables an action. +- Do not wrap required arrays or fields in null-fallback helpers. Use empty collection fallbacks only for not-yet-loaded query data or genuinely nullable collections at the owning render boundary. +- Do not drop rows only to satisfy props or React keys; use a stable fallback key when possible. +- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by a narrowing filter. +- Avoid truthiness type guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after those filters. +- Use type guards only for meaningful domain or runtime validation, such as enum membership, object shape, or a reusable business invariant. ## Queries And Mutations @@ -39,7 +92,8 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`. - Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it. - Keep feature hooks for real orchestration, workflow state, or shared domain behavior. -- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid. +- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`. +- For generated oRPC `queryOptions()` / `infiniteOptions()`, do not pass `skipToken` as `input`; keep a valid placeholder input shape and use `enabled` to gate missing required params because the OpenAPI codec encodes input eagerly. - Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows. - Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules. - Do not use deprecated `useInvalid` or `useReset`. @@ -48,12 +102,13 @@ Use this as the decision guide for React/TypeScript component structure. Existin ## Component Boundaries - Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner. +- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner. - Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer. - Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary. - Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow. - Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment. - Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible. -- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. +- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface. ## You Might Not Need An Effect @@ -68,4 +123,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin ## Navigation And Performance - Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission. +- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched. +- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree. - Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason. diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index c0e079eeb2..20fd651b75 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -16,6 +16,7 @@ api = ExternalApi( inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/") from . import mail as _mail +from . import runtime_credentials as _runtime_credentials from .app import dsl as _app_dsl from .knowledge import retrieval as _knowledge_retrieval from .plugin import agent_drive as _agent_drive @@ -30,6 +31,7 @@ __all__ = [ "_knowledge_retrieval", "_mail", "_plugin", + "_runtime_credentials", "_workspace", "api", "bp", diff --git a/api/controllers/inner_api/runtime_credentials.py b/api/controllers/inner_api/runtime_credentials.py new file mode 100644 index 0000000000..bea65230d7 --- /dev/null +++ b/api/controllers/inner_api/runtime_credentials.py @@ -0,0 +1,205 @@ +"""Inner API endpoints for runtime credential resolution. + +Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint +returns decrypted model and tool credentials for in-memory runtime use only. +""" + +import json +import logging +from json import JSONDecodeError +from typing import Any + +from flask_restx import Resource +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from controllers.common.schema import register_schema_model +from controllers.console.wraps import setup_required +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import enterprise_inner_api_only +from core.helper import encrypter +from core.helper.provider_cache import ToolProviderCredentialsCache +from core.helper.provider_encryption import create_provider_encrypter +from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager +from core.tools.tool_manager import ToolManager +from extensions.ext_database import db +from models.provider import ProviderCredential +from models.tools import BuiltinToolProvider + +logger = logging.getLogger(__name__) + +_KIND_MODEL = "model" +_KIND_TOOL = "tool" + +# (body, status) pair returned by a resolver helper when resolution fails. +ResolveError = tuple[dict[str, str], int] + + +class InnerRuntimeCredentialResolveItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + credential_id: str = Field(description="Credential id") + provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai") + kind: str = Field(description="Credential kind, either 'model' or 'tool'") + + +class InnerRuntimeCredentialsResolvePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + tenant_id: str = Field(description="Workspace id") + credentials: list[InnerRuntimeCredentialResolveItem] = Field(default_factory=list) + + +register_schema_model(inner_api_ns, InnerRuntimeCredentialsResolvePayload) + + +@inner_api_ns.route("/enterprise/credentials/resolve") +class EnterpriseRuntimeCredentialsResolve(Resource): + @setup_required + @enterprise_inner_api_only + @inner_api_ns.doc( + "enterprise_runtime_credentials_resolve", + responses={ + 200: "Credentials resolved", + 400: "Invalid request or credential config", + 404: "Provider or credential not found", + }, + ) + @inner_api_ns.expect(inner_api_ns.models[InnerRuntimeCredentialsResolvePayload.__name__]) + def post(self): + args = InnerRuntimeCredentialsResolvePayload.model_validate(inner_api_ns.payload or {}) + if not args.credentials: + return {"credentials": []}, 200 + + # Model resolution shares one provider configuration set; build it lazily + # so a tool-only request never pays for the plugin daemon round trip. + model_configurations = None + + resolved: list[dict[str, Any]] = [] + for item in args.credentials: + if item.kind == _KIND_MODEL: + if model_configurations is None: + provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id) + model_configurations = provider_manager.get_configurations(args.tenant_id) + values, error = _resolve_model(args.tenant_id, model_configurations, item) + elif item.kind == _KIND_TOOL: + values, error = _resolve_tool(args.tenant_id, item) + else: + return {"message": f"unsupported credential kind '{item.kind}'"}, 400 + + if error is not None: + return error + resolved.append( + { + "credential_id": item.credential_id, + "kind": item.kind, + "provider": item.provider, + "values": values, + } + ) + + return {"credentials": resolved}, 200 + + +def _resolve_model( + tenant_id: str, provider_configurations: Any, item: InnerRuntimeCredentialResolveItem +) -> tuple[dict[str, Any] | None, ResolveError | None]: + provider_configuration = provider_configurations.get(item.provider) + if provider_configuration is None: + return None, ({"message": f"provider '{item.provider}' not found"}, 404) + + provider_schema = provider_configuration.provider.provider_credential_schema + secret_variables = provider_configuration.extract_secret_variables( + provider_schema.credential_form_schemas if provider_schema else [] + ) + + with Session(db.engine) as session: + stmt = select(ProviderCredential).where( + ProviderCredential.id == item.credential_id, + ProviderCredential.tenant_id == tenant_id, + ProviderCredential.provider_name.in_(provider_configuration._get_provider_names()), + ) + credential = session.execute(stmt).scalar_one_or_none() + + if credential is None or not credential.encrypted_config: + return None, ({"message": f"credential '{item.credential_id}' not found"}, 404) + + try: + values = json.loads(credential.encrypted_config) + except JSONDecodeError: + return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400) + if not isinstance(values, dict): + return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400) + + for key in secret_variables: + value = values.get(key) + if value is None: + continue + try: + values[key] = encrypter.decrypt_token(tenant_id=tenant_id, token=value) + except Exception as exc: + logger.warning( + "failed to resolve runtime model credential", + extra={ + "credential_id": item.credential_id, + "provider": item.provider, + "tenant_id": tenant_id, + "error": type(exc).__name__, + }, + ) + return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400) + + return values, None + + +def _resolve_tool( + tenant_id: str, item: InnerRuntimeCredentialResolveItem +) -> tuple[dict[str, Any] | None, ResolveError | None]: + try: + provider_controller = ToolManager.get_builtin_provider(item.provider, tenant_id) + except Exception as exc: + logger.warning( + "failed to load runtime tool provider", + extra={"provider": item.provider, "tenant_id": tenant_id, "error": type(exc).__name__}, + ) + return None, ({"message": f"tool provider '{item.provider}' not found"}, 404) + + with Session(db.engine) as session: + stmt = select(BuiltinToolProvider).where( + BuiltinToolProvider.id == item.credential_id, + BuiltinToolProvider.provider == item.provider, + BuiltinToolProvider.tenant_id == tenant_id, + ) + builtin_provider = session.execute(stmt).scalar_one_or_none() + + if builtin_provider is None: + return None, ({"message": f"credential '{item.credential_id}' not found"}, 404) + + try: + # Tool credentials are stored as a single encrypted dict; the secret + # fields are decided by the schema bound to this credential type. + provider_encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=[ + schema.to_basic_provider_config() + for schema in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type) + ], + cache=ToolProviderCredentialsCache( + tenant_id=tenant_id, provider=item.provider, credential_id=builtin_provider.id + ), + ) + values = dict(provider_encrypter.decrypt(builtin_provider.credentials)) + except Exception as exc: + logger.warning( + "failed to resolve runtime tool credential", + extra={ + "credential_id": item.credential_id, + "provider": item.provider, + "tenant_id": tenant_id, + "error": type(exc).__name__, + }, + ) + return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400) + + return values, None diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 3e21ed4fe0..b5dd329c80 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -18454,6 +18454,7 @@ Model class for provider system configuration response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | branding | [BrandingModel](#brandingmodel) | | Yes | +| enable_app_deploy | boolean | | Yes | | enable_change_email | boolean,
**Default:** true | | Yes | | enable_collaboration_mode | boolean,
**Default:** true | | Yes | | enable_creators_platform | boolean | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 302c2a55e4..ddbb6f51c7 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1597,6 +1597,7 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | branding | [BrandingModel](#brandingmodel) | | Yes | +| enable_app_deploy | boolean | | Yes | | enable_change_email | boolean,
**Default:** true | | Yes | | enable_collaboration_mode | boolean,
**Default:** true | | Yes | | enable_creators_platform | boolean | | Yes | diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 4d460c288a..10a15a0492 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -160,6 +160,7 @@ class PluginManagerModel(FeatureResponseModel): class SystemFeatureModel(FeatureResponseModel): + enable_app_deploy: bool = False sso_enforced_for_signin: bool = False sso_enforced_for_signin_protocol: str = "" enable_marketplace: bool = False @@ -419,6 +420,9 @@ class FeatureService: if "IsAllowCreateWorkspace" in enterprise_info: features.is_allow_create_workspace = enterprise_info["IsAllowCreateWorkspace"] + if "EnableAppDeploy" in enterprise_info: + features.enable_app_deploy = enterprise_info["EnableAppDeploy"] + if "Branding" in enterprise_info: features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "") features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "") diff --git a/api/tests/test_containers_integration_tests/services/test_feature_service.py b/api/tests/test_containers_integration_tests/services/test_feature_service.py index a678e37b41..4e5bfc6ac1 100644 --- a/api/tests/test_containers_integration_tests/services/test_feature_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feature_service.py @@ -61,6 +61,7 @@ class TestFeatureService: }, "WebAppAuth": {"allowSso": True, "allowEmailCodeLogin": True, "allowEmailPasswordLogin": False}, "SSOEnforcedForWebProtocol": "oidc", + "EnableAppDeploy": True, "License": { "status": "active", "expiredAt": "2025-12-31", @@ -291,6 +292,7 @@ class TestFeatureService: assert isinstance(result, SystemFeatureModel) # Verify enterprise features + assert result.enable_app_deploy is True assert result.branding.enabled is True assert result.webapp_auth.enabled is True assert result.enable_change_email is False @@ -377,6 +379,7 @@ class TestFeatureService: # Ensure that data required for frontend rendering remains accessible. # Branding should match the mock data + assert result.enable_app_deploy is True assert result.branding.enabled is True assert result.branding.application_title == "Test Enterprise" assert result.branding.login_page_logo == "https://example.com/logo.png" @@ -424,6 +427,7 @@ class TestFeatureService: assert isinstance(result, SystemFeatureModel) # Verify basic configuration + assert result.enable_app_deploy is False assert result.branding.enabled is False assert result.webapp_auth.enabled is False assert result.enable_change_email is True @@ -625,6 +629,7 @@ class TestFeatureService: assert isinstance(result, SystemFeatureModel) # Verify enterprise features are disabled + assert result.enable_app_deploy is False assert result.branding.enabled is False assert result.webapp_auth.enabled is False assert result.enable_change_email is True diff --git a/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py b/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py new file mode 100644 index 0000000000..87511a32b8 --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py @@ -0,0 +1,208 @@ +"""Unit tests for runtime credential inner API.""" + +import inspect +from unittest.mock import MagicMock, patch + +from flask import Flask + +from controllers.inner_api.runtime_credentials import ( + EnterpriseRuntimeCredentialsResolve, + InnerRuntimeCredentialsResolvePayload, +) + + +def test_runtime_credentials_payload_accepts_items(): + payload = InnerRuntimeCredentialsResolvePayload.model_validate( + { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/openai/openai", + "kind": "model", + } + ], + } + ) + + assert payload.tenant_id == "tenant-1" + assert payload.credentials[0].provider == "langgenius/openai/openai" + assert payload.credentials[0].kind == "model" + + +@patch("controllers.inner_api.runtime_credentials.encrypter.decrypt_token") +@patch("controllers.inner_api.runtime_credentials.db") +@patch("controllers.inner_api.runtime_credentials.Session") +@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager") +def test_runtime_model_credentials_resolve_returns_decrypted_values( + mock_provider_manager_factory, + mock_session_cls, + mock_db, + mock_decrypt_token, + app: Flask, +): + provider_configuration = MagicMock() + provider_configuration.provider.provider_credential_schema.credential_form_schemas = [] + provider_configuration.extract_secret_variables.return_value = ["openai_api_key"] + provider_configuration._get_provider_names.return_value = ["langgenius/openai/openai", "openai"] + + provider_configurations = MagicMock() + provider_configurations.get.return_value = provider_configuration + provider_manager = MagicMock() + provider_manager.get_configurations.return_value = provider_configurations + mock_provider_manager_factory.return_value = provider_manager + + credential = MagicMock() + credential.encrypted_config = '{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}' + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.scalar_one_or_none.return_value = credential + mock_session_cls.return_value = session + mock_db.engine = MagicMock() + mock_decrypt_token.return_value = "sk-test" + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/openai/openai", + "kind": "model", + } + ], + } + body, status_code = unwrapped(handler) + + assert status_code == 200 + assert body["credentials"][0]["kind"] == "model" + assert body["credentials"][0]["values"]["openai_api_key"] == "sk-test" + assert body["credentials"][0]["values"]["api_base"] == "https://api.openai.com/v1" + mock_decrypt_token.assert_called_once_with(tenant_id="tenant-1", token="encrypted") + + +@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager") +def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provider_manager_factory, app: Flask): + provider_configurations = MagicMock() + provider_configurations.get.return_value = None + provider_manager = MagicMock() + provider_manager.get_configurations.return_value = provider_configurations + mock_provider_manager_factory.return_value = provider_manager + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "credential-1", "provider": "missing", "kind": "model"}], + } + body, status_code = unwrapped(handler) + + assert status_code == 404 + assert "provider" in body["message"] + + +@patch("controllers.inner_api.runtime_credentials.create_provider_encrypter") +@patch("controllers.inner_api.runtime_credentials.ToolProviderCredentialsCache") +@patch("controllers.inner_api.runtime_credentials.db") +@patch("controllers.inner_api.runtime_credentials.Session") +@patch("controllers.inner_api.runtime_credentials.ToolManager") +def test_runtime_tool_credentials_resolve_returns_decrypted_values( + mock_tool_manager, + mock_session_cls, + mock_db, + mock_cache_cls, + mock_create_encrypter, + app: Flask, +): + provider_controller = MagicMock() + provider_controller.get_credentials_schema_by_type.return_value = [] + mock_tool_manager.get_builtin_provider.return_value = provider_controller + + builtin_provider = MagicMock() + builtin_provider.id = "credential-1" + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.scalar_one_or_none.return_value = builtin_provider + mock_session_cls.return_value = session + mock_db.engine = MagicMock() + + provider_encrypter = MagicMock() + provider_encrypter.decrypt.return_value = {"tavily_api_key": "tvly-secret"} + mock_create_encrypter.return_value = (provider_encrypter, MagicMock()) + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/tavily/tavily", + "kind": "tool", + } + ], + } + body, status_code = unwrapped(handler) + + assert status_code == 200 + assert body["credentials"][0]["kind"] == "tool" + assert body["credentials"][0]["provider"] == "langgenius/tavily/tavily" + assert body["credentials"][0]["values"]["tavily_api_key"] == "tvly-secret" + compiled = str(session.execute.call_args.args[0].compile(compile_kwargs={"literal_binds": True})) + assert "tool_builtin_providers.provider = 'langgenius/tavily/tavily'" in compiled + + +@patch("controllers.inner_api.runtime_credentials.db") +@patch("controllers.inner_api.runtime_credentials.Session") +@patch("controllers.inner_api.runtime_credentials.ToolManager") +def test_runtime_tool_credentials_resolve_rejects_unknown_credential( + mock_tool_manager, + mock_session_cls, + mock_db, + app: Flask, +): + mock_tool_manager.get_builtin_provider.return_value = MagicMock() + + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.scalar_one_or_none.return_value = None + mock_session_cls.return_value = session + mock_db.engine = MagicMock() + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "missing", "provider": "langgenius/tavily/tavily", "kind": "tool"}], + } + body, status_code = unwrapped(handler) + + assert status_code == 404 + assert "credential" in body["message"] + + +def test_runtime_credentials_resolve_rejects_unknown_kind(app: Flask): + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "credential-1", "provider": "x", "kind": "secret"}], + } + body, status_code = unwrapped(handler) + + assert status_code == 400 + assert "kind" in body["message"] diff --git a/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py new file mode 100644 index 0000000000..1c3b4fdbc1 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py @@ -0,0 +1,37 @@ +import pytest + +from services import feature_service as feature_service_module +from services.feature_service import FeatureService, SystemFeatureModel + + +@pytest.mark.parametrize( + ("enterprise_info", "initial", "expected"), + [ + # Enterprise reports the feature on -> mirrored through. + ({"EnableAppDeploy": True}, False, True), + # Enterprise may turn it off; the read runs after the hardcoded default + # and overrides it (forward-compat with a future entitlement gate). + ({"EnableAppDeploy": False}, True, False), + # Old enterprise without the key -> the existing value is left untouched. + ({}, True, True), + ], + ids=["enabled", "override_off", "missing_keeps_default"], +) +def test_fulfill_params_from_enterprise_enable_app_deploy( + monkeypatch: pytest.MonkeyPatch, + enterprise_info: dict, + initial: bool, + expected: bool, +): + monkeypatch.setattr( + feature_service_module.EnterpriseService, + "get_info", + staticmethod(lambda: enterprise_info), + ) + + features = SystemFeatureModel() + features.enable_app_deploy = initial + + FeatureService._fulfill_params_from_enterprise(features) + + assert features.enable_app_deploy is expected diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 87230e947e..8a75a82a9d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -487,14 +487,6 @@ "count": 1 } }, - "web/app/components/app/app-access-control/access-control-item.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/app/app-publisher/sections.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -7649,11 +7641,6 @@ "count": 1 } }, - "web/models/access-control.ts": { - "erasable-syntax-only/enums": { - "count": 2 - } - }, "web/models/app.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -8051,11 +8038,6 @@ "count": 17 } }, - "web/utils/clipboard.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/utils/completion-params.spec.ts": { "ts/no-explicit-any": { "count": 3 diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index 4f2ef2fa94..01c77ed076 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -6,6 +6,7 @@ export type ClientOptions = { export type SystemFeatureModel = { branding: BrandingModel + enable_app_deploy: boolean enable_change_email: boolean enable_collaboration_mode: boolean enable_creators_platform: boolean diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index 7464057a39..a7a8946ad5 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -98,6 +98,7 @@ export const zSystemFeatureModel = z.object({ login_page_logo: '', workspace_logo: '', }), + enable_app_deploy: z.boolean().default(false), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), enable_creators_platform: z.boolean().default(false), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 9d16cb9952..47eaed612c 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -540,6 +540,7 @@ export type SuggestedQuestionsResponse = { export type SystemFeatureModel = { branding: BrandingModel + enable_app_deploy: boolean enable_change_email: boolean enable_collaboration_mode: boolean enable_creators_platform: boolean diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index cb731344ab..aa96e4b323 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -824,6 +824,7 @@ export const zSystemFeatureModel = z.object({ login_page_logo: '', workspace_logo: '', }), + enable_app_deploy: z.boolean().default(false), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), enable_creators_platform: z.boolean().default(false), diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 6b9b76470a..61503a7f74 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -4,9 +4,97 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zAccessServiceCreateApiKeyBody, + zAccessServiceCreateApiKeyPath, + zAccessServiceCreateApiKeyResponse, + zAccessServiceDeleteApiKeyPath, + zAccessServiceDeleteApiKeyResponse, + zAccessServiceGetAccessChannelsPath, + zAccessServiceGetAccessChannelsResponse, + zAccessServiceGetAccessPolicyPath, + zAccessServiceGetAccessPolicyResponse, + zAccessServiceGetAccessSettingsPath, + zAccessServiceGetAccessSettingsResponse, + zAccessServiceGetDeveloperApiSettingsPath, + zAccessServiceGetDeveloperApiSettingsResponse, + zAccessServiceListApiKeysPath, + zAccessServiceListApiKeysResponse, + zAccessServiceUpdateAccessChannelsBody, + zAccessServiceUpdateAccessChannelsPath, + zAccessServiceUpdateAccessChannelsResponse, + zAccessServiceUpdateAccessPolicyBody, + zAccessServiceUpdateAccessPolicyPath, + zAccessServiceUpdateAccessPolicyResponse, + zAccessSubjectServiceListAccessSubjectsQuery, + zAccessSubjectServiceListAccessSubjectsResponse, + zAppInstanceServiceCreateAppInstanceBody, + zAppInstanceServiceCreateAppInstanceResponse, + zAppInstanceServiceDeleteAppInstancePath, + zAppInstanceServiceDeleteAppInstanceResponse, + zAppInstanceServiceGetAppInstanceOverviewPath, + zAppInstanceServiceGetAppInstanceOverviewResponse, + zAppInstanceServiceGetAppInstancePath, + zAppInstanceServiceGetAppInstanceResponse, + zAppInstanceServiceListAppInstancesQuery, + zAppInstanceServiceListAppInstancesResponse, + zAppInstanceServiceListAppInstanceSummariesQuery, + zAppInstanceServiceListAppInstanceSummariesResponse, + zAppInstanceServiceUpdateAppInstanceBody, + zAppInstanceServiceUpdateAppInstancePath, + zAppInstanceServiceUpdateAppInstanceResponse, zConsoleSsoOAuth2LoginResponse, zConsoleSsoOidcLoginResponse, zConsoleSsoSamlLoginResponse, + zDeploymentServiceCancelDeploymentBody, + zDeploymentServiceCancelDeploymentPath, + zDeploymentServiceCancelDeploymentResponse, + zDeploymentServiceDeployBody, + zDeploymentServiceDeployResponse, + zDeploymentServiceListDeploymentsPath, + zDeploymentServiceListDeploymentsQuery, + zDeploymentServiceListDeploymentsResponse, + zDeploymentServiceListEnvironmentDeploymentsPath, + zDeploymentServiceListEnvironmentDeploymentsResponse, + zDeploymentServiceListRollbackTargetsPath, + zDeploymentServiceListRollbackTargetsQuery, + zDeploymentServiceListRollbackTargetsResponse, + zDeploymentServicePromoteBody, + zDeploymentServicePromotePath, + zDeploymentServicePromoteResponse, + zDeploymentServiceRollbackBody, + zDeploymentServiceRollbackPath, + zDeploymentServiceRollbackResponse, + zDeploymentServiceUndeployBody, + zDeploymentServiceUndeployPath, + zDeploymentServiceUndeployResponse, + zEnvironmentServiceListEnvironmentsQuery, + zEnvironmentServiceListEnvironmentsResponse, + zReleaseServiceComputeDeploymentOptionsBody, + zReleaseServiceComputeDeploymentOptionsResponse, + zReleaseServiceComputeReleaseDeploymentViewPath, + zReleaseServiceComputeReleaseDeploymentViewQuery, + zReleaseServiceComputeReleaseDeploymentViewResponse, + zReleaseServiceCreateReleaseBody, + zReleaseServiceCreateReleaseResponse, + zReleaseServiceDeleteReleasePath, + zReleaseServiceDeleteReleaseResponse, + zReleaseServiceExportReleaseDslPath, + zReleaseServiceExportReleaseDslResponse, + zReleaseServiceGetReleasePath, + zReleaseServiceGetReleaseResponse, + zReleaseServiceListReleaseCredentialCandidatesPath, + zReleaseServiceListReleaseCredentialCandidatesResponse, + zReleaseServiceListReleasesPath, + zReleaseServiceListReleasesQuery, + zReleaseServiceListReleasesResponse, + zReleaseServiceListReleaseSummariesPath, + zReleaseServiceListReleaseSummariesQuery, + zReleaseServiceListReleaseSummariesResponse, + zReleaseServicePrecheckReleaseBody, + zReleaseServicePrecheckReleaseResponse, + zReleaseServiceUpdateReleaseBody, + zReleaseServiceUpdateReleasePath, + zReleaseServiceUpdateReleaseResponse, zWebAppAuthGetGroupSubjectsQuery, zWebAppAuthGetGroupSubjectsResponse, zWebAppAuthGetWebAppAccessModeQuery, @@ -21,6 +109,524 @@ import { zWebAppAuthUpdateWebAppWhitelistSubjectsResponse, } from './zod.gen' +export const listAccessSubjects = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessSubjectService_ListAccessSubjects', + path: '/enterprise/access-subjects', + tags: ['AccessSubjectService'], + }) + .input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() })) + .output(zAccessSubjectServiceListAccessSubjectsResponse) + +export const accessSubjectService = { + listAccessSubjects, +} + +export const listAppInstanceSummaries = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_ListAppInstanceSummaries', + path: '/enterprise/app-deploy/appInstanceSummaries', + tags: ['AppInstanceService'], + }) + .input(z.object({ query: zAppInstanceServiceListAppInstanceSummariesQuery.optional() })) + .output(zAppInstanceServiceListAppInstanceSummariesResponse) + +export const listAppInstances = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_ListAppInstances', + path: '/enterprise/app-deploy/appInstances', + tags: ['AppInstanceService'], + }) + .input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() })) + .output(zAppInstanceServiceListAppInstancesResponse) + +export const createAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'AppInstanceService_CreateAppInstance', + path: '/enterprise/app-deploy/appInstances', + tags: ['AppInstanceService'], + }) + .input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody })) + .output(zAppInstanceServiceCreateAppInstanceResponse) + +export const deleteAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'AppInstanceService_DeleteAppInstance', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}', + tags: ['AppInstanceService'], + }) + .input(z.object({ params: zAppInstanceServiceDeleteAppInstancePath })) + .output(zAppInstanceServiceDeleteAppInstanceResponse) + +export const getAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_GetAppInstance', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}', + tags: ['AppInstanceService'], + }) + .input(z.object({ params: zAppInstanceServiceGetAppInstancePath })) + .output(zAppInstanceServiceGetAppInstanceResponse) + +export const updateAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'AppInstanceService_UpdateAppInstance', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}', + tags: ['AppInstanceService'], + }) + .input( + z.object({ + body: zAppInstanceServiceUpdateAppInstanceBody, + params: zAppInstanceServiceUpdateAppInstancePath, + }), + ) + .output(zAppInstanceServiceUpdateAppInstanceResponse) + +export const getAppInstanceOverview = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_GetAppInstanceOverview', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}:getOverview', + tags: ['AppInstanceService'], + }) + .input(z.object({ params: zAppInstanceServiceGetAppInstanceOverviewPath })) + .output(zAppInstanceServiceGetAppInstanceOverviewResponse) + +export const appInstanceService = { + listAppInstanceSummaries, + listAppInstances, + createAppInstance, + deleteAppInstance, + getAppInstance, + updateAppInstance, + getAppInstanceOverview, +} + +export const getAccessChannels = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetAccessChannels', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetAccessChannelsPath })) + .output(zAccessServiceGetAccessChannelsResponse) + +export const updateAccessChannels = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'AccessService_UpdateAccessChannels', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels', + tags: ['AccessService'], + }) + .input( + z.object({ + body: zAccessServiceUpdateAccessChannelsBody, + params: zAccessServiceUpdateAccessChannelsPath, + }), + ) + .output(zAccessServiceUpdateAccessChannelsResponse) + +export const getAccessSettings = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetAccessSettings', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessSettings', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetAccessSettingsPath })) + .output(zAccessServiceGetAccessSettingsResponse) + +export const getDeveloperApiSettings = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetDeveloperApiSettings', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/developerApiSettings', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetDeveloperApiSettingsPath })) + .output(zAccessServiceGetDeveloperApiSettingsResponse) + +export const getAccessPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetAccessPolicy', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetAccessPolicyPath })) + .output(zAccessServiceGetAccessPolicyResponse) + +export const updateAccessPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'AccessService_UpdateAccessPolicy', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy', + tags: ['AccessService'], + }) + .input( + z.object({ + body: zAccessServiceUpdateAccessPolicyBody, + params: zAccessServiceUpdateAccessPolicyPath, + }), + ) + .output(zAccessServiceUpdateAccessPolicyResponse) + +export const listApiKeys = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_ListApiKeys', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceListApiKeysPath })) + .output(zAccessServiceListApiKeysResponse) + +export const createApiKey = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'AccessService_CreateApiKey', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys', + tags: ['AccessService'], + }) + .input(z.object({ body: zAccessServiceCreateApiKeyBody, params: zAccessServiceCreateApiKeyPath })) + .output(zAccessServiceCreateApiKeyResponse) + +export const deleteApiKey = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'AccessService_DeleteApiKey', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys/{apiKeyId}', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceDeleteApiKeyPath })) + .output(zAccessServiceDeleteApiKeyResponse) + +export const accessService = { + getAccessChannels, + updateAccessChannels, + getAccessSettings, + getDeveloperApiSettings, + getAccessPolicy, + updateAccessPolicy, + listApiKeys, + createApiKey, + deleteApiKey, +} + +export const listDeployments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'DeploymentService_ListDeployments', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/deployments', + tags: ['DeploymentService'], + }) + .input( + z.object({ + params: zDeploymentServiceListDeploymentsPath, + query: zDeploymentServiceListDeploymentsQuery.optional(), + }), + ) + .output(zDeploymentServiceListDeploymentsResponse) + +export const listEnvironmentDeployments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'DeploymentService_ListEnvironmentDeployments', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environmentDeployments', + tags: ['DeploymentService'], + }) + .input(z.object({ params: zDeploymentServiceListEnvironmentDeploymentsPath })) + .output(zDeploymentServiceListEnvironmentDeploymentsResponse) + +export const listRollbackTargets = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'DeploymentService_ListRollbackTargets', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/rollbackTargets', + tags: ['DeploymentService'], + }) + .input( + z.object({ + params: zDeploymentServiceListRollbackTargetsPath, + query: zDeploymentServiceListRollbackTargetsQuery.optional(), + }), + ) + .output(zDeploymentServiceListRollbackTargetsResponse) + +/** + * CancelDeployment cancels the in-flight deployment on the environment. + */ +export const cancelDeployment = oc + .route({ + description: 'CancelDeployment cancels the in-flight deployment on the environment.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_CancelDeployment', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:cancelDeployment', + tags: ['DeploymentService'], + }) + .input( + z.object({ + body: zDeploymentServiceCancelDeploymentBody, + params: zDeploymentServiceCancelDeploymentPath, + }), + ) + .output(zDeploymentServiceCancelDeploymentResponse) + +export const promote = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Promote', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:promote', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServicePromoteBody, params: zDeploymentServicePromotePath })) + .output(zDeploymentServicePromoteResponse) + +export const rollback = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Rollback', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:rollback', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServiceRollbackBody, params: zDeploymentServiceRollbackPath })) + .output(zDeploymentServiceRollbackResponse) + +export const undeploy = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Undeploy', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:undeploy', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServiceUndeployBody, params: zDeploymentServiceUndeployPath })) + .output(zDeploymentServiceUndeployResponse) + +export const deploy = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Deploy', + path: '/enterprise/app-deploy/appInstances:deploy', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServiceDeployBody })) + .output(zDeploymentServiceDeployResponse) + +export const deploymentService = { + listDeployments, + listEnvironmentDeployments, + listRollbackTargets, + cancelDeployment, + promote, + rollback, + undeploy, + deploy, +} + +export const listReleaseSummaries = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ListReleaseSummaries', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/releaseSummaries', + tags: ['ReleaseService'], + }) + .input( + z.object({ + params: zReleaseServiceListReleaseSummariesPath, + query: zReleaseServiceListReleaseSummariesQuery.optional(), + }), + ) + .output(zReleaseServiceListReleaseSummariesResponse) + +export const listReleases = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ListReleases', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/releases', + tags: ['ReleaseService'], + }) + .input( + z.object({ + params: zReleaseServiceListReleasesPath, + query: zReleaseServiceListReleasesQuery.optional(), + }), + ) + .output(zReleaseServiceListReleasesResponse) + +export const computeReleaseDeploymentView = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ComputeReleaseDeploymentView', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}:computeReleaseDeploymentView', + tags: ['ReleaseService'], + }) + .input( + z.object({ + params: zReleaseServiceComputeReleaseDeploymentViewPath, + query: zReleaseServiceComputeReleaseDeploymentViewQuery.optional(), + }), + ) + .output(zReleaseServiceComputeReleaseDeploymentViewResponse) + +export const createRelease = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ReleaseService_CreateRelease', + path: '/enterprise/app-deploy/releases', + tags: ['ReleaseService'], + }) + .input(z.object({ body: zReleaseServiceCreateReleaseBody })) + .output(zReleaseServiceCreateReleaseResponse) + +export const deleteRelease = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'ReleaseService_DeleteRelease', + path: '/enterprise/app-deploy/releases/{releaseId}', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceDeleteReleasePath })) + .output(zReleaseServiceDeleteReleaseResponse) + +export const getRelease = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_GetRelease', + path: '/enterprise/app-deploy/releases/{releaseId}', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceGetReleasePath })) + .output(zReleaseServiceGetReleaseResponse) + +export const updateRelease = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'ReleaseService_UpdateRelease', + path: '/enterprise/app-deploy/releases/{releaseId}', + tags: ['ReleaseService'], + }) + .input( + z.object({ body: zReleaseServiceUpdateReleaseBody, params: zReleaseServiceUpdateReleasePath }), + ) + .output(zReleaseServiceUpdateReleaseResponse) + +export const exportReleaseDsl = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ExportReleaseDSL', + path: '/enterprise/app-deploy/releases/{releaseId}:exportDsl', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceExportReleaseDslPath })) + .output(zReleaseServiceExportReleaseDslResponse) + +export const listReleaseCredentialCandidates = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ListReleaseCredentialCandidates', + path: '/enterprise/app-deploy/releases/{releaseId}:listCredentialCandidates', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceListReleaseCredentialCandidatesPath })) + .output(zReleaseServiceListReleaseCredentialCandidatesResponse) + +export const computeDeploymentOptions = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ReleaseService_ComputeDeploymentOptions', + path: '/enterprise/app-deploy/releases:computeDeploymentOptions', + tags: ['ReleaseService'], + }) + .input(z.object({ body: zReleaseServiceComputeDeploymentOptionsBody })) + .output(zReleaseServiceComputeDeploymentOptionsResponse) + +export const precheckRelease = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ReleaseService_PrecheckRelease', + path: '/enterprise/app-deploy/releases:precheck', + tags: ['ReleaseService'], + }) + .input(z.object({ body: zReleaseServicePrecheckReleaseBody })) + .output(zReleaseServicePrecheckReleaseResponse) + +export const releaseService = { + listReleaseSummaries, + listReleases, + computeReleaseDeploymentView, + createRelease, + deleteRelease, + getRelease, + updateRelease, + exportReleaseDsl, + listReleaseCredentialCandidates, + computeDeploymentOptions, + precheckRelease, +} + +/** + * ListEnvironments returns only the environments the current user can + * deploy to. + */ +export const listEnvironments = oc + .route({ + description: 'ListEnvironments returns only the environments the current user can\n deploy to.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'EnvironmentService_ListEnvironments', + path: '/enterprise/app-deploy/environments', + tags: ['EnvironmentService'], + }) + .input(z.object({ query: zEnvironmentServiceListEnvironmentsQuery.optional() })) + .output(zEnvironmentServiceListEnvironmentsResponse) + +export const environmentService = { + listEnvironments, +} + export const oAuth2Login = oc .route({ inputStructure: 'detailed', @@ -133,6 +739,12 @@ export const webAppAuth = { } export const contract = { + accessSubjectService, + appInstanceService, + accessService, + deploymentService, + releaseService, + environmentService, consoleSso, webAppAuth, } diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index b747c4baa8..0c990967a3 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -4,6 +4,1051 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } +export const AccessMode = { + ACCESS_MODE_UNSPECIFIED: 'ACCESS_MODE_UNSPECIFIED', + ACCESS_MODE_PUBLIC: 'ACCESS_MODE_PUBLIC', + ACCESS_MODE_PRIVATE: 'ACCESS_MODE_PRIVATE', + ACCESS_MODE_PRIVATE_ALL: 'ACCESS_MODE_PRIVATE_ALL', +} as const + +export type AccessMode = (typeof AccessMode)[keyof typeof AccessMode] + +export const SubjectType = { + SUBJECT_TYPE_UNSPECIFIED: 'SUBJECT_TYPE_UNSPECIFIED', + SUBJECT_TYPE_ACCOUNT: 'SUBJECT_TYPE_ACCOUNT', + SUBJECT_TYPE_GROUP: 'SUBJECT_TYPE_GROUP', +} as const + +export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType] + +export const AppRunnerLogStatus = { + APP_RUNNER_LOG_STATUS_UNSPECIFIED: 'APP_RUNNER_LOG_STATUS_UNSPECIFIED', + APP_RUNNER_LOG_STATUS_RUNNING: 'APP_RUNNER_LOG_STATUS_RUNNING', + APP_RUNNER_LOG_STATUS_SUCCEEDED: 'APP_RUNNER_LOG_STATUS_SUCCEEDED', + APP_RUNNER_LOG_STATUS_FAILED: 'APP_RUNNER_LOG_STATUS_FAILED', + APP_RUNNER_LOG_STATUS_PARTIAL_SUCCEEDED: 'APP_RUNNER_LOG_STATUS_PARTIAL_SUCCEEDED', +} as const + +export type AppRunnerLogStatus = (typeof AppRunnerLogStatus)[keyof typeof AppRunnerLogStatus] + +export const AssignmentOperation = { + ASSIGNMENT_OPERATION_UNSPECIFIED: 'ASSIGNMENT_OPERATION_UNSPECIFIED', + ASSIGNMENT_OPERATION_LOAD: 'ASSIGNMENT_OPERATION_LOAD', + ASSIGNMENT_OPERATION_UNLOAD: 'ASSIGNMENT_OPERATION_UNLOAD', +} as const + +export type AssignmentOperation = (typeof AssignmentOperation)[keyof typeof AssignmentOperation] + +export const EnvironmentMode = { + ENVIRONMENT_MODE_UNSPECIFIED: 'ENVIRONMENT_MODE_UNSPECIFIED', + ENVIRONMENT_MODE_SHARED: 'ENVIRONMENT_MODE_SHARED', + ENVIRONMENT_MODE_ISOLATED: 'ENVIRONMENT_MODE_ISOLATED', +} as const + +export type EnvironmentMode = (typeof EnvironmentMode)[keyof typeof EnvironmentMode] + +export const RuntimeBackend = { + RUNTIME_BACKEND_UNSPECIFIED: 'RUNTIME_BACKEND_UNSPECIFIED', + RUNTIME_BACKEND_K8S: 'RUNTIME_BACKEND_K8S', + RUNTIME_BACKEND_EXTERNAL: 'RUNTIME_BACKEND_EXTERNAL', +} as const + +export type RuntimeBackend = (typeof RuntimeBackend)[keyof typeof RuntimeBackend] + +export const PluginCategory = { + PLUGIN_CATEGORY_UNSPECIFIED: 'PLUGIN_CATEGORY_UNSPECIFIED', + PLUGIN_CATEGORY_MODEL: 'PLUGIN_CATEGORY_MODEL', + PLUGIN_CATEGORY_TOOL: 'PLUGIN_CATEGORY_TOOL', +} as const + +export type PluginCategory = (typeof PluginCategory)[keyof typeof PluginCategory] + +export const DeploymentStatus = { + DEPLOYMENT_STATUS_UNSPECIFIED: 'DEPLOYMENT_STATUS_UNSPECIFIED', + DEPLOYMENT_STATUS_DEPLOYING: 'DEPLOYMENT_STATUS_DEPLOYING', + DEPLOYMENT_STATUS_READY: 'DEPLOYMENT_STATUS_READY', + DEPLOYMENT_STATUS_FAILED: 'DEPLOYMENT_STATUS_FAILED', + DEPLOYMENT_STATUS_CANCELLED: 'DEPLOYMENT_STATUS_CANCELLED', +} as const + +export type DeploymentStatus = (typeof DeploymentStatus)[keyof typeof DeploymentStatus] + +export const DeploymentAction = { + DEPLOYMENT_ACTION_UNSPECIFIED: 'DEPLOYMENT_ACTION_UNSPECIFIED', + DEPLOYMENT_ACTION_DEPLOY: 'DEPLOYMENT_ACTION_DEPLOY', + DEPLOYMENT_ACTION_PROMOTE: 'DEPLOYMENT_ACTION_PROMOTE', + DEPLOYMENT_ACTION_ROLLBACK: 'DEPLOYMENT_ACTION_ROLLBACK', + DEPLOYMENT_ACTION_UNDEPLOY: 'DEPLOYMENT_ACTION_UNDEPLOY', +} as const + +export type DeploymentAction = (typeof DeploymentAction)[keyof typeof DeploymentAction] + +export const DeveloperApiUrlStatus = { + DEVELOPER_API_URL_STATUS_UNSPECIFIED: 'DEVELOPER_API_URL_STATUS_UNSPECIFIED', + DEVELOPER_API_URL_STATUS_CONFIGURED: 'DEVELOPER_API_URL_STATUS_CONFIGURED', + DEVELOPER_API_URL_STATUS_NOT_CONFIGURED: 'DEVELOPER_API_URL_STATUS_NOT_CONFIGURED', +} as const + +export type DeveloperApiUrlStatus + = (typeof DeveloperApiUrlStatus)[keyof typeof DeveloperApiUrlStatus] + +export const EnvVarValueSource = { + ENV_VAR_VALUE_SOURCE_UNSPECIFIED: 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', + ENV_VAR_VALUE_SOURCE_LITERAL: 'ENV_VAR_VALUE_SOURCE_LITERAL', + ENV_VAR_VALUE_SOURCE_DSL_DEFAULT: 'ENV_VAR_VALUE_SOURCE_DSL_DEFAULT', + ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT: 'ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT', +} as const + +export type EnvVarValueSource = (typeof EnvVarValueSource)[keyof typeof EnvVarValueSource] + +export const EnvVarValueType = { + ENV_VAR_VALUE_TYPE_UNSPECIFIED: 'ENV_VAR_VALUE_TYPE_UNSPECIFIED', + ENV_VAR_VALUE_TYPE_STRING: 'ENV_VAR_VALUE_TYPE_STRING', + ENV_VAR_VALUE_TYPE_NUMBER: 'ENV_VAR_VALUE_TYPE_NUMBER', + ENV_VAR_VALUE_TYPE_SECRET: 'ENV_VAR_VALUE_TYPE_SECRET', +} as const + +export type EnvVarValueType = (typeof EnvVarValueType)[keyof typeof EnvVarValueType] + +export const EnvironmentStatus = { + ENVIRONMENT_STATUS_UNSPECIFIED: 'ENVIRONMENT_STATUS_UNSPECIFIED', + ENVIRONMENT_STATUS_ADMISSION: 'ENVIRONMENT_STATUS_ADMISSION', + ENVIRONMENT_STATUS_BOOTSTRAPPING: 'ENVIRONMENT_STATUS_BOOTSTRAPPING', + ENVIRONMENT_STATUS_READY: 'ENVIRONMENT_STATUS_READY', + ENVIRONMENT_STATUS_FAILED: 'ENVIRONMENT_STATUS_FAILED', + ENVIRONMENT_STATUS_DELETING: 'ENVIRONMENT_STATUS_DELETING', +} as const + +export type EnvironmentStatus = (typeof EnvironmentStatus)[keyof typeof EnvironmentStatus] + +export const RuntimeInstanceStatus = { + RUNTIME_INSTANCE_STATUS_UNSPECIFIED: 'RUNTIME_INSTANCE_STATUS_UNSPECIFIED', + RUNTIME_INSTANCE_STATUS_UNDEPLOYED: 'RUNTIME_INSTANCE_STATUS_UNDEPLOYED', + RUNTIME_INSTANCE_STATUS_DEPLOYING: 'RUNTIME_INSTANCE_STATUS_DEPLOYING', + RUNTIME_INSTANCE_STATUS_READY: 'RUNTIME_INSTANCE_STATUS_READY', + RUNTIME_INSTANCE_STATUS_FAILED: 'RUNTIME_INSTANCE_STATUS_FAILED', + RUNTIME_INSTANCE_STATUS_DRIFTED: 'RUNTIME_INSTANCE_STATUS_DRIFTED', + RUNTIME_INSTANCE_STATUS_INVALID: 'RUNTIME_INSTANCE_STATUS_INVALID', + RUNTIME_INSTANCE_STATUS_UNDEPLOYING: 'RUNTIME_INSTANCE_STATUS_UNDEPLOYING', +} as const + +export type RuntimeInstanceStatus + = (typeof RuntimeInstanceStatus)[keyof typeof RuntimeInstanceStatus] + +export const AppRunnerLaunchProfileMode = { + APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED: 'APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED', + APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG: 'APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG', +} as const + +export type AppRunnerLaunchProfileMode + = (typeof AppRunnerLaunchProfileMode)[keyof typeof AppRunnerLaunchProfileMode] + +export const OperatorType = { + OPERATOR_TYPE_UNSPECIFIED: 'OPERATOR_TYPE_UNSPECIFIED', + OPERATOR_TYPE_END_USER: 'OPERATOR_TYPE_END_USER', + OPERATOR_TYPE_ACCOUNT: 'OPERATOR_TYPE_ACCOUNT', + OPERATOR_TYPE_SERVICE_ACCOUNT: 'OPERATOR_TYPE_SERVICE_ACCOUNT', + OPERATOR_TYPE_SYSTEM: 'OPERATOR_TYPE_SYSTEM', +} as const + +export type OperatorType = (typeof OperatorType)[keyof typeof OperatorType] + +export const ReleaseSource = { + RELEASE_SOURCE_UNSPECIFIED: 'RELEASE_SOURCE_UNSPECIFIED', + RELEASE_SOURCE_SOURCE_APP: 'RELEASE_SOURCE_SOURCE_APP', + RELEASE_SOURCE_UPLOAD: 'RELEASE_SOURCE_UPLOAD', +} as const + +export type ReleaseSource = (typeof ReleaseSource)[keyof typeof ReleaseSource] + +export const ReleaseEnvironmentActionKind = { + RELEASE_ENVIRONMENT_ACTION_KIND_UNSPECIFIED: 'RELEASE_ENVIRONMENT_ACTION_KIND_UNSPECIFIED', + RELEASE_ENVIRONMENT_ACTION_KIND_PROMOTE: 'RELEASE_ENVIRONMENT_ACTION_KIND_PROMOTE', + RELEASE_ENVIRONMENT_ACTION_KIND_ROLLBACK: 'RELEASE_ENVIRONMENT_ACTION_KIND_ROLLBACK', + RELEASE_ENVIRONMENT_ACTION_KIND_CURRENT: 'RELEASE_ENVIRONMENT_ACTION_KIND_CURRENT', + RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING: 'RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING', + RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED: 'RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED', +} as const + +export type ReleaseEnvironmentActionKind + = (typeof ReleaseEnvironmentActionKind)[keyof typeof ReleaseEnvironmentActionKind] + +export const AckStatus = { + ACK_STATUS_UNSPECIFIED: 'ACK_STATUS_UNSPECIFIED', + ACK_STATUS_READY: 'ACK_STATUS_READY', + ACK_STATUS_FAILED: 'ACK_STATUS_FAILED', +} as const + +export type AckStatus = (typeof AckStatus)[keyof typeof AckStatus] + +export const SlotType = { + SLOT_TYPE_UNSPECIFIED: 'SLOT_TYPE_UNSPECIFIED', + SLOT_TYPE_PLUGIN_CREDENTIAL: 'SLOT_TYPE_PLUGIN_CREDENTIAL', + SLOT_TYPE_ENV_VAR: 'SLOT_TYPE_ENV_VAR', +} as const + +export type SlotType = (typeof SlotType)[keyof typeof SlotType] + +export const RouteTargetKind = { + ROUTE_TARGET_KIND_UNSPECIFIED: 'ROUTE_TARGET_KIND_UNSPECIFIED', + ROUTE_TARGET_KIND_K8S_SERVICE: 'ROUTE_TARGET_KIND_K8S_SERVICE', + ROUTE_TARGET_KIND_DIRECT_UPSTREAM: 'ROUTE_TARGET_KIND_DIRECT_UPSTREAM', +} as const + +export type RouteTargetKind = (typeof RouteTargetKind)[keyof typeof RouteTargetKind] + +export const PasswordChangeReason = { + PASSWORD_CHANGE_REASON_UNSPECIFIED: 'PASSWORD_CHANGE_REASON_UNSPECIFIED', + PASSWORD_CHANGE_REASON_TEMP: 'PASSWORD_CHANGE_REASON_TEMP', + PASSWORD_CHANGE_REASON_EXPIRED: 'PASSWORD_CHANGE_REASON_EXPIRED', + PASSWORD_CHANGE_REASON_POLICY: 'PASSWORD_CHANGE_REASON_POLICY', +} as const + +export type PasswordChangeReason = (typeof PasswordChangeReason)[keyof typeof PasswordChangeReason] + +export const OtelEndpointMode = { + OTEL_ENDPOINT_MODE_UNIFIED: 'OTEL_ENDPOINT_MODE_UNIFIED', + OTEL_ENDPOINT_MODE_DEDICATED: 'OTEL_ENDPOINT_MODE_DEDICATED', +} as const + +export type OtelEndpointMode = (typeof OtelEndpointMode)[keyof typeof OtelEndpointMode] + +export const AppStatus = { + APP_STATUS_UNSPECIFIED: 'APP_STATUS_UNSPECIFIED', + APP_STATUS_PUBLISHED: 'APP_STATUS_PUBLISHED', + APP_STATUS_UNPUBLISHED: 'APP_STATUS_UNPUBLISHED', + APP_STATUS_DELETED: 'APP_STATUS_DELETED', +} as const + +export type AppStatus = (typeof AppStatus)[keyof typeof AppStatus] + +export const LimitType = { + LIMIT_TYPE_UNSPECIFIED: 'LIMIT_TYPE_UNSPECIFIED', + LIMIT_TYPE_RPM: 'LIMIT_TYPE_RPM', + LIMIT_TYPE_CONCURRENCY: 'LIMIT_TYPE_CONCURRENCY', + LIMIT_TYPE_TOKEN: 'LIMIT_TYPE_TOKEN', +} as const + +export type LimitType = (typeof LimitType)[keyof typeof LimitType] + +export const LimitAction = { + LIMIT_ACTION_UNSPECIFIED: 'LIMIT_ACTION_UNSPECIFIED', + LIMIT_ACTION_BLOCK: 'LIMIT_ACTION_BLOCK', + LIMIT_ACTION_TRACK: 'LIMIT_ACTION_TRACK', +} as const + +export type LimitAction = (typeof LimitAction)[keyof typeof LimitAction] + +export const PasswordStrengthLevel = { + PASSWORD_STRENGTH_LEVEL_UNSPECIFIED: 'PASSWORD_STRENGTH_LEVEL_UNSPECIFIED', + PASSWORD_STRENGTH_LEVEL_WEAK: 'PASSWORD_STRENGTH_LEVEL_WEAK', + PASSWORD_STRENGTH_LEVEL_MEDIUM: 'PASSWORD_STRENGTH_LEVEL_MEDIUM', + PASSWORD_STRENGTH_LEVEL_STRONG: 'PASSWORD_STRENGTH_LEVEL_STRONG', +} as const + +export type PasswordStrengthLevel + = (typeof PasswordStrengthLevel)[keyof typeof PasswordStrengthLevel] + +export const PluginInstallationScope = { + PLUGIN_INSTALLATION_SCOPE_ALL: 'PLUGIN_INSTALLATION_SCOPE_ALL', + PLUGIN_INSTALLATION_SCOPE_OFFICIAL_ONLY: 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_ONLY', + PLUGIN_INSTALLATION_SCOPE_OFFICIAL_AND_SPECIFIC_PARTNERS: + 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_AND_SPECIFIC_PARTNERS', + PLUGIN_INSTALLATION_SCOPE_NONE: 'PLUGIN_INSTALLATION_SCOPE_NONE', +} as const + +export type PluginInstallationScope + = (typeof PluginInstallationScope)[keyof typeof PluginInstallationScope] + +export const LimitStatus = { + LIMIT_STATUS_UNSPECIFIED: 'LIMIT_STATUS_UNSPECIFIED', + LIMIT_STATUS_NA: 'LIMIT_STATUS_NA', + LIMIT_STATUS_NORMAL: 'LIMIT_STATUS_NORMAL', + LIMIT_STATUS_THROTTLED: 'LIMIT_STATUS_THROTTLED', +} as const + +export type LimitStatus = (typeof LimitStatus)[keyof typeof LimitStatus] + +export type AccessChannels = { + id: string + appInstanceId: string + webAppEnabled: boolean + developerApiEnabled: boolean + updatedBy: Actor + createdAt: string + updatedAt: string +} + +export type AccessEndpoint = { + environment?: Environment + endpointUrl: string +} + +export type AccessPolicy = { + id: string + appInstanceId: string + environmentId: string + mode: AccessMode + subjects: Array + createdAt: string + updatedAt: string +} + +export type AccessSubject = { + subjectType: SubjectType + subjectId: string +} + +export type Actor = { + id: string + displayName: string +} + +export type ApiKey = { + id: string + appInstanceId: string + environmentId: string + displayName: string + maskedToken: string + createdBy: Actor + createdAt: string + lastUsedAt?: string +} + +export type ApiKeySummary = { + apiKeyCount: number + environmentCount: number + developerApiEnabled: boolean + developerApiUrl: DeveloperApiUrl +} + +export type AppInstance = { + id: string + tenantId: string + displayName: string + description: string + createdBy: Actor + updatedBy: Actor + createdAt: string + updatedAt: string +} + +export type AppInstanceSummary = { + appInstance: AppInstance + environmentDeployments: Array + latestRelease?: Release + accessChannels: AccessChannels + apiKeySummary: ApiKeySummary +} + +export type AppRunnerLog = { + id: string + timestamp: string + workflowRunId: string + status: AppRunnerLogStatus + durationSeconds: number + totalTokens: string + workspace: NamedRef + environment: NamedRef + appInstance: NamedRef + operator: Operator + invokeFrom: string + traceId: string + difyTraceId: string + gateCommitId: string + body?: string + attributesJson?: string + resourceAttributesJson?: string +} + +export type BatchResolveRuntimeArtifactsRequest = { + requests?: Array +} + +export type BatchResolveRuntimeArtifactsResponse = { + results?: Array +} + +export type BootstrapAssignment = { + appId?: string + environmentId?: string + workflowId?: string + runtimeInstanceId?: string + workspaceId?: string + runtimeInstanceVersion?: string + bindingSnapshotVersion?: string + executionTokenVersion?: string + executionToken?: string + releaseId?: string + operation?: AssignmentOperation + deploymentId?: string + requiresStatusReport?: boolean +} + +export type BootstrapRunnerRequest = { + runner?: RunnerInfo +} + +export type BootstrapRunnerResponse = { + runnerId?: string + assignmentRevision?: string + assignments?: Array +} + +export type CancelDeploymentRequest = { + appInstanceId?: string + environmentId?: string +} + +export type CancelDeploymentResponse = { + deployment: Deployment +} + +export type ComputeDeploymentOptionsRequest = { + environmentId?: string + appInstanceId?: string + dsl?: string + sourceAppId?: string + releaseId?: string +} + +export type ComputeDeploymentOptionsResponse = { + options: DeploymentOptions +} + +export type ComputeReleaseDeploymentViewResponse = { + releases: Array + environmentDeployments: Array + environmentActions: Array + options?: DeploymentOptions +} + +export type CreateApiKeyRequest = { + appInstanceId?: string + environmentId?: string + displayName: string +} + +export type CreateApiKeyResponse = { + apiKey: ApiKey + token: string +} + +export type CreateAppInstanceRequest = { + displayName: string + description?: string +} + +export type CreateAppInstanceResponse = { + appInstance: AppInstance +} + +export type CreateEnvironmentRequest = { + displayName: string + description?: string + mode?: EnvironmentMode + backend?: RuntimeBackend + k8s?: K8sEnvironmentConfig + external?: ExternalAppRunnerConfig + cpuCount?: number + idempotencyKey: string +} + +export type CreateEnvironmentResponse = { + environment?: Environment +} + +export type CreateReleaseRequest = { + createAppInstance?: boolean + appInstanceId?: string + displayName?: string + description?: string + dsl?: string + sourceAppId?: string +} + +export type CreateReleaseResponse = { + release: Release + appInstance: AppInstance +} + +export type CredentialCandidate = { + credentialId: string + providerId: string + category: PluginCategory + displayName: string + fromEnterprise: boolean +} + +export type CredentialSelectionInput = { + providerId: string + category?: PluginCategory + credentialId: string +} + +export type CredentialSlot = { + providerId: string + category: PluginCategory + candidates: Array + lastCredentialId: string +} + +export type DashboardListAppInstancesResponse = { + appInstances: Array + pagination: Pagination +} + +export type DashboardListEnvironmentDeploymentsResponse = { + deployments?: Array + pagination?: Pagination +} + +export type DeleteApiKeyResponse = { + [key: string]: unknown +} + +export type DeleteAppInstanceResponse = { + [key: string]: unknown +} + +export type DeleteEnvironmentResponse = { + [key: string]: unknown +} + +export type DeleteReleaseResponse = { + [key: string]: unknown +} + +export type DeployRequest = { + dsl?: string + sourceAppId?: string + newAppInstance?: NewAppInstance + environmentId: string + releaseName?: string + releaseDescription?: string + credentials?: Array + envVars?: Array + idempotencyKey: string + expectedDslDigest?: string +} + +export type DeployResponse = { + appInstance: AppInstance + release: Release + deployment: Deployment +} + +export type Deployment = { + id: string + appInstanceId: string + status: DeploymentStatus + action: DeploymentAction + environment: Environment + release: Release + error?: Error + createdBy: Actor + createdAt: string + finalizedAt?: string +} + +export type DeploymentOptions = { + dslDigest: string + appInstanceDefaults: DeploymentOptionsAppInstanceDefaults + releaseDefaults: DeploymentOptionsReleaseDefaults + credentialSlots: Array + envVarSlots: Array +} + +export type DeploymentOptionsAppInstanceDefaults = { + displayName: string + description: string +} + +export type DeploymentOptionsReleaseDefaults = { + displayName: string + description: string +} + +export type DeveloperApiUrl = { + apiUrl: string + status: DeveloperApiUrlStatus + error?: Error +} + +export type EnvVarInput = { + key: string + value?: string + valueSource?: EnvVarValueSource +} + +export type EnvVarSlot = { + key: string + valueType: EnvVarValueType + description: string + defaultValue?: string + lastValue?: string +} + +export type Environment = { + id: string + displayName: string + description: string + mode: EnvironmentMode + backend: RuntimeBackend + status: EnvironmentStatus + statusMessage: string + lastError?: Error + apiServer?: string + namespace?: string + managedBy?: string + runtimeEndpoint?: string + cpuCount: number + createdAt: string + updatedAt: string +} + +export type EnvironmentAccessPolicy = { + environment: Environment + policy?: AccessPolicy + resolvedSubjects: Array +} + +export type EnvironmentAppInstance = { + appInstance?: AppInstance + currentRelease?: Release + status?: RuntimeInstanceStatus + lastError?: Error + workspaceId?: string + workspaceName?: string +} + +export type EnvironmentDeployment = { + appInstanceId: string + environment: Environment + status: RuntimeInstanceStatus + currentRelease?: Release + desiredRelease?: Release + currentDeployment?: EnvironmentDeploymentRecord + error?: Error + updatedAt: string + releasesBehind?: number +} + +export type EnvironmentDeploymentHistoryItem = { + deployment?: Deployment + appInstanceId?: string + appInstanceName?: string + workspaceId?: string + workspaceName?: string +} + +export type EnvironmentDeploymentRecord = { + id: string + status: DeploymentStatus + createdAt: string + finalizedAt?: string +} + +export type Error = { + code?: string + message?: string + phase?: string + occurredAt?: string +} + +export type ExchangeControlTokenRequest = { + joinToken?: string +} + +export type ExchangeControlTokenResponse = { + accessToken?: string + expiresAt?: string +} + +export type ExportReleaseDslResponse = { + dsl: string +} + +export type ExternalAppRunnerConfig = { + runtimeEndpoint?: string +} + +export type GenerateAppRunnerLaunchProfileRequest = { + environmentId?: string + mode?: AppRunnerLaunchProfileMode + controlEndpoint: string + pluginDaemonBaseUrl: string + runtimeListenAddr: string + debugListenAddr?: string +} + +export type GenerateAppRunnerLaunchProfileResponse = { + environmentId?: string + joinToken?: string + configYaml?: string + runtimeEndpoint?: string + sourceCommands?: Array + dockerCommands?: Array +} + +export type GetAccessChannelsResponse = { + accessChannels: AccessChannels +} + +export type GetAccessPolicyResponse = { + policy: AccessPolicy +} + +export type GetAccessSettingsResponse = { + accessChannels: AccessChannels + environmentPolicies: Array + webAppEndpoints?: Array + cliEndpoint?: AccessEndpoint +} + +export type GetAppInstanceOverviewResponse = { + appInstance: AppInstance + environmentDeployments: Array + recentReleases: Array + accessChannels: AccessChannels + apiKeySummary: ApiKeySummary + totalReleaseCount: number +} + +export type GetAppInstanceResponse = { + appInstance: AppInstance +} + +export type GetAppRunnerLogResponse = { + appRunnerLog: AppRunnerLog + lastArchived?: string +} + +export type GetDeveloperApiSettingsResponse = { + accessChannels: AccessChannels + environments: Array + apiKeys: Array + developerApiUrl: DeveloperApiUrl +} + +export type GetEnvironmentResponse = { + environment?: Environment +} + +export type GetReleaseResponse = { + release: Release +} + +export type K8sEnvironmentConfig = { + namespace?: string + apiServer?: string + caBundle?: string + bearerToken?: string +} + +export type ListApiKeysResponse = { + apiKeys: Array + apiUrl: string +} + +export type ListAppInstanceSummariesResponse = { + appInstanceSummaries: Array + pagination: Pagination +} + +export type ListAppInstancesResponse = { + appInstances: Array + pagination: Pagination +} + +export type ListAppRunnerLogsResponse = { + appRunnerLogs: Array + pagination: CursorPagination + lastArchived?: string +} + +export type ListDeploymentsResponse = { + deployments: Array + pagination: Pagination +} + +export type ListEnvironmentAppInstancesResponse = { + appInstances?: Array + pagination?: Pagination +} + +export type ListEnvironmentDeploymentsResponse = { + environmentDeployments: Array +} + +export type ListEnvironmentsResponse = { + environments: Array + pagination: Pagination +} + +export type ListReleaseCredentialCandidatesResponse = { + slots: Array +} + +export type ListReleaseSummariesResponse = { + releaseSummaries: Array + pagination: Pagination +} + +export type ListReleasesResponse = { + releases: Array + pagination: Pagination +} + +export type ListRollbackTargetsResponse = { + rollbackTargets: Array + pagination: Pagination +} + +export type NamedRef = { + id: string + displayName: string +} + +export type NewAppInstance = { + displayName?: string + description?: string +} + +export type Operator = { + type: OperatorType + id: string + displayName: string +} + +export type PrecheckReleaseRequest = { + appInstanceId?: string + dsl?: string + sourceAppId?: string +} + +export type PrecheckReleaseResponse = { + gateCommitId: string + canCreate: boolean + matchedRelease?: ReleaseContentMatch + unsupportedNodes: Array +} + +export type PromoteRequest = { + appInstanceId?: string + releaseId: string + environmentId?: string + credentials?: Array + envVars?: Array + idempotencyKey: string +} + +export type PromoteResponse = { + deployment: Deployment +} + +export type Release = { + id: string + appInstanceId: string + displayName: string + description: string + source: ReleaseSource + sourceAppId?: string + gateCommitId: string + requiredSlots: Array + createdBy: Actor + createdAt: string +} + +export type ReleaseContentMatch = { + releaseId: string + displayName: string + createdAt: string +} + +export type ReleaseEnvironmentAction = { + environment: Environment + kind: ReleaseEnvironmentActionKind + disabledReason?: string + requiresRuntimeInputs: boolean + currentReleaseId: string +} + +export type ReleaseEnvironmentDeployment = { + environment: Environment + status: RuntimeInstanceStatus +} + +export type ReleaseSummary = { + release: Release + deployedEnvironments: Array + environmentActions: Array + activeEnvironmentCount: number +} + +export type ReportRuntimeAssignmentStatusRequest = { + deploymentId?: string + runtimeInstanceId?: string + releaseId?: string + status?: AckStatus + lastError?: Error + runnerId?: string + assignmentRevision?: string +} + +export type ReportRuntimeAssignmentStatusResponse = { + accepted?: boolean + stale?: boolean +} + +export type RequiredSlot = { + type: SlotType + providerId: string + category: PluginCategory + key: string +} + +export type ResolveApiTokenRouteRequest = { + token?: string +} + +export type ResolveApiTokenRouteResponse = { + environmentId?: string + namespace?: string + serviceName?: string + servicePort?: number + environmentStatus?: EnvironmentStatus + appId?: string + tenantId?: string + runtimeInstanceId?: string + observedReleaseId?: string + runtimeInstanceStatus?: RuntimeInstanceStatus + revoked?: boolean + unavailableReason?: string + targetKind?: RouteTargetKind + directUpstream?: string +} + +export type RollbackRequest = { + appInstanceId?: string + environmentId?: string + targetReleaseId: string + idempotencyKey: string +} + +export type RollbackResponse = { + deployment: Deployment +} + +export type RollbackTarget = { + release: Release + resolvedDeploymentId: string + deployedAt: string + isCurrent: boolean +} + +export type RunnerInfo = { + hostname?: string +} + +export type RuntimeArtifact = { + dslYaml?: string + bindingSnapshotVersion?: string + bindingSnapshot?: { + [key: string]: unknown + } +} + +export type RuntimeArtifactRequest = { + runtimeInstanceId?: string + releaseId?: string + deploymentId?: string + bindingSnapshotVersion?: string +} + +export type RuntimeArtifactResult = { + runtimeInstanceId?: string + releaseId?: string + artifact?: RuntimeArtifact + error?: Error + deploymentId?: string +} + +export type TestConnectionRequest = { + environmentId?: string +} + +export type TestConnectionResponse = { + reachable?: boolean + message?: string +} + +export type UndeployRequest = { + appInstanceId?: string + environmentId?: string + idempotencyKey: string +} + +export type UndeployResponse = { + deployment: Deployment +} + +export type UnsupportedDslNode = { + id: string + type: string +} + +export type UpdateAccessChannelsRequest = { + appInstanceId?: string + webAppEnabled?: boolean + developerApiEnabled?: boolean +} + +export type UpdateAccessChannelsResponse = { + accessChannels: AccessChannels +} + +export type UpdateAccessPolicyRequest = { + appInstanceId?: string + environmentId?: string + mode: AccessMode + subjects?: Array +} + +export type UpdateAccessPolicyResponse = { + policy: AccessPolicy +} + +export type UpdateAppInstanceRequest = { + appInstanceId?: string + displayName: string + description?: string +} + +export type UpdateAppInstanceResponse = { + appInstance: AppInstance +} + +export type UpdateEnvironmentRequest = { + environmentId?: string + displayName: string + description?: string +} + +export type UpdateEnvironmentResponse = { + environment?: Environment +} + +export type UpdateReleaseRequest = { + releaseId?: string + displayName: string + description?: string +} + +export type UpdateReleaseResponse = { + release: Release +} + export type Account = { id?: string email?: string @@ -68,7 +1113,7 @@ export type BrandingInfo = { export type CheckPasswordStatusReply = { requirePasswordChange?: boolean - changeReason?: number + changeReason?: PasswordChangeReason daysToExpire?: number message?: string } @@ -185,7 +1230,7 @@ export type DeleteWorkspaceReply = { } export type EndpointReply = { - mode?: number + mode?: OtelEndpointMode metricsEndpoint?: OtelExporterEndpoint tracesEndpoint?: OtelExporterEndpoint } @@ -196,6 +1241,14 @@ export type EnterpriseSystemUserSettingReply = { enableEmailPasswordLogin?: boolean } +export type ExternallyAccessibleApp = { + appId?: string + tenantId?: string + mode?: string + name?: string + updatedAt?: string +} + export type GetBearerTokenResponse = { maskedToken?: string } @@ -280,7 +1333,7 @@ export type GroupAppItem = { app_name?: string workspace_id?: string workspace_name?: string - app_status?: number + app_status?: AppStatus token_usage?: string rpm?: string concurrency?: string @@ -304,6 +1357,7 @@ export type InfoConfigReply = { Branding?: BrandingInfo WebAppAuth?: WebAppAuthInfo PluginInstallationPermission?: PluginInstallationPermissionInfo + EnableAppDeploy?: boolean } export type InnerAdmission = { @@ -355,6 +1409,19 @@ export type InnerIsUserAllowedToAccessWebAppRes = { result?: boolean } +export type InnerListExternallyAccessibleAppsReq = { + page?: number + limit?: number + mode?: string + name?: string +} + +export type InnerListExternallyAccessibleAppsRes = { + data?: Array + total?: number + hasMore?: boolean +} + export type InnerReleaseAdmissionRequest = { admission?: InnerAdmission } @@ -411,15 +1478,21 @@ export type LicenseStatus = { } export type LimitConfig = { - type?: number + type?: LimitType threshold?: string - action?: number + action?: LimitAction reached?: boolean } export type LimitFields = { workspaceMembers?: number workspaces?: ResourceQuota + appRunnerEnvCpus?: ResourceQuota +} + +export type ListAccessSubjectsReply = { + subjects?: Array + pagination?: Pagination } export type ListGroupAppsResponse = { @@ -442,6 +1515,11 @@ export type ListSecretKeysReply = { pagination?: Pagination } +export type ListTraceProvidersReply = { + providers?: Array + catalog?: Array +} + export type ListUsersReply = { data?: Array pagination?: Pagination @@ -531,7 +1609,7 @@ export type OidcReply = { export type OtelExporterEndpoint = { endpoint?: string compression?: string - protocol?: number + protocol?: 'HTTP_PROTOBUF' | 'HTTP_JSON' | 'GRPC' timeout?: string headers?: { [key: string]: string @@ -549,7 +1627,7 @@ export type OtelExporterStatusReply = { bytesPushed?: string itemsInQueue?: string logs?: string - status?: number + status?: 'RUNNING' | 'ERROR' | 'STOPPED' } export type PasswordPolicyConfig = { @@ -565,7 +1643,7 @@ export type PasswordPolicyConfig = { } export type PasswordStrengthReply = { - level?: number + level?: PasswordStrengthLevel } export type PasswordStrengthReq = { @@ -578,7 +1656,7 @@ export type PluginInstallationPermissionInfo = { } export type PluginInstallationSettingsReply = { - pluginInstallationScope?: number + pluginInstallationScope?: PluginInstallationScope restrictToMarketplaceOnly?: boolean } @@ -616,11 +1694,11 @@ export type ResourceGroupDetail = { description?: string enabled?: boolean rpm_limit?: number - rpm_action?: number + rpm_action?: LimitAction concurrency_limit?: number - concurrency_action?: number + concurrency_action?: LimitAction token_quota?: string - token_action?: number + token_action?: LimitAction created_at?: string updated_at?: string } @@ -635,8 +1713,8 @@ export type ResourceGroupItem = { token_quota?: string token_usage?: string app_count?: string - rpm_status?: number - conc_status?: number + rpm_status?: LimitStatus + conc_status?: LimitStatus created_at?: string updated_at?: string } @@ -684,7 +1762,7 @@ export type SearchAppItem = { app_name?: string workspace_id?: string workspace_name?: string - app_status?: number + app_status?: AppStatus icon?: string icon_type?: string icon_background?: string @@ -720,7 +1798,7 @@ export type SetDefaultWorkspaceReq = { export type Subject = { subjectId?: string - subjectType?: string + subjectType?: SubjectType accountData?: SubjectAccountData groupData?: SubjectGroupData } @@ -753,10 +1831,50 @@ export type TestConnectionReply = { error?: string } +export type TestTraceProviderRequest = { + provider?: TraceProvider +} + export type ToggleEndpointRequest = { enabled?: boolean } +export type ToggleTraceProviderRequest = { + id?: string + enabled?: boolean +} + +export type TraceProvider = { + id?: string + name?: string + provider?: string + endpoint?: string + protocol?: string + credentials?: { + [key: string]: string + } + settings?: { + [key: string]: string + } + enabled?: boolean +} + +export type TraceProviderDescriptor = { + provider?: string + displayName?: string + credentialFields?: Array + settingFields?: Array + defaultProtocol?: string + supportedProtocols?: Array +} + +export type TraceProviderField = { + key?: string + displayName?: string + required?: boolean + secret?: boolean +} + export type UpdateAccessModeReq = { appId?: string accessMode?: string @@ -850,7 +1968,7 @@ export type UpdateOfflineLicenseReq = { } export type UpdatePluginInstallationSettingsRequest = { - pluginInstallationScope?: number + pluginInstallationScope?: PluginInstallationScope restrictToMarketplaceOnly?: boolean } @@ -860,11 +1978,11 @@ export type UpdateResourceGroupRequest = { description?: string enabled?: boolean rpm_limit?: number - rpm_action?: number + rpm_action?: LimitAction concurrency_limit?: number - concurrency_action?: number + concurrency_action?: LimitAction token_quota?: string - token_action?: number + token_action?: LimitAction } export type UpdateUserReply = { @@ -919,6 +2037,14 @@ export type UpdateWorkspaceReq = { status?: string } +export type UpsertTraceProviderReply = { + provider?: TraceProvider +} + +export type UpsertTraceProviderRequest = { + provider?: TraceProvider +} + export type WebAppAuthInfo = { allowSso?: boolean allowEmailCodeLogin?: boolean @@ -956,6 +2082,15 @@ export type WorkspacePermission = { allowOwnerTransfer?: boolean } +export type CursorPagination = { + pageSize?: number + nextCursor?: string + prevCursor?: string + hasNextPage?: boolean + hasPrevPage?: boolean + totalCount?: string +} + export type Pagination = { totalCount?: number perPage?: number @@ -963,6 +2098,633 @@ export type Pagination = { totalPages?: number } +export type AccessSubjectServiceListAccessSubjectsData = { + body?: never + path?: never + query?: { + keyword?: string + groupId?: string + pageNumber?: number + resultsPerPage?: number + } + url: '/enterprise/access-subjects' +} + +export type AccessSubjectServiceListAccessSubjectsResponses = { + 200: ListAccessSubjectsReply +} + +export type AccessSubjectServiceListAccessSubjectsResponse + = AccessSubjectServiceListAccessSubjectsResponses[keyof AccessSubjectServiceListAccessSubjectsResponses] + +export type AppInstanceServiceListAppInstanceSummariesData = { + body?: never + path?: never + query?: { + pageNumber?: number + resultsPerPage?: number + displayName?: string + environmentId?: string + } + url: '/enterprise/app-deploy/appInstanceSummaries' +} + +export type AppInstanceServiceListAppInstanceSummariesResponses = { + 200: ListAppInstanceSummariesResponse +} + +export type AppInstanceServiceListAppInstanceSummariesResponse + = AppInstanceServiceListAppInstanceSummariesResponses[keyof AppInstanceServiceListAppInstanceSummariesResponses] + +export type AppInstanceServiceListAppInstancesData = { + body?: never + path?: never + query?: { + pageNumber?: number + resultsPerPage?: number + displayName?: string + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances' +} + +export type AppInstanceServiceListAppInstancesResponses = { + 200: ListAppInstancesResponse +} + +export type AppInstanceServiceListAppInstancesResponse + = AppInstanceServiceListAppInstancesResponses[keyof AppInstanceServiceListAppInstancesResponses] + +export type AppInstanceServiceCreateAppInstanceData = { + body: CreateAppInstanceRequest + path?: never + query?: never + url: '/enterprise/app-deploy/appInstances' +} + +export type AppInstanceServiceCreateAppInstanceResponses = { + 200: CreateAppInstanceResponse +} + +export type AppInstanceServiceCreateAppInstanceResponse + = AppInstanceServiceCreateAppInstanceResponses[keyof AppInstanceServiceCreateAppInstanceResponses] + +export type AppInstanceServiceDeleteAppInstanceData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}' +} + +export type AppInstanceServiceDeleteAppInstanceResponses = { + 200: DeleteAppInstanceResponse +} + +export type AppInstanceServiceDeleteAppInstanceResponse + = AppInstanceServiceDeleteAppInstanceResponses[keyof AppInstanceServiceDeleteAppInstanceResponses] + +export type AppInstanceServiceGetAppInstanceData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}' +} + +export type AppInstanceServiceGetAppInstanceResponses = { + 200: GetAppInstanceResponse +} + +export type AppInstanceServiceGetAppInstanceResponse + = AppInstanceServiceGetAppInstanceResponses[keyof AppInstanceServiceGetAppInstanceResponses] + +export type AppInstanceServiceUpdateAppInstanceData = { + body: UpdateAppInstanceRequest + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}' +} + +export type AppInstanceServiceUpdateAppInstanceResponses = { + 200: UpdateAppInstanceResponse +} + +export type AppInstanceServiceUpdateAppInstanceResponse + = AppInstanceServiceUpdateAppInstanceResponses[keyof AppInstanceServiceUpdateAppInstanceResponses] + +export type AccessServiceGetAccessChannelsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels' +} + +export type AccessServiceGetAccessChannelsResponses = { + 200: GetAccessChannelsResponse +} + +export type AccessServiceGetAccessChannelsResponse + = AccessServiceGetAccessChannelsResponses[keyof AccessServiceGetAccessChannelsResponses] + +export type AccessServiceUpdateAccessChannelsData = { + body: UpdateAccessChannelsRequest + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels' +} + +export type AccessServiceUpdateAccessChannelsResponses = { + 200: UpdateAccessChannelsResponse +} + +export type AccessServiceUpdateAccessChannelsResponse + = AccessServiceUpdateAccessChannelsResponses[keyof AccessServiceUpdateAccessChannelsResponses] + +export type AccessServiceGetAccessSettingsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessSettings' +} + +export type AccessServiceGetAccessSettingsResponses = { + 200: GetAccessSettingsResponse +} + +export type AccessServiceGetAccessSettingsResponse + = AccessServiceGetAccessSettingsResponses[keyof AccessServiceGetAccessSettingsResponses] + +export type DeploymentServiceListDeploymentsData = { + body?: never + path: { + appInstanceId: string + } + query?: { + pageNumber?: number + resultsPerPage?: number + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/deployments' +} + +export type DeploymentServiceListDeploymentsResponses = { + 200: ListDeploymentsResponse +} + +export type DeploymentServiceListDeploymentsResponse + = DeploymentServiceListDeploymentsResponses[keyof DeploymentServiceListDeploymentsResponses] + +export type AccessServiceGetDeveloperApiSettingsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/developerApiSettings' +} + +export type AccessServiceGetDeveloperApiSettingsResponses = { + 200: GetDeveloperApiSettingsResponse +} + +export type AccessServiceGetDeveloperApiSettingsResponse + = AccessServiceGetDeveloperApiSettingsResponses[keyof AccessServiceGetDeveloperApiSettingsResponses] + +export type DeploymentServiceListEnvironmentDeploymentsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environmentDeployments' +} + +export type DeploymentServiceListEnvironmentDeploymentsResponses = { + 200: ListEnvironmentDeploymentsResponse +} + +export type DeploymentServiceListEnvironmentDeploymentsResponse + = DeploymentServiceListEnvironmentDeploymentsResponses[keyof DeploymentServiceListEnvironmentDeploymentsResponses] + +export type AccessServiceGetAccessPolicyData = { + body?: never + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy' +} + +export type AccessServiceGetAccessPolicyResponses = { + 200: GetAccessPolicyResponse +} + +export type AccessServiceGetAccessPolicyResponse + = AccessServiceGetAccessPolicyResponses[keyof AccessServiceGetAccessPolicyResponses] + +export type AccessServiceUpdateAccessPolicyData = { + body: UpdateAccessPolicyRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy' +} + +export type AccessServiceUpdateAccessPolicyResponses = { + 200: UpdateAccessPolicyResponse +} + +export type AccessServiceUpdateAccessPolicyResponse + = AccessServiceUpdateAccessPolicyResponses[keyof AccessServiceUpdateAccessPolicyResponses] + +export type AccessServiceListApiKeysData = { + body?: never + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys' +} + +export type AccessServiceListApiKeysResponses = { + 200: ListApiKeysResponse +} + +export type AccessServiceListApiKeysResponse + = AccessServiceListApiKeysResponses[keyof AccessServiceListApiKeysResponses] + +export type AccessServiceCreateApiKeyData = { + body: CreateApiKeyRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys' +} + +export type AccessServiceCreateApiKeyResponses = { + 200: CreateApiKeyResponse +} + +export type AccessServiceCreateApiKeyResponse + = AccessServiceCreateApiKeyResponses[keyof AccessServiceCreateApiKeyResponses] + +export type AccessServiceDeleteApiKeyData = { + body?: never + path: { + appInstanceId: string + environmentId: string + apiKeyId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys/{apiKeyId}' +} + +export type AccessServiceDeleteApiKeyResponses = { + 200: DeleteApiKeyResponse +} + +export type AccessServiceDeleteApiKeyResponse + = AccessServiceDeleteApiKeyResponses[keyof AccessServiceDeleteApiKeyResponses] + +export type DeploymentServiceListRollbackTargetsData = { + body?: never + path: { + appInstanceId: string + environmentId: string + } + query?: { + pageNumber?: number + resultsPerPage?: number + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/rollbackTargets' +} + +export type DeploymentServiceListRollbackTargetsResponses = { + 200: ListRollbackTargetsResponse +} + +export type DeploymentServiceListRollbackTargetsResponse + = DeploymentServiceListRollbackTargetsResponses[keyof DeploymentServiceListRollbackTargetsResponses] + +export type DeploymentServiceCancelDeploymentData = { + body: CancelDeploymentRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:cancelDeployment' +} + +export type DeploymentServiceCancelDeploymentResponses = { + 200: CancelDeploymentResponse +} + +export type DeploymentServiceCancelDeploymentResponse + = DeploymentServiceCancelDeploymentResponses[keyof DeploymentServiceCancelDeploymentResponses] + +export type DeploymentServicePromoteData = { + body: PromoteRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:promote' +} + +export type DeploymentServicePromoteResponses = { + 200: PromoteResponse +} + +export type DeploymentServicePromoteResponse + = DeploymentServicePromoteResponses[keyof DeploymentServicePromoteResponses] + +export type DeploymentServiceRollbackData = { + body: RollbackRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:rollback' +} + +export type DeploymentServiceRollbackResponses = { + 200: RollbackResponse +} + +export type DeploymentServiceRollbackResponse + = DeploymentServiceRollbackResponses[keyof DeploymentServiceRollbackResponses] + +export type DeploymentServiceUndeployData = { + body: UndeployRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:undeploy' +} + +export type DeploymentServiceUndeployResponses = { + 200: UndeployResponse +} + +export type DeploymentServiceUndeployResponse + = DeploymentServiceUndeployResponses[keyof DeploymentServiceUndeployResponses] + +export type ReleaseServiceListReleaseSummariesData = { + body?: never + path: { + appInstanceId: string + } + query?: { + releaseId?: string + displayName?: string + pageNumber?: number + resultsPerPage?: number + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/releaseSummaries' +} + +export type ReleaseServiceListReleaseSummariesResponses = { + 200: ListReleaseSummariesResponse +} + +export type ReleaseServiceListReleaseSummariesResponse + = ReleaseServiceListReleaseSummariesResponses[keyof ReleaseServiceListReleaseSummariesResponses] + +export type ReleaseServiceListReleasesData = { + body?: never + path: { + appInstanceId: string + } + query?: { + releaseId?: string + displayName?: string + pageNumber?: number + resultsPerPage?: number + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/releases' +} + +export type ReleaseServiceListReleasesResponses = { + 200: ListReleasesResponse +} + +export type ReleaseServiceListReleasesResponse + = ReleaseServiceListReleasesResponses[keyof ReleaseServiceListReleasesResponses] + +export type ReleaseServiceComputeReleaseDeploymentViewData = { + body?: never + path: { + appInstanceId: string + } + query?: { + releaseId?: string + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}:computeReleaseDeploymentView' +} + +export type ReleaseServiceComputeReleaseDeploymentViewResponses = { + 200: ComputeReleaseDeploymentViewResponse +} + +export type ReleaseServiceComputeReleaseDeploymentViewResponse + = ReleaseServiceComputeReleaseDeploymentViewResponses[keyof ReleaseServiceComputeReleaseDeploymentViewResponses] + +export type AppInstanceServiceGetAppInstanceOverviewData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}:getOverview' +} + +export type AppInstanceServiceGetAppInstanceOverviewResponses = { + 200: GetAppInstanceOverviewResponse +} + +export type AppInstanceServiceGetAppInstanceOverviewResponse + = AppInstanceServiceGetAppInstanceOverviewResponses[keyof AppInstanceServiceGetAppInstanceOverviewResponses] + +export type DeploymentServiceDeployData = { + body: DeployRequest + path?: never + query?: never + url: '/enterprise/app-deploy/appInstances:deploy' +} + +export type DeploymentServiceDeployResponses = { + 200: DeployResponse +} + +export type DeploymentServiceDeployResponse + = DeploymentServiceDeployResponses[keyof DeploymentServiceDeployResponses] + +export type EnvironmentServiceListEnvironmentsData = { + body?: never + path?: never + query?: { + environmentId?: string + displayName?: string + pageNumber?: number + resultsPerPage?: number + } + url: '/enterprise/app-deploy/environments' +} + +export type EnvironmentServiceListEnvironmentsResponses = { + 200: ListEnvironmentsResponse +} + +export type EnvironmentServiceListEnvironmentsResponse + = EnvironmentServiceListEnvironmentsResponses[keyof EnvironmentServiceListEnvironmentsResponses] + +export type ReleaseServiceCreateReleaseData = { + body: CreateReleaseRequest + path?: never + query?: never + url: '/enterprise/app-deploy/releases' +} + +export type ReleaseServiceCreateReleaseResponses = { + 200: CreateReleaseResponse +} + +export type ReleaseServiceCreateReleaseResponse + = ReleaseServiceCreateReleaseResponses[keyof ReleaseServiceCreateReleaseResponses] + +export type ReleaseServiceDeleteReleaseData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}' +} + +export type ReleaseServiceDeleteReleaseResponses = { + 200: DeleteReleaseResponse +} + +export type ReleaseServiceDeleteReleaseResponse + = ReleaseServiceDeleteReleaseResponses[keyof ReleaseServiceDeleteReleaseResponses] + +export type ReleaseServiceGetReleaseData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}' +} + +export type ReleaseServiceGetReleaseResponses = { + 200: GetReleaseResponse +} + +export type ReleaseServiceGetReleaseResponse + = ReleaseServiceGetReleaseResponses[keyof ReleaseServiceGetReleaseResponses] + +export type ReleaseServiceUpdateReleaseData = { + body: UpdateReleaseRequest + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}' +} + +export type ReleaseServiceUpdateReleaseResponses = { + 200: UpdateReleaseResponse +} + +export type ReleaseServiceUpdateReleaseResponse + = ReleaseServiceUpdateReleaseResponses[keyof ReleaseServiceUpdateReleaseResponses] + +export type ReleaseServiceExportReleaseDslData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}:exportDsl' +} + +export type ReleaseServiceExportReleaseDslResponses = { + 200: ExportReleaseDslResponse +} + +export type ReleaseServiceExportReleaseDslResponse + = ReleaseServiceExportReleaseDslResponses[keyof ReleaseServiceExportReleaseDslResponses] + +export type ReleaseServiceListReleaseCredentialCandidatesData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}:listCredentialCandidates' +} + +export type ReleaseServiceListReleaseCredentialCandidatesResponses = { + 200: ListReleaseCredentialCandidatesResponse +} + +export type ReleaseServiceListReleaseCredentialCandidatesResponse + = ReleaseServiceListReleaseCredentialCandidatesResponses[keyof ReleaseServiceListReleaseCredentialCandidatesResponses] + +export type ReleaseServiceComputeDeploymentOptionsData = { + body: ComputeDeploymentOptionsRequest + path?: never + query?: never + url: '/enterprise/app-deploy/releases:computeDeploymentOptions' +} + +export type ReleaseServiceComputeDeploymentOptionsResponses = { + 200: ComputeDeploymentOptionsResponse +} + +export type ReleaseServiceComputeDeploymentOptionsResponse + = ReleaseServiceComputeDeploymentOptionsResponses[keyof ReleaseServiceComputeDeploymentOptionsResponses] + +export type ReleaseServicePrecheckReleaseData = { + body: PrecheckReleaseRequest + path?: never + query?: never + url: '/enterprise/app-deploy/releases:precheck' +} + +export type ReleaseServicePrecheckReleaseResponses = { + 200: PrecheckReleaseResponse +} + +export type ReleaseServicePrecheckReleaseResponse + = ReleaseServicePrecheckReleaseResponses[keyof ReleaseServicePrecheckReleaseResponses] + export type ConsoleSsoOAuth2LoginData = { body?: never path?: never diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index cef500a906..8e4251b209 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -2,6 +2,975 @@ import * as z from 'zod' +export const zAccessMode = z.enum([ + 'ACCESS_MODE_UNSPECIFIED', + 'ACCESS_MODE_PUBLIC', + 'ACCESS_MODE_PRIVATE', + 'ACCESS_MODE_PRIVATE_ALL', +]) + +export const zSubjectType = z.enum([ + 'SUBJECT_TYPE_UNSPECIFIED', + 'SUBJECT_TYPE_ACCOUNT', + 'SUBJECT_TYPE_GROUP', +]) + +export const zAppRunnerLogStatus = z.enum([ + 'APP_RUNNER_LOG_STATUS_UNSPECIFIED', + 'APP_RUNNER_LOG_STATUS_RUNNING', + 'APP_RUNNER_LOG_STATUS_SUCCEEDED', + 'APP_RUNNER_LOG_STATUS_FAILED', + 'APP_RUNNER_LOG_STATUS_PARTIAL_SUCCEEDED', +]) + +export const zAssignmentOperation = z.enum([ + 'ASSIGNMENT_OPERATION_UNSPECIFIED', + 'ASSIGNMENT_OPERATION_LOAD', + 'ASSIGNMENT_OPERATION_UNLOAD', +]) + +export const zEnvironmentMode = z.enum([ + 'ENVIRONMENT_MODE_UNSPECIFIED', + 'ENVIRONMENT_MODE_SHARED', + 'ENVIRONMENT_MODE_ISOLATED', +]) + +export const zRuntimeBackend = z.enum([ + 'RUNTIME_BACKEND_UNSPECIFIED', + 'RUNTIME_BACKEND_K8S', + 'RUNTIME_BACKEND_EXTERNAL', +]) + +export const zPluginCategory = z.enum([ + 'PLUGIN_CATEGORY_UNSPECIFIED', + 'PLUGIN_CATEGORY_MODEL', + 'PLUGIN_CATEGORY_TOOL', +]) + +export const zDeploymentStatus = z.enum([ + 'DEPLOYMENT_STATUS_UNSPECIFIED', + 'DEPLOYMENT_STATUS_DEPLOYING', + 'DEPLOYMENT_STATUS_READY', + 'DEPLOYMENT_STATUS_FAILED', + 'DEPLOYMENT_STATUS_CANCELLED', +]) + +export const zDeploymentAction = z.enum([ + 'DEPLOYMENT_ACTION_UNSPECIFIED', + 'DEPLOYMENT_ACTION_DEPLOY', + 'DEPLOYMENT_ACTION_PROMOTE', + 'DEPLOYMENT_ACTION_ROLLBACK', + 'DEPLOYMENT_ACTION_UNDEPLOY', +]) + +export const zDeveloperApiUrlStatus = z.enum([ + 'DEVELOPER_API_URL_STATUS_UNSPECIFIED', + 'DEVELOPER_API_URL_STATUS_CONFIGURED', + 'DEVELOPER_API_URL_STATUS_NOT_CONFIGURED', +]) + +export const zEnvVarValueSource = z.enum([ + 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', + 'ENV_VAR_VALUE_SOURCE_LITERAL', + 'ENV_VAR_VALUE_SOURCE_DSL_DEFAULT', + 'ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT', +]) + +export const zEnvVarValueType = z.enum([ + 'ENV_VAR_VALUE_TYPE_UNSPECIFIED', + 'ENV_VAR_VALUE_TYPE_STRING', + 'ENV_VAR_VALUE_TYPE_NUMBER', + 'ENV_VAR_VALUE_TYPE_SECRET', +]) + +export const zEnvironmentStatus = z.enum([ + 'ENVIRONMENT_STATUS_UNSPECIFIED', + 'ENVIRONMENT_STATUS_ADMISSION', + 'ENVIRONMENT_STATUS_BOOTSTRAPPING', + 'ENVIRONMENT_STATUS_READY', + 'ENVIRONMENT_STATUS_FAILED', + 'ENVIRONMENT_STATUS_DELETING', +]) + +export const zRuntimeInstanceStatus = z.enum([ + 'RUNTIME_INSTANCE_STATUS_UNSPECIFIED', + 'RUNTIME_INSTANCE_STATUS_UNDEPLOYED', + 'RUNTIME_INSTANCE_STATUS_DEPLOYING', + 'RUNTIME_INSTANCE_STATUS_READY', + 'RUNTIME_INSTANCE_STATUS_FAILED', + 'RUNTIME_INSTANCE_STATUS_DRIFTED', + 'RUNTIME_INSTANCE_STATUS_INVALID', + 'RUNTIME_INSTANCE_STATUS_UNDEPLOYING', +]) + +export const zAppRunnerLaunchProfileMode = z.enum([ + 'APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED', + 'APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG', +]) + +export const zOperatorType = z.enum([ + 'OPERATOR_TYPE_UNSPECIFIED', + 'OPERATOR_TYPE_END_USER', + 'OPERATOR_TYPE_ACCOUNT', + 'OPERATOR_TYPE_SERVICE_ACCOUNT', + 'OPERATOR_TYPE_SYSTEM', +]) + +export const zReleaseSource = z.enum([ + 'RELEASE_SOURCE_UNSPECIFIED', + 'RELEASE_SOURCE_SOURCE_APP', + 'RELEASE_SOURCE_UPLOAD', +]) + +export const zReleaseEnvironmentActionKind = z.enum([ + 'RELEASE_ENVIRONMENT_ACTION_KIND_UNSPECIFIED', + 'RELEASE_ENVIRONMENT_ACTION_KIND_PROMOTE', + 'RELEASE_ENVIRONMENT_ACTION_KIND_ROLLBACK', + 'RELEASE_ENVIRONMENT_ACTION_KIND_CURRENT', + 'RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING', + 'RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED', +]) + +export const zAckStatus = z.enum([ + 'ACK_STATUS_UNSPECIFIED', + 'ACK_STATUS_READY', + 'ACK_STATUS_FAILED', +]) + +export const zSlotType = z.enum([ + 'SLOT_TYPE_UNSPECIFIED', + 'SLOT_TYPE_PLUGIN_CREDENTIAL', + 'SLOT_TYPE_ENV_VAR', +]) + +export const zRouteTargetKind = z.enum([ + 'ROUTE_TARGET_KIND_UNSPECIFIED', + 'ROUTE_TARGET_KIND_K8S_SERVICE', + 'ROUTE_TARGET_KIND_DIRECT_UPSTREAM', +]) + +export const zPasswordChangeReason = z.enum([ + 'PASSWORD_CHANGE_REASON_UNSPECIFIED', + 'PASSWORD_CHANGE_REASON_TEMP', + 'PASSWORD_CHANGE_REASON_EXPIRED', + 'PASSWORD_CHANGE_REASON_POLICY', +]) + +export const zOtelEndpointMode = z.enum([ + 'OTEL_ENDPOINT_MODE_UNIFIED', + 'OTEL_ENDPOINT_MODE_DEDICATED', +]) + +export const zAppStatus = z.enum([ + 'APP_STATUS_UNSPECIFIED', + 'APP_STATUS_PUBLISHED', + 'APP_STATUS_UNPUBLISHED', + 'APP_STATUS_DELETED', +]) + +export const zLimitType = z.enum([ + 'LIMIT_TYPE_UNSPECIFIED', + 'LIMIT_TYPE_RPM', + 'LIMIT_TYPE_CONCURRENCY', + 'LIMIT_TYPE_TOKEN', +]) + +export const zLimitAction = z.enum([ + 'LIMIT_ACTION_UNSPECIFIED', + 'LIMIT_ACTION_BLOCK', + 'LIMIT_ACTION_TRACK', +]) + +export const zPasswordStrengthLevel = z.enum([ + 'PASSWORD_STRENGTH_LEVEL_UNSPECIFIED', + 'PASSWORD_STRENGTH_LEVEL_WEAK', + 'PASSWORD_STRENGTH_LEVEL_MEDIUM', + 'PASSWORD_STRENGTH_LEVEL_STRONG', +]) + +export const zPluginInstallationScope = z.enum([ + 'PLUGIN_INSTALLATION_SCOPE_ALL', + 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_ONLY', + 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_AND_SPECIFIC_PARTNERS', + 'PLUGIN_INSTALLATION_SCOPE_NONE', +]) + +export const zLimitStatus = z.enum([ + 'LIMIT_STATUS_UNSPECIFIED', + 'LIMIT_STATUS_NA', + 'LIMIT_STATUS_NORMAL', + 'LIMIT_STATUS_THROTTLED', +]) + +export const zAccessSubject = z.object({ + subjectType: zSubjectType, + subjectId: z.string(), +}) + +export const zAccessPolicy = z.object({ + id: z.string(), + appInstanceId: z.string(), + environmentId: z.string(), + mode: zAccessMode, + subjects: z.array(zAccessSubject), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +export const zActor = z.object({ + id: z.string(), + displayName: z.string(), +}) + +export const zAccessChannels = z.object({ + id: z.string(), + appInstanceId: z.string(), + webAppEnabled: z.boolean(), + developerApiEnabled: z.boolean(), + updatedBy: zActor, + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +export const zApiKey = z.object({ + id: z.string(), + appInstanceId: z.string(), + environmentId: z.string(), + displayName: z.string(), + maskedToken: z.string(), + createdBy: zActor, + createdAt: z.iso.datetime(), + lastUsedAt: z.iso.datetime().optional(), +}) + +export const zAppInstance = z.object({ + id: z.string(), + tenantId: z.string(), + displayName: z.string(), + description: z.string(), + createdBy: zActor, + updatedBy: zActor, + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +/** + * BootstrapAssignment is one runtime_instance assignment in a runner's startup + * baseline. + */ +export const zBootstrapAssignment = z.object({ + appId: z.string().optional(), + environmentId: z.string().optional(), + workflowId: z.string().optional(), + runtimeInstanceId: z.string().optional(), + workspaceId: z.string().optional(), + runtimeInstanceVersion: z.string().optional(), + bindingSnapshotVersion: z.string().optional(), + executionTokenVersion: z.string().optional(), + executionToken: z.string().optional(), + releaseId: z.string().optional(), + operation: zAssignmentOperation.optional(), + deploymentId: z.string().optional(), + requiresStatusReport: z.boolean().optional(), +}) + +export const zBootstrapRunnerResponse = z.object({ + runnerId: z.string().optional(), + assignmentRevision: z.string().optional(), + assignments: z.array(zBootstrapAssignment).optional(), +}) + +export const zCancelDeploymentRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), +}) + +export const zComputeDeploymentOptionsRequest = z.object({ + environmentId: z.string().optional(), + appInstanceId: z.string().optional(), + dsl: z.string().optional(), + sourceAppId: z.string().optional(), + releaseId: z.string().optional(), +}) + +export const zCreateApiKeyRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + displayName: z.string(), +}) + +export const zCreateApiKeyResponse = z.object({ + apiKey: zApiKey, + token: z.string(), +}) + +export const zCreateAppInstanceRequest = z.object({ + displayName: z.string(), + description: z.string().optional(), +}) + +export const zCreateAppInstanceResponse = z.object({ + appInstance: zAppInstance, +}) + +export const zCreateReleaseRequest = z.object({ + createAppInstance: z.boolean().optional(), + appInstanceId: z.string().optional(), + displayName: z.string().optional(), + description: z.string().optional(), + dsl: z.string().optional(), + sourceAppId: z.string().optional(), +}) + +/** + * CredentialCandidate is one tenant-visible credential a frontend may + * pick for a credential slot. It carries no secret. + */ +export const zCredentialCandidate = z.object({ + credentialId: z.string(), + providerId: z.string(), + category: zPluginCategory, + displayName: z.string(), + fromEnterprise: z.boolean(), +}) + +/** + * CredentialSelectionInput is one deploy-time plugin-credential + * selection: a shared credential id chosen for a required DSL slot. + */ +export const zCredentialSelectionInput = z.object({ + providerId: z.string(), + category: zPluginCategory.optional(), + credentialId: z.string(), +}) + +/** + * CredentialSlot is one model/tool plugin-credential requirement a + * Release's DSL declares, paired with the candidates selectable for it. + */ +export const zCredentialSlot = z.object({ + providerId: z.string(), + category: zPluginCategory, + candidates: z.array(zCredentialCandidate), + lastCredentialId: z.string(), +}) + +export const zDeleteApiKeyResponse = z.record(z.string(), z.unknown()) + +export const zDeleteAppInstanceResponse = z.record(z.string(), z.unknown()) + +export const zDeleteEnvironmentResponse = z.record(z.string(), z.unknown()) + +export const zDeleteReleaseResponse = z.record(z.string(), z.unknown()) + +export const zDeploymentOptionsAppInstanceDefaults = z.object({ + displayName: z.string(), + description: z.string(), +}) + +export const zDeploymentOptionsReleaseDefaults = z.object({ + displayName: z.string(), + description: z.string(), +}) + +export const zEnvVarInput = z.object({ + key: z.string(), + value: z.string().optional(), + valueSource: zEnvVarValueSource.optional(), +}) + +export const zEnvVarSlot = z.object({ + key: z.string(), + valueType: zEnvVarValueType, + description: z.string(), + defaultValue: z.string().optional(), + lastValue: z.string().optional(), +}) + +export const zDeploymentOptions = z.object({ + dslDigest: z.string(), + appInstanceDefaults: zDeploymentOptionsAppInstanceDefaults, + releaseDefaults: zDeploymentOptionsReleaseDefaults, + credentialSlots: z.array(zCredentialSlot), + envVarSlots: z.array(zEnvVarSlot), +}) + +export const zComputeDeploymentOptionsResponse = z.object({ + options: zDeploymentOptions, +}) + +export const zEnvironmentDeploymentRecord = z.object({ + id: z.string(), + status: zDeploymentStatus, + createdAt: z.iso.datetime(), + finalizedAt: z.iso.datetime().optional(), +}) + +/** + * Error is the package-wide failure shape, carried wherever an operation or + * resource reports an error. + */ +export const zError = z.object({ + code: z.string().optional(), + message: z.string().optional(), + phase: z.string().optional(), + occurredAt: z.iso.datetime().optional(), +}) + +export const zDeveloperApiUrl = z.object({ + apiUrl: z.string(), + status: zDeveloperApiUrlStatus, + error: zError.optional(), +}) + +export const zApiKeySummary = z.object({ + apiKeyCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + environmentCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + developerApiEnabled: z.boolean(), + developerApiUrl: zDeveloperApiUrl, +}) + +export const zEnvironment = z.object({ + id: z.string(), + displayName: z.string(), + description: z.string(), + mode: zEnvironmentMode, + backend: zRuntimeBackend, + status: zEnvironmentStatus, + statusMessage: z.string(), + lastError: zError.optional(), + apiServer: z.string().optional(), + namespace: z.string().optional(), + managedBy: z.string().optional(), + runtimeEndpoint: z.string().optional(), + cpuCount: z.number(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +export const zAccessEndpoint = z.object({ + environment: zEnvironment.optional(), + endpointUrl: z.string(), +}) + +export const zCreateEnvironmentResponse = z.object({ + environment: zEnvironment.optional(), +}) + +export const zExchangeControlTokenRequest = z.object({ + joinToken: z.string().optional(), +}) + +export const zExchangeControlTokenResponse = z.object({ + accessToken: z.string().optional(), + expiresAt: z.iso.datetime().optional(), +}) + +export const zExportReleaseDslResponse = z.object({ + dsl: z.string(), +}) + +export const zExternalAppRunnerConfig = z.object({ + runtimeEndpoint: z.string().optional(), +}) + +export const zGenerateAppRunnerLaunchProfileRequest = z.object({ + environmentId: z.string().optional(), + mode: zAppRunnerLaunchProfileMode.optional(), + controlEndpoint: z.string(), + pluginDaemonBaseUrl: z.string(), + runtimeListenAddr: z.string(), + debugListenAddr: z.string().optional(), +}) + +export const zGenerateAppRunnerLaunchProfileResponse = z.object({ + environmentId: z.string().optional(), + joinToken: z.string().optional(), + configYaml: z.string().optional(), + runtimeEndpoint: z.string().optional(), + sourceCommands: z.array(z.string()).optional(), + dockerCommands: z.array(z.string()).optional(), +}) + +export const zGetAccessChannelsResponse = z.object({ + accessChannels: zAccessChannels, +}) + +export const zGetAccessPolicyResponse = z.object({ + policy: zAccessPolicy, +}) + +export const zGetAppInstanceResponse = z.object({ + appInstance: zAppInstance, +}) + +export const zGetDeveloperApiSettingsResponse = z.object({ + accessChannels: zAccessChannels, + environments: z.array(zEnvironment), + apiKeys: z.array(zApiKey), + developerApiUrl: zDeveloperApiUrl, +}) + +export const zGetEnvironmentResponse = z.object({ + environment: zEnvironment.optional(), +}) + +export const zK8sEnvironmentConfig = z.object({ + namespace: z.string().optional(), + apiServer: z.string().optional(), + caBundle: z.string().optional(), + bearerToken: z.string().optional(), +}) + +export const zCreateEnvironmentRequest = z.object({ + displayName: z.string(), + description: z.string().optional(), + mode: zEnvironmentMode.optional(), + backend: zRuntimeBackend.optional(), + k8s: zK8sEnvironmentConfig.optional(), + external: zExternalAppRunnerConfig.optional(), + cpuCount: z.number().optional(), + idempotencyKey: z.string(), +}) + +export const zListApiKeysResponse = z.object({ + apiKeys: z.array(zApiKey), + apiUrl: z.string(), +}) + +export const zListReleaseCredentialCandidatesResponse = z.object({ + slots: z.array(zCredentialSlot), +}) + +export const zNamedRef = z.object({ + id: z.string(), + displayName: z.string(), +}) + +export const zNewAppInstance = z.object({ + displayName: z.string().optional(), + description: z.string().optional(), +}) + +export const zDeployRequest = z.object({ + dsl: z.string().optional(), + sourceAppId: z.string().optional(), + newAppInstance: zNewAppInstance.optional(), + environmentId: z.string(), + releaseName: z.string().optional(), + releaseDescription: z.string().optional(), + credentials: z.array(zCredentialSelectionInput).optional(), + envVars: z.array(zEnvVarInput).optional(), + idempotencyKey: z.string(), + expectedDslDigest: z.string().optional(), +}) + +/** + * Operator is who triggered the run (the "END USER OR ACCOUNT" column). + */ +export const zOperator = z.object({ + type: zOperatorType, + id: z.string(), + displayName: z.string(), +}) + +export const zAppRunnerLog = z.object({ + id: z.string(), + timestamp: z.iso.datetime(), + workflowRunId: z.string(), + status: zAppRunnerLogStatus, + durationSeconds: z.number(), + totalTokens: z.string(), + workspace: zNamedRef, + environment: zNamedRef, + appInstance: zNamedRef, + operator: zOperator, + invokeFrom: z.string(), + traceId: z.string(), + difyTraceId: z.string(), + gateCommitId: z.string(), + body: z.string().optional(), + attributesJson: z.string().optional(), + resourceAttributesJson: z.string().optional(), +}) + +export const zGetAppRunnerLogResponse = z.object({ + appRunnerLog: zAppRunnerLog, + lastArchived: z.iso.datetime().optional(), +}) + +export const zPrecheckReleaseRequest = z.object({ + appInstanceId: z.string().optional(), + dsl: z.string().optional(), + sourceAppId: z.string().optional(), +}) + +export const zPromoteRequest = z.object({ + appInstanceId: z.string().optional(), + releaseId: z.string(), + environmentId: z.string().optional(), + credentials: z.array(zCredentialSelectionInput).optional(), + envVars: z.array(zEnvVarInput).optional(), + idempotencyKey: z.string(), +}) + +/** + * ReleaseContentMatch identifies an existing release whose DSL content is + * identical to the checked content. + */ +export const zReleaseContentMatch = z.object({ + releaseId: z.string(), + displayName: z.string(), + createdAt: z.iso.datetime(), +}) + +export const zReleaseEnvironmentAction = z.object({ + environment: zEnvironment, + kind: zReleaseEnvironmentActionKind, + disabledReason: z.string().optional(), + requiresRuntimeInputs: z.boolean(), + currentReleaseId: z.string(), +}) + +/** + * ReleaseEnvironmentDeployment is an environment where the release is the + * active deployment, paired with that environment's runtime status so the + * version history can show running vs failed vs deploying. + */ +export const zReleaseEnvironmentDeployment = z.object({ + environment: zEnvironment, + status: zRuntimeInstanceStatus, +}) + +export const zReportRuntimeAssignmentStatusRequest = z.object({ + deploymentId: z.string().optional(), + runtimeInstanceId: z.string().optional(), + releaseId: z.string().optional(), + status: zAckStatus.optional(), + lastError: zError.optional(), + runnerId: z.string().optional(), + assignmentRevision: z.string().optional(), +}) + +export const zReportRuntimeAssignmentStatusResponse = z.object({ + accepted: z.boolean().optional(), + stale: z.boolean().optional(), +}) + +/** + * RequiredSlot is an input requirement extracted from a Release's + * DSL. + */ +export const zRequiredSlot = z.object({ + type: zSlotType, + providerId: z.string(), + category: zPluginCategory, + key: z.string(), +}) + +export const zRelease = z.object({ + id: z.string(), + appInstanceId: z.string(), + displayName: z.string(), + description: z.string(), + source: zReleaseSource, + sourceAppId: z.string().optional(), + gateCommitId: z.string(), + requiredSlots: z.array(zRequiredSlot), + createdBy: zActor, + createdAt: z.iso.datetime(), +}) + +export const zCreateReleaseResponse = z.object({ + release: zRelease, + appInstance: zAppInstance, +}) + +export const zDeployment = z.object({ + id: z.string(), + appInstanceId: z.string(), + status: zDeploymentStatus, + action: zDeploymentAction, + environment: zEnvironment, + release: zRelease, + error: zError.optional(), + createdBy: zActor, + createdAt: z.iso.datetime(), + finalizedAt: z.iso.datetime().optional(), +}) + +export const zCancelDeploymentResponse = z.object({ + deployment: zDeployment, +}) + +export const zDeployResponse = z.object({ + appInstance: zAppInstance, + release: zRelease, + deployment: zDeployment, +}) + +/** + * EnvironmentAppInstance is one app instance as seen from a single environment: + * its current release, runtime status, and derived last error in THIS env. + */ +export const zEnvironmentAppInstance = z.object({ + appInstance: zAppInstance.optional(), + currentRelease: zRelease.optional(), + status: zRuntimeInstanceStatus.optional(), + lastError: zError.optional(), + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), +}) + +export const zEnvironmentDeployment = z.object({ + appInstanceId: z.string(), + environment: zEnvironment, + status: zRuntimeInstanceStatus, + currentRelease: zRelease.optional(), + desiredRelease: zRelease.optional(), + currentDeployment: zEnvironmentDeploymentRecord.optional(), + error: zError.optional(), + updatedAt: z.iso.datetime(), + releasesBehind: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +export const zAppInstanceSummary = z.object({ + appInstance: zAppInstance, + environmentDeployments: z.array(zEnvironmentDeployment), + latestRelease: zRelease.optional(), + accessChannels: zAccessChannels, + apiKeySummary: zApiKeySummary, +}) + +export const zComputeReleaseDeploymentViewResponse = z.object({ + releases: z.array(zRelease), + environmentDeployments: z.array(zEnvironmentDeployment), + environmentActions: z.array(zReleaseEnvironmentAction), + options: zDeploymentOptions.optional(), +}) + +/** + * EnvironmentDeploymentHistoryItem is one deployment row in an environment's + * history, with a thin reference to the owning app instance. + */ +export const zEnvironmentDeploymentHistoryItem = z.object({ + deployment: zDeployment.optional(), + appInstanceId: z.string().optional(), + appInstanceName: z.string().optional(), + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), +}) + +export const zGetAppInstanceOverviewResponse = z.object({ + appInstance: zAppInstance, + environmentDeployments: z.array(zEnvironmentDeployment), + recentReleases: z.array(zRelease), + accessChannels: zAccessChannels, + apiKeySummary: zApiKeySummary, + totalReleaseCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), +}) + +export const zGetReleaseResponse = z.object({ + release: zRelease, +}) + +export const zListEnvironmentDeploymentsResponse = z.object({ + environmentDeployments: z.array(zEnvironmentDeployment), +}) + +export const zPromoteResponse = z.object({ + deployment: zDeployment, +}) + +export const zReleaseSummary = z.object({ + release: zRelease, + deployedEnvironments: z.array(zReleaseEnvironmentDeployment), + environmentActions: z.array(zReleaseEnvironmentAction), + activeEnvironmentCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), +}) + +export const zResolveApiTokenRouteRequest = z.object({ + token: z.string().optional(), +}) + +export const zResolveApiTokenRouteResponse = z.object({ + environmentId: z.string().optional(), + namespace: z.string().optional(), + serviceName: z.string().optional(), + servicePort: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentStatus: zEnvironmentStatus.optional(), + appId: z.string().optional(), + tenantId: z.string().optional(), + runtimeInstanceId: z.string().optional(), + observedReleaseId: z.string().optional(), + runtimeInstanceStatus: zRuntimeInstanceStatus.optional(), + revoked: z.boolean().optional(), + unavailableReason: z.string().optional(), + targetKind: zRouteTargetKind.optional(), + directUpstream: z.string().optional(), +}) + +export const zRollbackRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + targetReleaseId: z.string(), + idempotencyKey: z.string(), +}) + +export const zRollbackResponse = z.object({ + deployment: zDeployment, +}) + +export const zRollbackTarget = z.object({ + release: zRelease, + resolvedDeploymentId: z.string(), + deployedAt: z.iso.datetime(), + isCurrent: z.boolean(), +}) + +export const zRunnerInfo = z.object({ + hostname: z.string().optional(), +}) + +export const zBootstrapRunnerRequest = z.object({ + runner: zRunnerInfo.optional(), +}) + +export const zRuntimeArtifact = z.object({ + dslYaml: z.string().optional(), + bindingSnapshotVersion: z.string().optional(), + bindingSnapshot: z.record(z.string(), z.unknown()).optional(), +}) + +export const zRuntimeArtifactRequest = z.object({ + runtimeInstanceId: z.string().optional(), + releaseId: z.string().optional(), + deploymentId: z.string().optional(), + bindingSnapshotVersion: z.string().optional(), +}) + +export const zBatchResolveRuntimeArtifactsRequest = z.object({ + requests: z.array(zRuntimeArtifactRequest).optional(), +}) + +export const zRuntimeArtifactResult = z.object({ + runtimeInstanceId: z.string().optional(), + releaseId: z.string().optional(), + artifact: zRuntimeArtifact.optional(), + error: zError.optional(), + deploymentId: z.string().optional(), +}) + +export const zBatchResolveRuntimeArtifactsResponse = z.object({ + results: z.array(zRuntimeArtifactResult).optional(), +}) + +export const zTestConnectionRequest = z.object({ + environmentId: z.string().optional(), +}) + +export const zTestConnectionResponse = z.object({ + reachable: z.boolean().optional(), + message: z.string().optional(), +}) + +export const zUndeployRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + idempotencyKey: z.string(), +}) + +export const zUndeployResponse = z.object({ + deployment: zDeployment, +}) + +/** + * UnsupportedDslNode identifies a workflow node whose type the app runner + * cannot execute. + */ +export const zUnsupportedDslNode = z.object({ + id: z.string(), + type: z.string(), +}) + +export const zPrecheckReleaseResponse = z.object({ + gateCommitId: z.string(), + canCreate: z.boolean(), + matchedRelease: zReleaseContentMatch.optional(), + unsupportedNodes: z.array(zUnsupportedDslNode), +}) + +export const zUpdateAccessChannelsRequest = z.object({ + appInstanceId: z.string().optional(), + webAppEnabled: z.boolean().optional(), + developerApiEnabled: z.boolean().optional(), +}) + +export const zUpdateAccessChannelsResponse = z.object({ + accessChannels: zAccessChannels, +}) + +export const zUpdateAccessPolicyRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + mode: zAccessMode, + subjects: z.array(zAccessSubject).optional(), +}) + +export const zUpdateAccessPolicyResponse = z.object({ + policy: zAccessPolicy, +}) + +export const zUpdateAppInstanceRequest = z.object({ + appInstanceId: z.string().optional(), + displayName: z.string(), + description: z.string().optional(), +}) + +export const zUpdateAppInstanceResponse = z.object({ + appInstance: zAppInstance, +}) + +export const zUpdateEnvironmentRequest = z.object({ + environmentId: z.string().optional(), + displayName: z.string(), + description: z.string().optional(), +}) + +export const zUpdateEnvironmentResponse = z.object({ + environment: zEnvironment.optional(), +}) + +export const zUpdateReleaseRequest = z.object({ + releaseId: z.string().optional(), + displayName: z.string(), + description: z.string().optional(), +}) + +export const zUpdateReleaseResponse = z.object({ + release: zRelease, +}) + /** * Account represents a basic user account */ @@ -52,7 +1021,7 @@ export const zBrandingInfo = z.object({ export const zCheckPasswordStatusReply = z.object({ requirePasswordChange: z.boolean().optional(), - changeReason: z.int().optional(), + changeReason: zPasswordChangeReason.optional(), daysToExpire: z .int() .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) @@ -178,6 +1147,14 @@ export const zEnterpriseSystemUserSettingReply = z.object({ enableEmailPasswordLogin: z.boolean().optional(), }) +export const zExternallyAccessibleApp = z.object({ + appId: z.string().optional(), + tenantId: z.string().optional(), + mode: z.string().optional(), + name: z.string().optional(), + updatedAt: z.string().optional(), +}) + export const zGetBearerTokenResponse = z.object({ maskedToken: z.string().optional(), }) @@ -228,7 +1205,7 @@ export const zGroupAppItem = z.object({ app_name: z.string().optional(), workspace_id: z.string().optional(), workspace_name: z.string().optional(), - app_status: z.int().optional(), + app_status: zAppStatus.optional(), token_usage: z.string().optional(), rpm: z.string().optional(), concurrency: z.string().optional(), @@ -277,6 +1254,31 @@ export const zInnerIsUserAllowedToAccessWebAppRes = z.object({ result: z.boolean().optional(), }) +export const zInnerListExternallyAccessibleAppsReq = z.object({ + page: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + mode: z.string().optional(), + name: z.string().optional(), +}) + +export const zInnerListExternallyAccessibleAppsRes = z.object({ + data: z.array(zExternallyAccessibleApp).optional(), + total: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + hasMore: z.boolean().optional(), +}) + export const zInnerReleaseAdmissionRequest = z.object({ admission: zInnerAdmission.optional(), }) @@ -314,9 +1316,9 @@ export const zJoinWorkspaceReq = z.object({ }) export const zLimitConfig = z.object({ - type: z.int().optional(), + type: zLimitType.optional(), threshold: z.string().optional(), - action: z.int().optional(), + action: zLimitAction.optional(), reached: z.boolean().optional(), }) @@ -424,7 +1426,7 @@ export const zOidcReply = z.object({ export const zOtelExporterEndpoint = z.object({ endpoint: z.string().optional(), compression: z.string().optional(), - protocol: z.int().optional(), + protocol: z.enum(['HTTP_PROTOBUF', 'HTTP_JSON', 'GRPC']).optional(), timeout: z .string() .regex(/^-?(?:0|[1-9]\d{0,11})(?:\.\d{1,9})?s$/) @@ -439,7 +1441,7 @@ export const zOtelExporterEndpoint = z.object({ }) export const zEndpointReply = z.object({ - mode: z.int().optional(), + mode: zOtelEndpointMode.optional(), metricsEndpoint: zOtelExporterEndpoint.optional(), tracesEndpoint: zOtelExporterEndpoint.optional(), }) @@ -449,7 +1451,7 @@ export const zOtelExporterStatusReply = z.object({ bytesPushed: z.string().optional(), itemsInQueue: z.string().optional(), logs: z.string().optional(), - status: z.int().optional(), + status: z.enum(['RUNNING', 'ERROR', 'STOPPED']).optional(), }) export const zPasswordPolicyConfig = z.object({ @@ -473,7 +1475,7 @@ export const zPasswordPolicyConfig = z.object({ }) export const zPasswordStrengthReply = z.object({ - level: z.int().optional(), + level: zPasswordStrengthLevel.optional(), }) export const zPasswordStrengthReq = z.object({ @@ -486,7 +1488,7 @@ export const zPluginInstallationPermissionInfo = z.object({ }) export const zPluginInstallationSettingsReply = z.object({ - pluginInstallationScope: z.int().optional(), + pluginInstallationScope: zPluginInstallationScope.optional(), restrictToMarketplaceOnly: z.boolean().optional(), }) @@ -534,15 +1536,15 @@ export const zResourceGroupDetail = z.object({ .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - rpm_action: z.int().optional(), + rpm_action: zLimitAction.optional(), concurrency_limit: z .int() .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - concurrency_action: z.int().optional(), + concurrency_action: zLimitAction.optional(), token_quota: z.string().optional(), - token_action: z.int().optional(), + token_action: zLimitAction.optional(), created_at: z.string().optional(), updated_at: z.string().optional(), }) @@ -565,8 +1567,8 @@ export const zResourceGroupItem = z.object({ token_quota: z.string().optional(), token_usage: z.string().optional(), app_count: z.string().optional(), - rpm_status: z.int().optional(), - conc_status: z.int().optional(), + rpm_status: zLimitStatus.optional(), + conc_status: zLimitStatus.optional(), created_at: z.string().optional(), updated_at: z.string().optional(), }) @@ -606,6 +1608,7 @@ export const zLimitFields = z.object({ .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), workspaces: zResourceQuota.optional(), + appRunnerEnvCpus: zResourceQuota.optional(), }) /** @@ -693,7 +1696,7 @@ export const zSearchAppItem = z.object({ app_name: z.string().optional(), workspace_id: z.string().optional(), workspace_name: z.string().optional(), - app_status: z.int().optional(), + app_status: zAppStatus.optional(), icon: z.string().optional(), icon_type: z.string().optional(), icon_background: z.string().optional(), @@ -760,11 +1763,24 @@ export const zGetWebAppWhitelistSubjectsRes = z.object({ */ export const zSubject = z.object({ subjectId: z.string().optional(), - subjectType: z.string().optional(), + subjectType: zSubjectType.optional(), accountData: zSubjectAccountData.optional(), groupData: zSubjectGroupData.optional(), }) +export const zEnvironmentAccessPolicy = z.object({ + environment: zEnvironment, + policy: zAccessPolicy.optional(), + resolvedSubjects: z.array(zSubject), +}) + +export const zGetAccessSettingsResponse = z.object({ + accessChannels: zAccessChannels, + environmentPolicies: z.array(zEnvironmentAccessPolicy), + webAppEndpoints: z.array(zAccessEndpoint).optional(), + cliEndpoint: zAccessEndpoint.optional(), +}) + export const zGetGroupSubjectsRes = z.object({ subjects: z.array(zSubject).optional(), }) @@ -798,6 +1814,72 @@ export const zToggleEndpointRequest = z.object({ enabled: z.boolean().optional(), }) +export const zToggleTraceProviderRequest = z.object({ + id: z.string().optional(), + enabled: z.boolean().optional(), +}) + +/** + * TraceProvider is one configured trace-export destination. Trace data + * collected by the enterprise collector is fanned out to every enabled + * destination. Credentials carries per-provider secret values (e.g. Langfuse + * public/secret keys); Settings carries non-secret options (e.g. host, + * project). Secret credential values are redacted on read and preserved on + * write when the client echoes the redaction sentinel back (same round-trip + * contract as endpoint headers / TLS keys). + */ +export const zTraceProvider = z.object({ + id: z.string().optional(), + name: z.string().optional(), + provider: z.string().optional(), + endpoint: z.string().optional(), + protocol: z.string().optional(), + credentials: z.record(z.string(), z.string()).optional(), + settings: z.record(z.string(), z.string()).optional(), + enabled: z.boolean().optional(), +}) + +/** + * TestTraceProviderRequest tests connectivity/auth for a destination config + * before (or without) persisting it. When credentials carry the redaction + * sentinel, the stored secret for the destination with the same id is used. + */ +export const zTestTraceProviderRequest = z.object({ + provider: zTraceProvider.optional(), +}) + +/** + * TraceProviderField describes one credential or setting field of a provider in + * the static catalog: the key the dashboard sends back, its display label, + * whether it is required, and (for credentials) whether it is a secret that + * gets redacted. + */ +export const zTraceProviderField = z.object({ + key: z.string().optional(), + displayName: z.string().optional(), + required: z.boolean().optional(), + secret: z.boolean().optional(), +}) + +/** + * TraceProviderDescriptor is one entry in the static provider catalog: the + * field definitions and transport defaults for a provider type. The dashboard + * uses it to render the add/edit form and to know which fields are secret. + */ +export const zTraceProviderDescriptor = z.object({ + provider: z.string().optional(), + displayName: z.string().optional(), + credentialFields: z.array(zTraceProviderField).optional(), + settingFields: z.array(zTraceProviderField).optional(), + defaultProtocol: z.string().optional(), + supportedProtocols: z.array(z.string()).optional(), +}) + +export const zListTraceProvidersReply = z.object({ + providers: z.array(zTraceProvider).optional(), + catalog: z.array(zTraceProviderDescriptor).optional(), +}) + export const zUpdateAccessModeReq = z.object({ appId: z.string().optional(), accessMode: z.string().optional(), @@ -894,7 +1976,7 @@ export const zUpdateOfflineLicenseReq = z.object({ }) export const zUpdatePluginInstallationSettingsRequest = z.object({ - pluginInstallationScope: z.int().optional(), + pluginInstallationScope: zPluginInstallationScope.optional(), restrictToMarketplaceOnly: z.boolean().optional(), }) @@ -908,15 +1990,15 @@ export const zUpdateResourceGroupRequest = z.object({ .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - rpm_action: z.int().optional(), + rpm_action: zLimitAction.optional(), concurrency_limit: z .int() .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - concurrency_action: z.int().optional(), + concurrency_action: zLimitAction.optional(), token_quota: z.string().optional(), - token_action: z.int().optional(), + token_action: zLimitAction.optional(), }) export const zUpdateUserReply = z.object({ @@ -963,6 +2045,18 @@ export const zUpdateWorkspaceReq = z.object({ status: z.string().optional(), }) +export const zUpsertTraceProviderReply = z.object({ + provider: zTraceProvider.optional(), +}) + +/** + * UpsertTraceProviderRequest creates a destination when id is empty (a new id + * is allocated) or updates the destination with the given id otherwise. + */ +export const zUpsertTraceProviderRequest = z.object({ + provider: zTraceProvider.optional(), +}) + export const zWebAppAuthInfo = z.object({ allowSso: z.boolean().optional(), allowEmailCodeLogin: z.boolean().optional(), @@ -985,6 +2079,7 @@ export const zInfoConfigReply = z.object({ Branding: zBrandingInfo.optional(), WebAppAuth: zWebAppAuthInfo.optional(), PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(), + EnableAppDeploy: z.boolean().optional(), }) export const zWebOAuth2LoginReply = z.object({ @@ -1058,6 +2153,28 @@ export const zUpdateWorkspacePermissionReq = z.object({ permission: zWorkspacePermission.optional(), }) +/** + * CursorPagination: pagination by cursor token + */ +export const zCursorPagination = z.object({ + pageSize: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + nextCursor: z.string().optional(), + prevCursor: z.string().optional(), + hasNextPage: z.boolean().optional(), + hasPrevPage: z.boolean().optional(), + totalCount: z.string().optional(), +}) + +export const zListAppRunnerLogsResponse = z.object({ + appRunnerLogs: z.array(zAppRunnerLog), + pagination: zCursorPagination, + lastArchived: z.iso.datetime().optional(), +}) + /** * Pagination : Just for pagination by page */ @@ -1084,6 +2201,61 @@ export const zPagination = z.object({ .optional(), }) +export const zDashboardListAppInstancesResponse = z.object({ + appInstances: z.array(zAppInstance), + pagination: zPagination, +}) + +export const zDashboardListEnvironmentDeploymentsResponse = z.object({ + deployments: z.array(zEnvironmentDeploymentHistoryItem).optional(), + pagination: zPagination.optional(), +}) + +export const zListAppInstanceSummariesResponse = z.object({ + appInstanceSummaries: z.array(zAppInstanceSummary), + pagination: zPagination, +}) + +export const zListAppInstancesResponse = z.object({ + appInstances: z.array(zAppInstance), + pagination: zPagination, +}) + +export const zListDeploymentsResponse = z.object({ + deployments: z.array(zDeployment), + pagination: zPagination, +}) + +export const zListEnvironmentAppInstancesResponse = z.object({ + appInstances: z.array(zEnvironmentAppInstance).optional(), + pagination: zPagination.optional(), +}) + +export const zListEnvironmentsResponse = z.object({ + environments: z.array(zEnvironment), + pagination: zPagination, +}) + +export const zListReleaseSummariesResponse = z.object({ + releaseSummaries: z.array(zReleaseSummary), + pagination: zPagination, +}) + +export const zListReleasesResponse = z.object({ + releases: z.array(zRelease), + pagination: zPagination, +}) + +export const zListRollbackTargetsResponse = z.object({ + rollbackTargets: z.array(zRollbackTarget), + pagination: zPagination, +}) + +export const zListAccessSubjectsReply = z.object({ + subjects: z.array(zSubject).optional(), + pagination: zPagination.optional(), +}) + export const zListMembersReply = z.object({ data: z.array(zAccountDetail).optional(), pagination: zPagination.optional(), @@ -1104,6 +2276,469 @@ export const zListWorkspacesReply = z.object({ pagination: zPagination.optional(), }) +export const zAccessSubjectServiceListAccessSubjectsQuery = z.object({ + keyword: z.string().optional(), + groupId: z.string().optional(), + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +/** + * OK + */ +export const zAccessSubjectServiceListAccessSubjectsResponse = zListAccessSubjectsReply + +export const zAppInstanceServiceListAppInstanceSummariesQuery = z.object({ + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + displayName: z.string().optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zAppInstanceServiceListAppInstanceSummariesResponse = zListAppInstanceSummariesResponse + +export const zAppInstanceServiceListAppInstancesQuery = z.object({ + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + displayName: z.string().optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zAppInstanceServiceListAppInstancesResponse = zListAppInstancesResponse + +export const zAppInstanceServiceCreateAppInstanceBody = zCreateAppInstanceRequest + +/** + * OK + */ +export const zAppInstanceServiceCreateAppInstanceResponse = zCreateAppInstanceResponse + +export const zAppInstanceServiceDeleteAppInstancePath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceDeleteAppInstanceResponse = zDeleteAppInstanceResponse + +export const zAppInstanceServiceGetAppInstancePath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceGetAppInstanceResponse = zGetAppInstanceResponse + +export const zAppInstanceServiceUpdateAppInstanceBody = zUpdateAppInstanceRequest + +export const zAppInstanceServiceUpdateAppInstancePath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceUpdateAppInstanceResponse = zUpdateAppInstanceResponse + +export const zAccessServiceGetAccessChannelsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetAccessChannelsResponse = zGetAccessChannelsResponse + +export const zAccessServiceUpdateAccessChannelsBody = zUpdateAccessChannelsRequest + +export const zAccessServiceUpdateAccessChannelsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceUpdateAccessChannelsResponse = zUpdateAccessChannelsResponse + +export const zAccessServiceGetAccessSettingsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetAccessSettingsResponse = zGetAccessSettingsResponse + +export const zDeploymentServiceListDeploymentsPath = z.object({ + appInstanceId: z.string(), +}) + +export const zDeploymentServiceListDeploymentsQuery = z.object({ + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zDeploymentServiceListDeploymentsResponse = zListDeploymentsResponse + +export const zAccessServiceGetDeveloperApiSettingsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetDeveloperApiSettingsResponse = zGetDeveloperApiSettingsResponse + +export const zDeploymentServiceListEnvironmentDeploymentsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceListEnvironmentDeploymentsResponse + = zListEnvironmentDeploymentsResponse + +export const zAccessServiceGetAccessPolicyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetAccessPolicyResponse = zGetAccessPolicyResponse + +export const zAccessServiceUpdateAccessPolicyBody = zUpdateAccessPolicyRequest + +export const zAccessServiceUpdateAccessPolicyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceUpdateAccessPolicyResponse = zUpdateAccessPolicyResponse + +export const zAccessServiceListApiKeysPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceListApiKeysResponse = zListApiKeysResponse + +export const zAccessServiceCreateApiKeyBody = zCreateApiKeyRequest + +export const zAccessServiceCreateApiKeyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceCreateApiKeyResponse = zCreateApiKeyResponse + +export const zAccessServiceDeleteApiKeyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), + apiKeyId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceDeleteApiKeyResponse = zDeleteApiKeyResponse + +export const zDeploymentServiceListRollbackTargetsPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +export const zDeploymentServiceListRollbackTargetsQuery = z.object({ + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +/** + * OK + */ +export const zDeploymentServiceListRollbackTargetsResponse = zListRollbackTargetsResponse + +export const zDeploymentServiceCancelDeploymentBody = zCancelDeploymentRequest + +export const zDeploymentServiceCancelDeploymentPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceCancelDeploymentResponse = zCancelDeploymentResponse + +export const zDeploymentServicePromoteBody = zPromoteRequest + +export const zDeploymentServicePromotePath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServicePromoteResponse = zPromoteResponse + +export const zDeploymentServiceRollbackBody = zRollbackRequest + +export const zDeploymentServiceRollbackPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceRollbackResponse = zRollbackResponse + +export const zDeploymentServiceUndeployBody = zUndeployRequest + +export const zDeploymentServiceUndeployPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceUndeployResponse = zUndeployResponse + +export const zReleaseServiceListReleaseSummariesPath = z.object({ + appInstanceId: z.string(), +}) + +export const zReleaseServiceListReleaseSummariesQuery = z.object({ + releaseId: z.string().optional(), + displayName: z.string().optional(), + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zReleaseServiceListReleaseSummariesResponse = zListReleaseSummariesResponse + +export const zReleaseServiceListReleasesPath = z.object({ + appInstanceId: z.string(), +}) + +export const zReleaseServiceListReleasesQuery = z.object({ + releaseId: z.string().optional(), + displayName: z.string().optional(), + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zReleaseServiceListReleasesResponse = zListReleasesResponse + +export const zReleaseServiceComputeReleaseDeploymentViewPath = z.object({ + appInstanceId: z.string(), +}) + +export const zReleaseServiceComputeReleaseDeploymentViewQuery = z.object({ + releaseId: z.string().optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zReleaseServiceComputeReleaseDeploymentViewResponse + = zComputeReleaseDeploymentViewResponse + +export const zAppInstanceServiceGetAppInstanceOverviewPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceGetAppInstanceOverviewResponse = zGetAppInstanceOverviewResponse + +export const zDeploymentServiceDeployBody = zDeployRequest + +/** + * OK + */ +export const zDeploymentServiceDeployResponse = zDeployResponse + +export const zEnvironmentServiceListEnvironmentsQuery = z.object({ + environmentId: z.string().optional(), + displayName: z.string().optional(), + pageNumber: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + resultsPerPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +/** + * OK + */ +export const zEnvironmentServiceListEnvironmentsResponse = zListEnvironmentsResponse + +export const zReleaseServiceCreateReleaseBody = zCreateReleaseRequest + +/** + * OK + */ +export const zReleaseServiceCreateReleaseResponse = zCreateReleaseResponse + +export const zReleaseServiceDeleteReleasePath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceDeleteReleaseResponse = zDeleteReleaseResponse + +export const zReleaseServiceGetReleasePath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceGetReleaseResponse = zGetReleaseResponse + +export const zReleaseServiceUpdateReleaseBody = zUpdateReleaseRequest + +export const zReleaseServiceUpdateReleasePath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceUpdateReleaseResponse = zUpdateReleaseResponse + +export const zReleaseServiceExportReleaseDslPath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceExportReleaseDslResponse = zExportReleaseDslResponse + +export const zReleaseServiceListReleaseCredentialCandidatesPath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceListReleaseCredentialCandidatesResponse + = zListReleaseCredentialCandidatesResponse + +export const zReleaseServiceComputeDeploymentOptionsBody = zComputeDeploymentOptionsRequest + +/** + * OK + */ +export const zReleaseServiceComputeDeploymentOptionsResponse = zComputeDeploymentOptionsResponse + +export const zReleaseServicePrecheckReleaseBody = zPrecheckReleaseRequest + +/** + * OK + */ +export const zReleaseServicePrecheckReleaseResponse = zPrecheckReleaseResponse + /** * OK */ diff --git a/packages/contracts/openapi-ts.enterprise.config.ts b/packages/contracts/openapi-ts.enterprise.config.ts index 3c9bc903ab..aae8dcb215 100644 --- a/packages/contracts/openapi-ts.enterprise.config.ts +++ b/packages/contracts/openapi-ts.enterprise.config.ts @@ -7,9 +7,36 @@ import yaml from 'js-yaml' type JsonObject = Record type OpenApiDocument = JsonObject & { + components?: OpenApiComponents paths?: Record } +type OpenApiComponents = JsonObject & { + schemas?: Record +} + +type OpenApiMediaType = JsonObject & { + schema?: unknown +} + +type OpenApiOperation = JsonObject & { + operationId?: string + responses?: Record +} + +type OpenApiPathItem = Record + +type OpenApiResponse = JsonObject & { + content?: Record +} + +type OpenApiSchema = JsonObject & { + enum?: unknown[] + format?: string + properties?: Record + type?: string | string[] +} + type ContractOperation = { id: string operationId?: string @@ -21,9 +48,30 @@ const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER ? path.resolve(process.env.DIFY_ENTERPRISE_SERVER) : path.resolve(currentDir, '../../../dify-enterprise/server') const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml') +const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/') +const isObject = (value: unknown): value is JsonObject => { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +const isOpenApiSchema = (value: unknown): value is OpenApiSchema => { + return isObject(value) +} + +const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => { + return isObject(value) ? value as OpenApiOperation : undefined +} + +const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => { + return isObject(value) ? value as OpenApiResponse : undefined +} + +const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => { + return isObject(value) ? value as OpenApiMediaType : undefined +} + const stripConsoleApiPrefix = (routePath: string) => { if (isConsoleApiPath(routePath)) return routePath.replace('/console/api', '') @@ -34,9 +82,18 @@ const stripConsoleApiPrefix = (routePath: string) => { const stripSchemaNamePrefix = (schemaName: string) => { return schemaName .replace(/^dify\.enterprise\.api\.enterprise\./, '') + .replace(/^dify\.enterprise\.api\.appdeploy\.v1\./, '') + .replace(/^dify\.enterprise\.api\.appdeploy\./, '') .replace(/^pagination\./, '') } +const contractTagSegment = (tag?: string) => { + if (tag === 'EnterpriseAppDeployConsole') + return 'AppDeploy' + + return tag || 'default' +} + const contractNameSegments = (operation: ContractOperation) => { const operationId = operation.operationId || operation.id const tag = operation.tags?.[0] @@ -48,7 +105,165 @@ const contractNameSegments = (operation: ContractOperation) => { } const contractPathSegments = (operation: ContractOperation) => { - return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)] + return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)] +} + +const hasSchemaLessResponseContent = (operation: OpenApiOperation) => { + if (!isObject(operation.responses)) + return false + + return Object.values(operation.responses).some((response) => { + const openApiResponse = asOpenApiResponse(response) + if (!openApiResponse || !isObject(openApiResponse.content)) + return false + + return Object.values(openApiResponse.content).some((mediaType) => { + const openApiMediaType = asOpenApiMediaType(mediaType) + return !!openApiMediaType && !('schema' in openApiMediaType) + }) + }) +} + +// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these +// raw download operations until the source OpenAPI exposes an explicit schema. +const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => { + return Object.fromEntries( + Object.entries(pathItem).filter(([method, operation]) => { + if (!operationMethods.has(method.toLowerCase())) + return true + + const openApiOperation = asOpenApiOperation(operation) + return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation) + }), + ) +} + +const toWords = (value: string) => { + return value + .replace(/[{}]/g, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[^a-z0-9]+/i) + .filter(Boolean) +} + +const toPascalCase = (words: string[]) => { + return words.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('') +} + +const commonWordPrefix = (values: string[]) => { + const wordLists = values.map(value => value.split('_')) + const firstWords = wordLists[0] ?? [] + const prefix: string[] = [] + + for (const [index, word] of firstWords.entries()) { + if (!wordLists.every(words => words[index] === word)) + break + + prefix.push(word) + } + + return prefix +} + +const enumSchemaNameFromValues = (values: unknown[]) => { + if (values.length === 0 || !values.every(value => typeof value === 'string')) + return undefined + + const prefix = commonWordPrefix(values) + if (prefix.length < 2) + return undefined + + return toPascalCase(prefix.map(word => word.toLowerCase())) +} + +const findSchemaEntry = ( + schemas: Record, + schemaName: string, +): [string, OpenApiSchema] | undefined => { + return Object.entries(schemas) + .find(([name]) => stripSchemaNamePrefix(name) === schemaName) +} + +const enumValuesKey = (values: unknown[]) => JSON.stringify(values) + +const reusableEnumSchema = (propertySchema: OpenApiSchema): OpenApiSchema => ({ + ...(propertySchema.format ? { format: propertySchema.format } : {}), + enum: propertySchema.enum, + type: propertySchema.type ?? 'string', +}) + +const enumSchemaKey = ( + schemas: Record, + preferredName: string, + valuesKey: string, + valuesToSchemaKey: Map, + schemaName: string, + propertyName: string, +) => { + const existingKey = valuesToSchemaKey.get(valuesKey) + if (existingKey) + return existingKey + + const existingEnumEntry = findSchemaEntry(schemas, preferredName) + if (!existingEnumEntry) + return preferredName + + const existingEnumValues = existingEnumEntry[1].enum + if (Array.isArray(existingEnumValues) && enumValuesKey(existingEnumValues) === valuesKey) + return existingEnumEntry[0] + + return `${stripSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}` +} + +const promoteInlineEnumSchema = ( + schemas: Record, + schemaName: string, + properties: Record, + propertyName: string, + propertySchema: OpenApiSchema, + valuesToSchemaKey: Map, +) => { + if (!Array.isArray(propertySchema.enum)) + return + + const preferredName = enumSchemaNameFromValues(propertySchema.enum) + if (!preferredName) + return + + const valuesKey = enumValuesKey(propertySchema.enum) + const key = enumSchemaKey(schemas, preferredName, valuesKey, valuesToSchemaKey, schemaName, propertyName) + + if (!schemas[key]) + schemas[key] = reusableEnumSchema(propertySchema) + + valuesToSchemaKey.set(valuesKey, key) + properties[propertyName] = { + $ref: `#/components/schemas/${key}`, + } +} + +// gnostic's protoc-gen-openapi inlines proto enum schemas into every field. +// Promote prefixable inline enums to reusable schemas so Hey API can emit +// runtime enum objects from the generated contract. +const promoteReusableEnumSchemasForHeyApi = (document: OpenApiDocument) => { + const schemas = document.components?.schemas + if (!schemas) + return + + const valuesToSchemaKey = new Map() + + Object.entries(schemas).forEach(([schemaName, schema]) => { + const properties = schema.properties + if (!properties) + return + + Object.entries(properties).forEach(([propertyName, propertySchema]) => { + if (!isOpenApiSchema(propertySchema)) + return + + promoteInlineEnumSchema(schemas, schemaName, properties, propertyName, propertySchema, valuesToSchemaKey) + }) + }) } const normalizeEnterpriseOpenApi = () => { @@ -63,9 +278,17 @@ const normalizeEnterpriseOpenApi = () => { document.paths = Object.fromEntries( Object.entries(paths) .filter(([routePath]) => isConsoleApiPath(routePath)) - .map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]), + .map(([routePath, pathItem]) => { + if (!isObject(pathItem)) + return [stripConsoleApiPrefix(routePath), pathItem] + + return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)] + }) + .filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0), ) + promoteReusableEnumSchemasForHeyApi(document) + return document } @@ -97,6 +320,9 @@ export default defineConfig({ { name: '@hey-api/typescript', comments: false, + enums: { + mode: 'javascript', + }, }, 'zod', { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d189883bd0..3932cb6d87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ catalogs: '@tanstack/eslint-plugin-query': specifier: 5.101.0 version: 5.101.0 + '@tanstack/query-core': + specifier: 5.101.0 + version: 5.101.0 '@tanstack/react-form': specifier: 1.33.0 version: 1.33.0 @@ -393,6 +396,12 @@ catalogs: jotai: specifier: 2.20.1 version: 2.20.1 + jotai-scope: + specifier: 0.11.0 + version: 0.11.0 + jotai-tanstack-query: + specifier: 0.11.0 + version: 0.11.0 js-audio-recorder: specifier: 1.0.7 version: 1.0.7 @@ -1096,6 +1105,9 @@ importers: '@tailwindcss/typography': specifier: 'catalog:' version: 0.5.20(tailwindcss@4.3.1) + '@tanstack/query-core': + specifier: 'catalog:' + version: 5.101.0 '@tanstack/react-form': specifier: 'catalog:' version: 1.33.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1189,6 +1201,12 @@ importers: jotai: specifier: 'catalog:' version: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) + jotai-scope: + specifier: 'catalog:' + version: 0.11.0(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + jotai-tanstack-query: + specifier: 'catalog:' + version: 0.11.0(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) js-audio-recorder: specifier: 'catalog:' version: 1.0.7 @@ -7198,6 +7216,26 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jotai-scope@0.11.0: + resolution: {integrity: sha512-ofiW0Z0i3lTw509Gx0+T6fqsDPMDxMn+AHmNs9iF9OA8CmK1/0xRprPxuZ89UZdBzt6jcrTYdunNZSF2255zJQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + jotai: '>=2.20.0' + react: '>=16.0.0' + + jotai-tanstack-query@0.11.0: + resolution: {integrity: sha512-Ys0u0IuuS6/okUJOulFTdCVfVaeKbm1+lKVSN9zHhIxtrAXl9FM4yu7fNvxM6fSz/NCE9tZOKR0MQ3hvplaH8A==} + peerDependencies: + '@tanstack/query-core': '*' + '@tanstack/react-query': '*' + jotai: '>=2.0.0' + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-query': + optional: true + react: + optional: true + jotai@2.20.1: resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==} engines: {node: '>=12.20.0'} @@ -16182,6 +16220,19 @@ snapshots: jiti@2.7.0: {} + jotai-scope@0.11.0(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + jotai: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + + jotai-tanstack-query@0.11.0(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + '@tanstack/query-core': 5.101.0 + jotai: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@tanstack/react-query': 5.101.0(react@19.2.7) + react: 19.2.7 + jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7): optionalDependencies: '@babel/core': 7.29.7 @@ -19321,6 +19372,7 @@ time: '@tailwindcss/typography@0.5.20': '2026-06-08T10:34:41.124Z' '@tailwindcss/vite@4.3.1': '2026-06-12T17:59:45.667Z' '@tanstack/eslint-plugin-query@5.101.0': '2026-06-02T19:24:31.866Z' + '@tanstack/query-core@5.101.0': '2026-06-02T19:24:32.202Z' '@tanstack/react-form@1.33.0': '2026-05-28T17:05:42.660Z' '@tanstack/react-hotkeys@0.10.0': '2026-04-25T12:28:06.989Z' '@tanstack/react-query@5.101.0': '2026-06-02T19:24:39.383Z' @@ -19397,6 +19449,8 @@ time: i18next@26.3.1: '2026-06-03T14:14:17.016Z' iconify-import-svg@0.2.0: '2026-04-20T06:18:25.132Z' immer@11.1.8: '2026-05-08T15:09:33.021Z' + jotai-scope@0.11.0: '2026-05-13T18:43:15.331Z' + jotai-tanstack-query@0.11.0: '2025-08-01T02:55:49.826Z' jotai@2.20.1: '2026-06-11T06:30:45.782Z' js-audio-recorder@1.0.7: '2021-01-09T10:20:49.923Z' js-cookie@3.0.8: '2026-05-29T10:51:39.065Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 313f2102eb..8aaafed726 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -106,6 +106,7 @@ catalog: '@tailwindcss/typography': 0.5.20 '@tailwindcss/vite': 4.3.1 '@tanstack/eslint-plugin-query': 5.101.0 + '@tanstack/query-core': 5.101.0 '@tanstack/react-form': 1.33.0 '@tanstack/react-hotkeys': 0.10.0 '@tanstack/react-query': 5.101.0 @@ -181,6 +182,8 @@ catalog: iconify-import-svg: 0.2.0 immer: 11.1.8 jotai: 2.20.1 + jotai-scope: 0.11.0 + jotai-tanstack-query: 0.11.0 js-audio-recorder: 1.0.7 js-cookie: 3.0.8 js-yaml: 4.2.0 diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx index e1284bfc5b..415b48c5ca 100644 --- a/web/__tests__/app/app-access-control-flow.test.tsx +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import AppPublisher from '@/app/components/app/app-publisher' +import { AppPublisher } from '@/app/components/app/app-publisher' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' @@ -89,7 +89,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () })) vi.mock('@/app/components/app/app-access-control', () => ({ - default: ({ + AccessControl: ({ onConfirm, onClose, }: { diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index d598051f72..593211ce9d 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -1,12 +1,11 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import AppPublisher from '@/app/components/app/app-publisher' +import { AppPublisher } from '@/app/components/app/app-publisher' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' const mockTrackEvent = vi.fn() -const mockRefetch = vi.fn() const mockFetchInstalledAppList = vi.fn() const mockFetchAppDetailDirect = vi.fn() const mockToastError = vi.fn() @@ -64,7 +63,6 @@ vi.mock('@/service/access-control', () => ({ useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false, - refetch: mockRefetch, }), useAppWhiteListSubjects: () => ({ data: { groups: [], members: [] }, @@ -115,7 +113,7 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () })) vi.mock('@/app/components/app/app-access-control', () => ({ - default: () =>
, + AccessControl: () =>
, })) vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) @@ -182,8 +180,6 @@ describe('App Publisher Flow', () => { app_name: 'Demo App', })) }) - - expect(mockRefetch).toHaveBeenCalled() }) it('opens embedded modal and resolves the installed explore target', async () => { diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 5702755a5c..67bd892b4e 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -13,7 +13,7 @@ import type { App } from '@/types/app' import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import AppCard from '@/app/components/apps/app-card' +import { AppCard } from '@/app/components/apps/app-card' import { AccessMode } from '@/models/access-control' import { exportAppConfig, updateAppInfo } from '@/service/apps' import { AppModeEnum } from '@/types/app' @@ -64,10 +64,10 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { }) vi.mock('@/next/dynamic', () => ({ - default: (loader: () => Promise<{ default: React.ComponentType }>) => { + default: (loader: () => Promise) => { let Component: React.ComponentType> | null = null loader().then((mod) => { - Component = mod.default as React.ComponentType> + Component = (typeof mod === 'function' ? mod : mod.default) as React.ComponentType> }).catch(() => {}) const Wrapper = (props: Record) => { if (Component) @@ -201,7 +201,7 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ })) vi.mock('@/app/components/app/app-access-control', () => ({ - default: ({ onConfirm, onClose }: Record) => ( + AccessControl: ({ onConfirm, onClose }: Record) => (
diff --git a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx index 67b50f9409..f26723d019 100644 --- a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx +++ b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx @@ -1,13 +1,16 @@ +import type { ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import RoleRouteGuard from '../role-route-guard' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { RoleRouteGuard } from '../role-route-guard' const mocks = vi.hoisted(() => ({ redirect: vi.fn((url: string) => { throw new Error(`NEXT_REDIRECT:${url}`) }), currentWorkspaceQueryOptions: vi.fn(() => ({ queryKey: ['console', 'workspaces', 'current', 'post'] })), + systemFeaturesQueryKey: vi.fn(() => ['console', 'systemFeatures', 'get']), })) let mockPathname = '/apps' @@ -27,6 +30,11 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { vi.mock('@/service/client', () => ({ consoleQuery: { + systemFeatures: { + get: { + queryKey: mocks.systemFeaturesQueryKey, + }, + }, workspaces: { current: { post: { @@ -39,6 +47,19 @@ vi.mock('@/service/client', () => ({ const mockUseQuery = vi.mocked(useQuery) +function renderGuard(children: ReactNode) { + return renderWithSystemFeatures( + + {children} + , + { + systemFeatures: { + enable_app_deploy: true, + }, + }, + ) +} + const setCurrentWorkspaceQuery = (overrides: { role?: string, isPending?: boolean } = {}) => { mockUseQuery.mockReturnValue({ data: overrides.role, @@ -56,11 +77,7 @@ describe('RoleRouteGuard', () => { it('should render loading while workspace is loading', () => { setCurrentWorkspaceQuery({ isPending: true }) - render(( - -
content
-
- )) + renderGuard(
content
) expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.queryByText('content')).not.toBeInTheDocument() @@ -73,11 +90,16 @@ describe('RoleRouteGuard', () => { it('should redirect dataset operator on guarded routes', () => { setCurrentWorkspaceQuery({ role: 'dataset_operator' }) - expect(() => render(( - -
content
-
- ))).toThrow('NEXT_REDIRECT:/datasets') + expect(() => renderGuard(
content
)).toThrow('NEXT_REDIRECT:/datasets') + + expect(mocks.redirect).toHaveBeenCalledWith('/datasets') + }) + + it('should redirect dataset operator on deployments routes', () => { + mockPathname = '/deployments/create' + setCurrentWorkspaceQuery({ role: 'dataset_operator' }) + + expect(() => renderGuard(
content
)).toThrow('NEXT_REDIRECT:/datasets') expect(mocks.redirect).toHaveBeenCalledWith('/datasets') }) @@ -86,11 +108,7 @@ describe('RoleRouteGuard', () => { mockPathname = '/plugins' setCurrentWorkspaceQuery({ role: 'dataset_operator' }) - render(( - -
content
-
- )) + renderGuard(
content
) expect(screen.getByText('content')).toBeInTheDocument() expect(mocks.redirect).not.toHaveBeenCalled() @@ -100,11 +118,7 @@ describe('RoleRouteGuard', () => { mockPathname = '/plugins' setCurrentWorkspaceQuery({ isPending: true }) - render(( - -
content
-
- )) + renderGuard(
content
) expect(screen.getByText('content')).toBeInTheDocument() expect(screen.queryByRole('status')).not.toBeInTheDocument() diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx new file mode 100644 index 0000000000..5fa2ff4225 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx @@ -0,0 +1,8 @@ +import { AccessTab } from '@/features/deployments/detail/access-tab' + +export default async function InstanceDetailAccessPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx new file mode 100644 index 0000000000..6fb643008a --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx @@ -0,0 +1,8 @@ +import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab' + +export default async function InstanceDetailApiTokensPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx new file mode 100644 index 0000000000..d51bcfd589 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx @@ -0,0 +1,8 @@ +import { DeployTab } from '@/features/deployments/detail/deploy-tab' + +export default async function InstanceDetailInstancesPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx new file mode 100644 index 0000000000..4e39b06620 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' +import { InstanceDetail } from '@/features/deployments/detail' + +export default async function InstanceDetailLayout({ children, params }: { + children: ReactNode + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + + return ( + + {children} + + ) +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx new file mode 100644 index 0000000000..7362d075a2 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx @@ -0,0 +1,8 @@ +import { OverviewTab } from '@/features/deployments/detail/overview-tab' + +export default async function InstanceDetailOverviewPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx new file mode 100644 index 0000000000..b0ae57a3a8 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from '@/next/navigation' + +export default async function InstanceDetailPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + redirect(`/deployments/${appInstanceId}/overview`) +} diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx new file mode 100644 index 0000000000..ee5e689cc6 --- /dev/null +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx @@ -0,0 +1,8 @@ +import { VersionsTab } from '@/features/deployments/detail/versions-tab' + +export default async function InstanceDetailReleasesPage({ params }: { + params: Promise<{ appInstanceId: string }> +}) { + const { appInstanceId } = await params + return +} diff --git a/web/app/(commonLayout)/deployments/create/page.tsx b/web/app/(commonLayout)/deployments/create/page.tsx new file mode 100644 index 0000000000..1e85fbdffa --- /dev/null +++ b/web/app/(commonLayout)/deployments/create/page.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useTranslation } from 'react-i18next' +import { CreateDeploymentGuide } from '@/features/deployments/create-guide' +import useDocumentTitle from '@/hooks/use-document-title' + +export default function CreateDeploymentPage() { + const { t } = useTranslation('deployments') + useDocumentTitle(t('documentTitle.create')) + + return +} diff --git a/web/app/(commonLayout)/deployments/layout.tsx b/web/app/(commonLayout)/deployments/layout.tsx new file mode 100644 index 0000000000..eb52244477 --- /dev/null +++ b/web/app/(commonLayout)/deployments/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' +import { DeployDrawer } from '@/features/deployments/deploy-drawer' + +export default function DeploymentsLayout({ children }: { + children: ReactNode +}) { + return ( + <> + {children} + + + ) +} diff --git a/web/app/(commonLayout)/deployments/page.tsx b/web/app/(commonLayout)/deployments/page.tsx new file mode 100644 index 0000000000..753f0a1837 --- /dev/null +++ b/web/app/(commonLayout)/deployments/page.tsx @@ -0,0 +1,10 @@ +'use client' +import { useTranslation } from 'react-i18next' +import { DeploymentsList } from '@/features/deployments/list' +import useDocumentTitle from '@/hooks/use-document-title' + +export default function DeploymentsPage() { + const { t } = useTranslation('deployments') + useDocumentTitle(t('documentTitle.list')) + return +} diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index fa0ce21bbb..4d6bdf7984 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -16,9 +16,9 @@ import { ModalContextProvider } from '@/context/modal-context-provider' import { ProviderContextProvider } from '@/context/provider-context-provider' import PartnerStack from '../components/billing/partner-stack' import { CommonLayoutHydrationBoundary } from './hydration-boundary' -import RoleRouteGuard from './role-route-guard' +import { RoleRouteGuard } from './role-route-guard' -const Layout = async ({ children }: { children: ReactNode }) => { +export default async function Layout({ children }: { children: ReactNode }) { return ( <> @@ -49,4 +49,3 @@ const Layout = async ({ children }: { children: ReactNode }) => { ) } -export default Layout diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx index 482a13c5d2..05cf59aee4 100644 --- a/web/app/(commonLayout)/role-route-guard.tsx +++ b/web/app/(commonLayout)/role-route-guard.tsx @@ -1,28 +1,38 @@ 'use client' import type { ReactNode } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import Loading from '@/app/components/base/loading' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { redirect, usePathname } from '@/next/navigation' import { consoleQuery } from '@/service/client' -const datasetOperatorRedirectRoutes = ['/', '/apps', '/app', '/snippets', '/explore', '/tools', '/integrations'] as const +const datasetOperatorRedirectRoutes = ['/', '/apps', '/app', '/deployments', '/snippets', '/explore', '/tools', '/integrations'] as const -const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`) +function isPathUnderRoute(pathname: string, route: string) { + return pathname === route || pathname.startsWith(`${route}/`) +} -export default function RoleRouteGuard({ children }: { children: ReactNode }) { +export function RoleRouteGuard({ children }: { children: ReactNode }) { const currentWorkspaceRoleQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({ select: workspace => workspace.role, })) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const pathname = usePathname() const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route)) - const shouldRedirect = shouldGuardRoute && !currentWorkspaceRoleQuery.isPending && currentWorkspaceRoleQuery.data === 'dataset_operator' + const shouldRedirectDatasetOperator = shouldGuardRoute + && !currentWorkspaceRoleQuery.isPending + && currentWorkspaceRoleQuery.data === 'dataset_operator' + const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy // Block rendering only for guarded routes to avoid permission flicker. if (shouldGuardRoute && currentWorkspaceRoleQuery.isPending) return - if (shouldRedirect) + if (shouldRedirectAppDeploy) + redirect('/apps') + + if (shouldRedirectDatasetOperator) redirect('/datasets') return <>{children} diff --git a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx index 03a35bd52a..acbdf1281e 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import AccessControlDialog from '../access-control-dialog' +import { AccessControlDialog } from '../access-control-dialog' describe('AccessControlDialog', () => { it('should render dialog content when visible', () => { diff --git a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx index b1a862a13c..0972a65475 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx @@ -1,45 +1,43 @@ import { fireEvent, render, screen } from '@testing-library/react' -import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' -import AccessControlItem from '../access-control-item' +import { AccessControlItem } from '../access-control-item' +import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness' +import { createAccessControlDraftHarness } from './access-control-test-utils' describe('AccessControlItem', () => { beforeEach(() => { vi.clearAllMocks() - useAccessControlStore.setState({ - appId: '', - specificGroups: [], - specificMembers: [], - currentMenu: AccessMode.PUBLIC, - selectedGroupsForBreadcrumb: [], - }) }) it('should update current menu when selecting a different access type', () => { - render( - - Organization Only - , + const harness = createAccessControlDraftHarness( + + + Organization Only + + , + { currentMenu: AccessMode.PUBLIC }, ) + render(harness.element) - const option = screen.getByText('Organization Only').parentElement as HTMLElement + const option = screen.getByRole('radio', { name: 'Organization Only' }) fireEvent.click(option) - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) + expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION) }) it('should keep the selected state for the active access type', () => { - useAccessControlStore.setState({ - currentMenu: AccessMode.ORGANIZATION, - }) - - render( - - Organization Only - , + const harness = createAccessControlDraftHarness( + + + Organization Only + + , + { currentMenu: AccessMode.ORGANIZATION }, ) + render(harness.element) - const option = screen.getByText('Organization Only').parentElement as HTMLElement - expect(option).toHaveClass('border-components-option-card-option-selected-border') + const option = screen.getByRole('radio', { name: 'Organization Only' }) + expect(option).toHaveAttribute('data-checked') }) }) diff --git a/web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx b/web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx new file mode 100644 index 0000000000..8dc6cbd75c --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/access-control-radio-group-harness.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' +import type { AccessMode } from '@/models/access-control' +import { RadioGroup } from '@langgenius/dify-ui/radio-group' +import { useAccessControlStore } from '../store' + +export function AccessControlRadioGroupHarness({ children }: { + children: ReactNode +}) { + const currentMenu = useAccessControlStore(state => state.currentMenu) + const setCurrentMenu = useAccessControlStore(state => state.setCurrentMenu) + + return ( + value={currentMenu} onValueChange={setCurrentMenu}> + {children} + + ) +} diff --git a/web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts b/web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts new file mode 100644 index 0000000000..2a6f27b833 --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/access-control-test-utils.ts @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react' +import type { AccessControlDraft, AccessControlStore } from '../store' +import { createElement } from 'react' +import { AccessMode } from '@/models/access-control' +import { useAccessControlStore } from '../store' +import { AccessControlDraftProvider } from '../store-provider' + +const emptyDraft = { + appId: '', + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + specificGroups: [], + specificMembers: [], + selectedGroupsForBreadcrumb: [], +} satisfies Required + +function draftKey(draft: AccessControlDraft) { + return [ + draft.appId ?? '', + draft.currentMenu, + draft.specificGroups?.map(group => group.id).join(',') ?? '', + draft.specificMembers?.map(member => member.id).join(',') ?? '', + draft.selectedGroupsForBreadcrumb?.map(group => group.id).join(',') ?? '', + ].join(':') +} + +function completeDraft(initialDraft: Partial = {}): Required { + return { + ...emptyDraft, + ...initialDraft, + } +} + +function SnapshotProbe({ onSnapshot }: { + onSnapshot: (snapshot: AccessControlStore) => void +}) { + onSnapshot(useAccessControlStore(state => state)) + return null +} + +export function createAccessControlDraftHarness( + children: ReactNode, + initialDraft?: Partial, +) { + const draft = completeDraft(initialDraft) + let snapshot: AccessControlStore = { + appId: draft.appId, + specificGroups: draft.specificGroups, + setSpecificGroups: () => undefined, + specificMembers: draft.specificMembers, + setSpecificMembers: () => undefined, + currentMenu: draft.currentMenu, + setCurrentMenu: () => undefined, + selectedGroupsForBreadcrumb: draft.selectedGroupsForBreadcrumb, + setSelectedGroupsForBreadcrumb: () => undefined, + } + + return { + element: createElement( + AccessControlDraftProvider, + { + draftKey: draftKey(draft), + initialDraft: draft, + }, + createElement(SnapshotProbe, { + onSnapshot: nextSnapshot => snapshot = nextSnapshot, + }), + children, + ), + getSnapshot: () => snapshot, + } +} diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 52c2a0dd54..1a8a181f68 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -1,24 +1,23 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import type { App } from '@/types/app' +import { SubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' -import useAccessControlStore from '@/context/access-control-store' import { AccessMode, SubjectType } from '@/models/access-control' -import AccessControlDialog from '../access-control-dialog' -import AccessControlItem from '../access-control-item' -import AddMemberOrGroupDialog from '../add-member-or-group-pop' -import AccessControl from '../index' -import SpecificGroupsOrMembers from '../specific-groups-or-members' +import { AccessControlDialog } from '../access-control-dialog' +import { AccessControlItem } from '../access-control-item' +import { AddMemberOrGroupDialog } from '../add-member-or-group-pop' +import { AccessControl } from '../index' +import { SpecificGroupsOrMembers } from '../specific-groups-or-members' +import { AccessControlRadioGroupHarness } from './access-control-radio-group-harness' +import { createAccessControlDraftHarness } from './access-control-test-utils' const mockUseAppWhiteListSubjects = vi.fn() const mockUseSearchForWhiteListCandidates = vi.fn() -const mockMutateAsync = vi.fn() -const mockUseUpdateAccessMode = vi.fn(() => ({ - isPending: false, - mutateAsync: mockMutateAsync, -})) +const mockMutate = vi.fn() +const mockUseMutation = vi.hoisted(() => vi.fn()) const intersectionObserverMocks = vi.hoisted(() => ({ callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void), })) @@ -39,9 +38,16 @@ vi.mock('@/context/app-context', () => ({ vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), - useUpdateAccessMode: () => mockUseUpdateAccessMode(), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useMutation: (...args: unknown[]) => mockUseMutation(...args), + } +}) + vi.mock('ahooks', async (importOriginal) => { const actual = await importOriginal() return { @@ -94,10 +100,12 @@ beforeAll(() => { }) beforeEach(() => { - mockMutateAsync.mockResolvedValue(undefined) - mockUseUpdateAccessMode.mockReturnValue({ + mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + mockUseMutation.mockReturnValue({ isPending: false, - mutateAsync: mockMutateAsync, + mutate: mockMutate, }) mockUseAppWhiteListSubjects.mockReturnValue({ isPending: false, @@ -117,33 +125,39 @@ beforeEach(() => { // AccessControlItem handles selected vs. unselected styling and click state updates describe('AccessControlItem', () => { it('should update current menu when selecting a different access type', () => { - useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC }) - render( - - Organization Only - , + const harness = createAccessControlDraftHarness( + + + Organization Only + + , + { currentMenu: AccessMode.PUBLIC }, ) + render(harness.element) - const option = screen.getByText('Organization Only').parentElement as HTMLElement + const option = screen.getByRole('radio', { name: 'Organization Only' }) expect(option).toHaveClass('cursor-pointer') fireEvent.click(option) - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) + expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION) }) it('should keep current menu when clicking the selected access type', () => { - useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION }) - render( - - Organization Only - , + const harness = createAccessControlDraftHarness( + + + Organization Only + + , + { currentMenu: AccessMode.ORGANIZATION }, ) + render(harness.element) - const option = screen.getByText('Organization Only').parentElement as HTMLElement + const option = screen.getByRole('radio', { name: 'Organization Only' }) fireEvent.click(option) - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) + expect(harness.getSnapshot().currentMenu).toBe(AccessMode.ORGANIZATION) }) }) @@ -180,32 +194,40 @@ describe('AccessControlDialog', () => { // SpecificGroupsOrMembers syncs store state with fetched data and supports removals describe('SpecificGroupsOrMembers', () => { it('should render collapsed view when not in specific selection mode', () => { - useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION }) + const harness = createAccessControlDraftHarness( + , + { currentMenu: AccessMode.ORGANIZATION }, + ) - render() + render(harness.element) expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument() }) it('should show loading state while pending', async () => { - useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: true, - data: undefined, - }) + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) - const { container } = render() + render(harness.element) - await waitFor(() => { - expect(container.querySelector('.spin-animation')).toBeInTheDocument() - }) + expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument() }) it('should render fetched groups and members and support removal', async () => { - useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }) + const harness = createAccessControlDraftHarness( + , + { + appId: 'app-1', + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + specificGroups: [baseGroup], + specificMembers: [baseMember], + }, + ) - render() + render(harness.element) await waitFor(() => { expect(screen.getByText(baseGroup.name)).toBeInTheDocument() @@ -234,8 +256,12 @@ describe('SpecificGroupsOrMembers', () => { describe('AddMemberOrGroupDialog', () => { it('should open search popover and display candidates', async () => { const user = userEvent.setup() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) - render() + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -246,17 +272,21 @@ describe('AddMemberOrGroupDialog', () => { it('should allow selecting members and expanding groups', async () => { const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand') await user.click(expandButton) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup]) await user.click(screen.getByRole('option', { name: /Member One/ })) - expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) + expect(harness.getSnapshot().specificMembers).toEqual([baseMember]) }) it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => { @@ -269,7 +299,11 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group') @@ -286,9 +320,9 @@ describe('AddMemberOrGroupDialog', () => { fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers')) - expect(useAccessControlStore.getState().specificGroups).toEqual([]) - expect(useAccessControlStore.getState().specificMembers).toEqual([]) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) + expect(harness.getSnapshot().specificGroups).toEqual([]) + expect(harness.getSnapshot().specificMembers).toEqual([]) + expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([]) expect(fetchNextPage).not.toHaveBeenCalled() }) @@ -301,7 +335,11 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -315,10 +353,6 @@ describe('AccessControl', () => { const onClose = vi.fn() const onConfirm = vi.fn() const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') - useAccessControlStore.setState({ - specificGroups: [baseGroup], - specificMembers: [baseMember], - }) const app = { id: 'app-id-1', access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, @@ -332,21 +366,22 @@ describe('AccessControl', () => { />, ) - await waitFor(() => { - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS) - }) - fireEvent.click(screen.getByText('common.operation.confirm')) await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith({ - appId: app.id, - accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - subjects: [ - { subjectId: baseGroup.id, subjectType: SubjectType.GROUP }, - { subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT }, - ], - }) + expect(mockMutate).toHaveBeenCalledWith( + { + body: { + appId: app.id, + accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + subjects: [ + { subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_GROUP }, + { subjectId: baseMember.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_ACCOUNT }, + ], + }, + }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess') expect(onConfirm).toHaveBeenCalled() }) diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx index d34756e85e..b447e9c381 100644 --- a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -1,9 +1,9 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import useAccessControlStore from '@/context/access-control-store' -import { SubjectType } from '@/models/access-control' -import AddMemberOrGroupDialog from '../add-member-or-group-pop' +import { AccessMode, SubjectType } from '@/models/access-control' +import { AddMemberOrGroupDialog } from '../add-member-or-group-pop' +import { createAccessControlDraftHarness } from './access-control-test-utils' const mockUseSearchForWhiteListCandidates = vi.fn() const intersectionObserverMocks = vi.hoisted(() => ({ @@ -69,13 +69,6 @@ describe('AddMemberOrGroupDialog', () => { beforeEach(() => { vi.clearAllMocks() - useAccessControlStore.setState({ - appId: 'app-1', - specificGroups: [], - specificMembers: [], - currentMenu: SubjectType.GROUP as never, - selectedGroupsForBreadcrumb: [], - }) mockUseSearchForWhiteListCandidates.mockReturnValue({ isLoading: false, isFetchingNextPage: false, @@ -88,7 +81,11 @@ describe('AddMemberOrGroupDialog', () => { it('should open the search popover and display candidates', async () => { const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -99,16 +96,20 @@ describe('AddMemberOrGroupDialog', () => { it('should allow expanding groups and selecting members', async () => { const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([baseGroup]) await user.click(screen.getByRole('option', { name: /Member One/ })) - expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) + expect(harness.getSnapshot().specificMembers).toEqual([baseMember]) }) it('should show the empty state when no candidates are returned', async () => { @@ -120,7 +121,11 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -128,9 +133,6 @@ describe('AddMemberOrGroupDialog', () => { }) it('should keep breadcrumbs visible when the current group has no candidates', async () => { - useAccessControlStore.setState({ - selectedGroupsForBreadcrumb: [baseGroup], - }) mockUseSearchForWhiteListCandidates.mockReturnValue({ isLoading: false, isFetchingNextPage: false, @@ -139,7 +141,15 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - render() + const harness = createAccessControlDraftHarness( + , + { + appId: 'app-1', + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + selectedGroupsForBreadcrumb: [baseGroup], + }, + ) + render(harness.element) await user.click(screen.getByText('common.operation.add')) @@ -149,6 +159,6 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) + expect(harness.getSnapshot().selectedGroupsForBreadcrumb).toEqual([]) }) }) diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index 74e7d7046c..f3cb47f16b 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -3,9 +3,8 @@ import type { App } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' -import AccessControl from '../index' +import { AccessControl } from '../index' let mockWebappAuth = { enabled: true, @@ -18,20 +17,24 @@ const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { systemFeatures: { webapp_auth: mockWebappAuth }, }) -const mockMutateAsync = vi.fn() -const mockUseUpdateAccessMode = vi.fn(() => ({ - isPending: false, - mutateAsync: mockMutateAsync, -})) +const mockMutate = vi.fn() +const mockUseMutation = vi.hoisted(() => vi.fn()) const mockUseAppWhiteListSubjects = vi.fn() const mockUseSearchForWhiteListCandidates = vi.fn() vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), - useUpdateAccessMode: () => mockUseUpdateAccessMode(), })) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useMutation: (...args: unknown[]) => mockUseMutation(...args), + } +}) + describe('AccessControl', () => { beforeEach(() => { vi.clearAllMocks() @@ -41,14 +44,13 @@ describe('AccessControl', () => { allow_email_password_login: false, allow_email_code_login: false, } - useAccessControlStore.setState({ - appId: '', - specificGroups: [], - specificMembers: [], - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - selectedGroupsForBreadcrumb: [], + mockMutate.mockImplementation((_: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + mockUseMutation.mockReturnValue({ + isPending: false, + mutate: mockMutate, }) - mockMutateAsync.mockResolvedValue(undefined) mockUseAppWhiteListSubjects.mockReturnValue({ isPending: false, data: { @@ -81,18 +83,18 @@ describe('AccessControl', () => { />, ) - await waitFor(() => { - expect(useAccessControlStore.getState().appId).toBe(app.id) - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC) - }) - fireEvent.click(screen.getByText('common.operation.confirm')) await waitFor(() => { - expect(mockMutateAsync).toHaveBeenCalledWith({ - appId: app.id, - accessMode: AccessMode.PUBLIC, - }) + expect(mockMutate).toHaveBeenCalledWith( + { + body: { + appId: app.id, + accessMode: AccessMode.PUBLIC, + }, + }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess') expect(onConfirm).toHaveBeenCalledTimes(1) }) @@ -116,4 +118,30 @@ describe('AccessControl', () => { expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument() expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument() }) + + it('should prevent confirming specific access before subjects are loaded', () => { + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: true, + data: undefined, + }) + + render( + , + ) + + const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) + const organizationOption = screen.getByRole('radio', { + name: 'app.accessControlDialog.accessItems.organization', + }) + + expect(confirmButton).toBeDisabled() + expect(organizationOption).toHaveAttribute('aria-disabled', 'true') + + fireEvent.click(confirmButton) + + expect(mockMutate).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx index e763521940..044e5aa3d6 100644 --- a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -1,17 +1,13 @@ import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' -import SpecificGroupsOrMembers from '../specific-groups-or-members' +import { SpecificGroupsOrMembers } from '../specific-groups-or-members' +import { createAccessControlDraftHarness } from './access-control-test-utils' -const mockUseAppWhiteListSubjects = vi.fn() +const mockUseSearchForWhiteListCandidates = vi.fn() vi.mock('@/service/access-control', () => ({ - useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), -})) - -vi.mock('../add-member-or-group-pop', () => ({ - default: () =>
, + useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), })) const createGroup = (overrides: Partial = {}): AccessControlGroup => ({ @@ -36,50 +32,48 @@ describe('SpecificGroupsOrMembers', () => { beforeEach(() => { vi.clearAllMocks() - useAccessControlStore.setState({ - appId: '', - specificGroups: [], - specificMembers: [], - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - selectedGroupsForBreadcrumb: [], - }) - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: false, - data: { - groups: [baseGroup], - members: [baseMember], - }, + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [] }, }) }) it('should render the collapsed row when not in specific mode', () => { - useAccessControlStore.setState({ - currentMenu: AccessMode.ORGANIZATION, - }) + const harness = createAccessControlDraftHarness( + , + { currentMenu: AccessMode.ORGANIZATION }, + ) - render() + render(harness.element) expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() - expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.add' })).not.toBeInTheDocument() }) - it('should show loading while whitelist subjects are pending', async () => { - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: true, - data: undefined, - }) + it('should show loading when the selected subjects are pending', async () => { + const harness = createAccessControlDraftHarness() + render(harness.element) - const { container } = render() + expect(screen.getByRole('combobox', { name: 'common.operation.add' })).toBeDisabled() await waitFor(() => { - expect(container.querySelector('.spin-animation')).toBeInTheDocument() + expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument() }) }) it('should render fetched groups and members and support removal', async () => { - useAccessControlStore.setState({ appId: 'app-1' }) + const harness = createAccessControlDraftHarness( + , + { + appId: 'app-1', + specificGroups: [baseGroup], + specificMembers: [baseMember], + }, + ) - render() + render(harness.element) await waitFor(() => { expect(screen.getByText(baseGroup.name)).toBeInTheDocument() @@ -91,9 +85,9 @@ describe('SpecificGroupsOrMembers', () => { const memberRemove = removeButtons[1]! fireEvent.click(groupRemove) - expect(useAccessControlStore.getState().specificGroups).toEqual([]) + expect(harness.getSnapshot().specificGroups).toEqual([]) fireEvent.click(memberRemove) - expect(useAccessControlStore.getState().specificMembers).toEqual([]) + expect(harness.getSnapshot().specificMembers).toEqual([]) }) }) diff --git a/web/app/components/app/app-access-control/access-control-dialog-content.tsx b/web/app/components/app/app-access-control/access-control-dialog-content.tsx new file mode 100644 index 0000000000..fb1f324a0e --- /dev/null +++ b/web/app/components/app/app-access-control/access-control-dialog-content.tsx @@ -0,0 +1,110 @@ +'use client' + +import type { ReactNode } from 'react' +import type { SpecificGroupsOrMembersProps } from './specific-groups-or-members' +import { Button } from '@langgenius/dify-ui/button' +import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { RadioGroup } from '@langgenius/dify-ui/radio-group' +import { useTranslation } from 'react-i18next' +import { AccessMode } from '@/models/access-control' +import { AccessControlItem } from './access-control-item' +import { SpecificGroupsOrMembers, WebAppSSONotEnabledTip } from './specific-groups-or-members' +import { useAccessControlStore } from './store' + +type AccessControlDialogContentProps = { + title?: ReactNode + description?: ReactNode + accessLabel?: ReactNode + hideExternal?: boolean + hideExternalTip?: boolean + saving?: boolean + controlsDisabled?: boolean + confirmDisabled?: boolean + specificGroupsOrMembersProps?: SpecificGroupsOrMembersProps + onClose: () => void + onConfirm: () => void +} + +export function AccessControlDialogContent({ + title, + description, + accessLabel, + hideExternal = false, + hideExternalTip = false, + saving = false, + controlsDisabled = false, + confirmDisabled = false, + specificGroupsOrMembersProps, + onClose, + onConfirm, +}: AccessControlDialogContentProps) { + const { t } = useTranslation() + const currentMenu = useAccessControlStore(s => s.currentMenu) + const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu) + + return ( +
+
+ + {title ?? t('accessControlDialog.title', { ns: 'app' })} + + + {description ?? t('accessControlDialog.description', { ns: 'app' })} + +
+ + value={currentMenu} + onValueChange={setCurrentMenu} + className="flex flex-col items-stretch gap-y-1 px-6 pb-3" + aria-labelledby="access-control-options-label" + disabled={controlsDisabled} + > +
+

+ {accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })} +

+
+ +
+
+
+
+
+ + + + {!hideExternal && ( + +
+
+
+ {!hideExternalTip && } +
+
+ )} + +
+
+
+ +
+ + +
+
+ ) +} diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx index a863935f90..c29e73aa9a 100644 --- a/web/app/components/app/app-access-control/access-control-dialog.tsx +++ b/web/app/components/app/app-access-control/access-control-dialog.tsx @@ -5,7 +5,6 @@ import { DialogCloseButton, DialogContent, } from '@langgenius/dify-ui/dialog' -import { useCallback } from 'react' type DialogProps = { className?: string @@ -14,21 +13,17 @@ type DialogProps = { onClose?: () => void } -const AccessControlDialog = ({ +export function AccessControlDialog({ className, children, show, onClose, -}: DialogProps) => { - const close = useCallback(() => { - onClose?.() - }, [onClose]) - +}: DialogProps) { return ( - !open && close()}> + !open && onClose?.()}> @@ -38,5 +33,3 @@ const AccessControlDialog = ({ ) } - -export default AccessControlDialog diff --git a/web/app/components/app/app-access-control/access-control-item.tsx b/web/app/components/app/app-access-control/access-control-item.tsx index cc2cf94f0c..5913a7e47f 100644 --- a/web/app/components/app/app-access-control/access-control-item.tsx +++ b/web/app/components/app/app-access-control/access-control-item.tsx @@ -1,37 +1,26 @@ 'use client' -import type { FC, PropsWithChildren } from 'react' +import type { PropsWithChildren } from 'react' import type { AccessMode } from '@/models/access-control' -import useAccessControlStore from '@/context/access-control-store' +import { cn } from '@langgenius/dify-ui/cn' +import { RadioRoot } from '@langgenius/dify-ui/radio' -type AccessControlItemProps = PropsWithChildren<{ +export function AccessControlItem({ type, children }: PropsWithChildren<{ type: AccessMode -}> - -const AccessControlItem: FC = ({ type, children }) => { - const currentMenu = useAccessControlStore(s => s.currentMenu) - const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu) - if (currentMenu !== type) { - return ( -
setCurrentMenu(type)} - > - {children} -
- ) - } - +}>) { return ( -
+ value={type} + variant="unstyled" + render={
} + className={cn( + 'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors', + 'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover', + 'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden', + 'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset', + 'data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg', + )} > {children} -
+ ) } - -AccessControlItem.displayName = 'AccessControlItem' - -export default AccessControlItem diff --git a/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx b/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx new file mode 100644 index 0000000000..43da1de6a7 --- /dev/null +++ b/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx @@ -0,0 +1,208 @@ +'use client' + +import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox' +import type { AccessSubjectSelectionProps } from './types' +import type { + AccessControlGroup, + Subject, +} from '@/models/access-control' +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxList, + ComboboxStatus, + ComboboxTrigger, +} from '@langgenius/dify-ui/combobox' +import { useDebounce } from 'ahooks' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { SkeletonRectangle } from '@/app/components/base/skeleton' +import { useSearchForWhiteListCandidates } from '@/service/access-control' +import { SelectedGroupsBreadCrumb, SubjectItem } from './subject-options' +import { + getSubjectLabel, + getSubjectValue, + isSameSubject, + selectionValueToSubjects, + subjectsToSelectionValue, +} from './utils' + +type AccessSubjectAddButtonProps = AccessSubjectSelectionProps & { + disabled?: boolean + breadcrumbGroups?: AccessControlGroup[] + onBreadcrumbGroupsChange?: (groups: AccessControlGroup[]) => void +} + +export function AccessSubjectAddButton({ + selectedGroups, + selectedMembers, + onChange, + disabled, + breadcrumbGroups, + onBreadcrumbGroupsChange, +}: AccessSubjectAddButtonProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [keyword, setKeyword] = useState('') + const [internalBreadcrumbGroups, setInternalBreadcrumbGroups] = useState([]) + const scrollRootRef = useRef(null) + const anchorRef = useRef(null) + const selectedGroupsForBreadcrumb = breadcrumbGroups ?? internalBreadcrumbGroups + const setSelectedGroupsForBreadcrumb = onBreadcrumbGroupsChange ?? setInternalBreadcrumbGroups + const debouncedKeyword = useDebounce(keyword, { wait: 500 }) + + const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1] + const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ + keyword: debouncedKeyword, + groupId: lastAvailableGroup?.id, + resultsPerPage: 10, + }, open && !disabled) + const pages = data?.pages ?? [] + const subjects = pages.flatMap(page => page.subjects ?? []) + const selectedSubjects = selectionValueToSubjects({ + groups: selectedGroups, + members: selectedMembers, + }) + const hasResults = pages.length > 0 && subjects.length > 0 + const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0 + const hasMore = pages[pages.length - 1]?.hasMore ?? false + + useEffect(() => { + let observer: IntersectionObserver | undefined + if (anchorRef.current) { + observer = new IntersectionObserver((entries) => { + if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore) + fetchNextPage() + }, { root: scrollRootRef.current, rootMargin: '20px' }) + observer.observe(anchorRef.current) + } + return () => observer?.disconnect() + }, [fetchNextPage, hasMore, isFetchingNextPage, isLoading]) + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen && disabled) + return + if (!nextOpen) + setKeyword('') + + setOpen(nextOpen) + } + + const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => { + if (!disabled && details.reason !== 'item-press') + setKeyword(inputValue) + } + + const handleValueChange = (nextSubjects: Subject[]) => { + onChange(subjectsToSelectionValue(nextSubjects)) + } + + return ( + + multiple + open={open} + value={selectedSubjects} + inputValue={keyword} + items={subjects} + disabled={disabled} + itemToStringLabel={getSubjectLabel} + itemToStringValue={getSubjectValue} + isItemEqualToValue={isSameSubject} + filter={null} + onOpenChange={handleOpenChange} + onInputValueChange={handleInputValueChange} + onValueChange={handleValueChange} + > + + + + + +
+
+ + +
+ {isLoading + ? ( + + + + ) + : ( + <> + {shouldShowBreadcrumb && ( +
+ +
+ )} + {hasResults + ? ( + <> + + {(subject: Subject) => ( + setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])} + /> + )} + + {isFetchingNextPage && } +
+ + ) + : ( + + {t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })} + + )} + + )} +
+ + + ) +} + +function SubjectOptionsSkeleton() { + return ( +
+ {[0, 1, 2, 3, 4].map(index => ( +
+ + + + +
+ ))} +
+ ) +} diff --git a/web/app/components/app/app-access-control/access-subject-selector/selection-list.tsx b/web/app/components/app/app-access-control/access-subject-selector/selection-list.tsx new file mode 100644 index 0000000000..4a7a545d62 --- /dev/null +++ b/web/app/components/app/app-access-control/access-subject-selector/selection-list.tsx @@ -0,0 +1,207 @@ +'use client' + +import type { ReactNode } from 'react' +import type { AccessSubjectSelectionProps } from './types' +import type { + AccessControlAccount, + AccessControlGroup, +} from '@/models/access-control' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import { SkeletonRectangle } from '@/app/components/base/skeleton' + +type AccessSubjectSelectionListProps = AccessSubjectSelectionProps & { + loading?: boolean + className?: string +} + +export function AccessSubjectSelectionList({ + selectedGroups, + selectedMembers, + onChange, + loading, + className, +}: AccessSubjectSelectionListProps) { + return ( +
+ {loading + ? + : ( + + )} +
+ ) +} + +function AccessSubjectSelectionListSkeleton() { + const { t } = useTranslation() + + return ( +
+ +
+ {[0, 1].map(index => ( + + ))} +
+ +
+ {[0, 1, 2].map(index => ( + + ))} +
+
+ ) +} + +function SelectedItemSkeleton({ withMeta = false }: { + withMeta?: boolean +}) { + return ( +
+ + + {withMeta && } + +
+ ) +} + +function RenderGroupsAndMembers({ + selectedGroups, + selectedMembers, + onChange, +}: AccessSubjectSelectionProps) { + const { t } = useTranslation() + if (selectedGroups.length <= 0 && selectedMembers.length <= 0) { + return ( +
+

+ {t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })} +

+
+ ) + } + + return ( + <> +

+ {t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })} +

+
+ {selectedGroups.map(group => ( + + ))} +
+

+ {t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })} +

+
+ {selectedMembers.map(member => ( + + ))} +
+ + ) +} + +type SelectedGroupItemProps = AccessSubjectSelectionProps & { + group: AccessControlGroup +} + +function SelectedGroupItem({ + group, + selectedGroups, + selectedMembers, + onChange, +}: SelectedGroupItemProps) { + const handleRemoveGroup = () => { + onChange({ + groups: selectedGroups.filter(selectedGroup => selectedGroup.id !== group.id), + members: selectedMembers, + }) + } + + return ( + + ) +} + +type SelectedMemberItemProps = AccessSubjectSelectionProps & { + member: AccessControlAccount +} + +function SelectedMemberItem({ + member, + selectedGroups, + selectedMembers, + onChange, +}: SelectedMemberItemProps) { + const handleRemoveMember = () => { + onChange({ + groups: selectedGroups, + members: selectedMembers.filter(selectedMember => selectedMember.id !== member.id), + }) + } + + return ( + } + onRemove={handleRemoveMember} + > +

{member.name}

+
+ ) +} + +type SelectedBaseItemProps = { + icon: ReactNode + children: ReactNode + onRemove?: () => void +} + +function SelectedBaseItem({ icon, onRemove, children }: SelectedBaseItemProps) { + const { t } = useTranslation() + + return ( +
+
+
+ {icon} +
+
+ {children} + +
+ ) +} diff --git a/web/app/components/app/app-access-control/access-subject-selector/subject-options.tsx b/web/app/components/app/app-access-control/access-subject-selector/subject-options.tsx new file mode 100644 index 0000000000..1a09df7363 --- /dev/null +++ b/web/app/components/app/app-access-control/access-subject-selector/subject-options.tsx @@ -0,0 +1,217 @@ +'use client' + +import type { ReactNode } from 'react' +import type { + AccessControlAccount, + AccessControlGroup, + Subject, + SubjectAccount, + SubjectGroup, +} from '@/models/access-control' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + ComboboxItem, + ComboboxItemText, +} from '@langgenius/dify-ui/combobox' +import { useTranslation } from 'react-i18next' +import { useSelector } from '@/context/app-context' +import { SubjectType } from '@/models/access-control' + +export function SubjectItem({ + subject, + selectedGroups, + selectedMembers, + onExpandGroup, +}: { + subject: Subject + selectedGroups: AccessControlGroup[] + selectedMembers: AccessControlAccount[] + onExpandGroup: (group: AccessControlGroup) => void +}) { + if (subject.subjectType === SubjectType.GROUP) { + return ( + + ) + } + + return ( + + ) +} + +export function SelectedGroupsBreadCrumb({ + selectedGroupsForBreadcrumb, + onChange, +}: { + selectedGroupsForBreadcrumb: AccessControlGroup[] + onChange: (groups: AccessControlGroup[]) => void +}) { + const { t } = useTranslation() + + const handleBreadCrumbClick = (index: number) => { + const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1) + onChange(newGroups) + } + const handleReset = () => { + onChange([]) + } + const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0 + + return ( +
+ {hasBreadcrumb + ? ( + + ) + : ( + {t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })} + )} + {selectedGroupsForBreadcrumb.map((group, index) => { + const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1 + + return ( +
+ / + {isLastGroup + ? {group.name} + : ( + + )} +
+ ) + })} +
+ ) +} + +type GroupItemProps = { + group: AccessControlGroup + subject: Subject + selectedGroups: AccessControlGroup[] + onExpandGroup: (group: AccessControlGroup) => void +} + +function GroupItem({ group, subject, selectedGroups, onExpandGroup }: GroupItemProps) { + const { t } = useTranslation() + const isChecked = selectedGroups.some(selectedGroup => selectedGroup.id === group.id) + + return ( +
+ + + +
+
+
+
+ {group.name} + {group.groupSize} +
+
+ +
+ ) +} + +type MemberItemProps = { + member: AccessControlAccount + subject: Subject + selectedMembers: AccessControlAccount[] +} + +function MemberItem({ member, subject, selectedMembers }: MemberItemProps) { + const currentUser = useSelector(s => s.userProfile) + const { t } = useTranslation() + const isChecked = selectedMembers.some(selectedMember => selectedMember.id === member.id) + return ( + + + +
+
+ +
+
+ {member.name} + {currentUser.email === member.email && ( + + ( + {t('you', { ns: 'common' })} + ) + + )} +
+ {member.email} +
+ ) +} + +type ComboboxBaseItemProps = { + className?: string + subject: Subject + children: ReactNode +} + +function ComboboxBaseItem({ children, className, subject }: ComboboxBaseItemProps) { + return ( + + {children} + + ) +} + +function SelectionBox({ checked }: { checked: boolean }) { + return ( +