🚀 feat: launch v1.0 — next-generation frontend built from the ground up (#4265)
* feat: add parameter coverage for the operations: copy, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, and regex_replace * fix: CrossGroupRetry default false 移除gorm:"default:false",避免每次 AutoMigrate时都执行ALTER TABLE `tokens` MODIFY COLUMN `cross_group_retry` boolean DEFAULT false 且bool默认false不影响原有功能 * feat: check-in feature integrates Turnstile security check * feat: add support for Doubao /v1/responses (#2567) * feat: add support for Doubao /v1/responses * fix: fix model deployment style issues, lint problems, and i18n gaps. (#2556) * fix: fix model deployment style issues, lint problems, and i18n gaps. * fix: adjust the key not to be displayed on the frontend, tested via the backend. * fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined. * feat: add plans directory to .gitignore * fix: 修复 gemini 文件类型不支持 image/jpg * fix: fix the proxyURL is empty, not using the default HTTP client configuration && the AWS calling side did not apply the relay timeout. * fix: batch add key backend deduplication * Merge pull request #2582 from seefs001/fix/tips fix: add tips for model management and channel testing * fix(gin): update request body size check to allow zero limit * feat: add regex pattern to mask API keys in sensitive information * fix(task): 修复使用 auto 分组时 Task Relay 不记录日志和不扣费的问题 问题描述: - 使用 auto 分组的令牌调用 /v1/videos 等 Task 接口时,虽然任务能成功创建, 但使用日志不显示记录,且不会扣费 根本原因: - Distribute 中间件在选择渠道后,会将实际选中的分组存储在 ContextKeyAutoGroup 中 - 但 RelayTaskSubmit 函数没有从 context 中读取这个值来更新 info.UsingGroup - 导致 info.UsingGroup 始终是 "auto" 而不是实际选中的分组(如 "sora2逆") - 当 auto 分组的倍率配置为 0 时,quota 计算结果为 0 - 日志记录条件 "if quota != 0" 不满足,导致日志不记录、不扣费 修复方案: - 在 RelayTaskSubmit 函数中计算分组倍率之前,添加从 ContextKeyAutoGroup 获取实际分组的逻辑 - 使用安全的类型断言,避免潜在的 panic 风险 影响范围: - 仅影响 Task Relay 流程(/v1/videos, /suno, /kling 等接口) - 不影响使用具体分组令牌的调用 - 不影响其他 Relay 类型(chat/completions 等已有类似处理逻辑) * 🚀 feat(web): port legacy v2 frontend changes into new UI (deployments, check-in, ollama) + align APIs Bring over the key frontend functionality introduced in merge `efa3301` and integrate it cleanly into the new `web/src` architecture and design system. - **Model deployments (io.net)** - Align frontend endpoints and payloads with backend deployment routes (`/api/deployments/*`) - Add missing deployment operations: details, logs (container-aware), update config, rename, extend duration - Improve create-deployment flow (proper request shape, name availability check, price estimation parity) - **System settings** - Enhance io.net deployment settings: allow testing connection with an unsaved API key and add “how to get API key” guidance - **Channels / Ollama** - Improve Ollama model management: live fetch via base_url with fallback to channel fetch, selection + apply flows, delete confirmation - Refactor for feature-layer consistency: extract Ollama parsing/normalization utilities into `features/channels/lib` - **Quality** - Ensure TypeScript typecheck passes after refactor and new dialogs/components integration * Merge pull request #2590 from xyfacai/fix/max-body-limit fix: 设置默认max req body 为128MB * docs: update readme * i18n: add missing translations * fix(gemini): fetch model list via native v1beta/models endpoint Use the native Gemini Models API (/v1beta/models) instead of the OpenAI-compatible path when listing models for Gemini channels, improving compatibility with third-party Gemini-format providers that don't implement OpenAI routes. - Add paginated model listing with timeout and optional proxy support - Select an enabled key for multi-key Gemini channels * refactor(gemini): 更新 GeminiModelsResponse 以使用 dto.GeminiModel 类型 * fix: remove Minimax from FETCHABLE channels * fix(minimax): 添加 MiniMax-M2 系列模型到 ModelList * feat: add doubao video 1.5 * 🤢 chore: remove useless file * feat: /v1/chat/completion -> /v1/response (#2629) * feat: /v1/chat/completion -> /v1/response * fix: clean propertyNames for gemini function * fix: support snake_case fields in GeminiChatGenerationConfig * chore: update dependencies and lockfile for improved compatibility - Updated @clerk/clerk-react to version 5.59.3 - Updated @hookform/resolvers to version 5.2.2 - Updated @lobehub/icons to version 2.48.0 - Updated various Radix UI components to their latest versions - Updated @tanstack/react-query and related packages for better performance - Updated axios, i18next, and other libraries for security and feature enhancements - Updated lockfile to include configVersion and ensure consistency across environments * Merge pull request #2647 from seefs001/feature/status-code-auto-disable feat: status code auto-disable configuration * fix: chat2response setting ui (#2643) * fix: setting ui * fix: rm global.chat_completions_to_responses_policy * fix: rm global.chat_completions_to_responses_policy * Merge pull request #2627 from seefs001/feature/channel-test-param-override feat: channel testing supports parameter overriding * chore: update dependencies and lockfile for improved compatibility - Updated @lobehub/icons to version 4.0.3 - Updated ai to version 6.0.27 - Updated various libraries including axios, react-day-picker, and streamdown for security and feature enhancements - Updated devDependencies for eslint, prettier, and typescript for better performance and compatibility - Updated lockfile to ensure consistency across environments * chore: update lockfile and Vite configuration for improved build process - Updated lockfile to version 1 for better compatibility and consistency - Enhanced Vite configuration to support production optimizations, including code minification and chunking for dependencies - Added environment-specific console and debugger removal for production builds * chore: migrate from Vite to Rsbuild for build process - Added Rsbuild configuration for development and production builds - Updated package.json scripts to use Rsbuild instead of Vite - Replaced @tailwindcss/vite with @tailwindcss/postcss in dependencies - Introduced postcss.config.mjs for Tailwind CSS integration - Updated TypeScript configuration to include Rsbuild config - Removed Vite configuration file to streamline the build process * refactor: optimize user data handling and API calls - Replaced direct API calls to get user data with cached user information from auth-store in ModelsFilter and SummaryCards components. - Improved session management in RootComponent and Authenticated route to utilize localStorage for user authentication status, reducing unnecessary API requests. - Added caching for setup status checks to enhance performance during navigation. * feat: enhance session validation in authenticated route - Implemented session verification to check user authentication status via API call only once per session. - Updated beforeLoad logic to redirect users to the login page if session validation fails or if no user information is available in localStorage. - Improved user data handling by updating the auth store with fresh user information upon successful session verification. * refactor: improve useMediaQuery hook for better SSR handling - Enhanced the useMediaQuery hook to check for window availability before accessing matchMedia, preventing errors during server-side rendering. - Simplified state initialization and change handling by using a dedicated function to determine initial matches. - Updated event listener management for improved performance and clarity. * feat(hooks): export useMediaQuery from hooks index * refactor: update useMediaQuery imports to use unified hooks index * fix(rsbuild): fix loadEnv API usage and removeConsole type * feat: customizable automatic retry status codes * refactor(hooks): use useSyncExternalStore for better SSR handling in useMediaQuery * refactor: simplify embedded file structure in main.go - Updated the embedded file directive to include the entire web/dist directory instead of individual assets, streamlining the build process. * refactor: replace DropdownMenu with Sheet component in ProfileDropdown - Updated the ProfileDropdown component to use a Sheet for user interactions instead of a DropdownMenu. - Enhanced user info display with improved layout and styling. - Added navigation links and sign-out functionality within the Sheet. * refactor: streamline ProfileDropdown layout and improve user info display - Removed unused Badge component and secondary text from user display. - Enhanced styling for user info section and navigation links. - Updated sign-out functionality to use a button for better accessibility. * feat: add System Settings link for super admin in ProfileDropdown - Introduced a new link to System Settings in the ProfileDropdown, visible only to users with the SUPER_ADMIN role. - Updated imports to include the Settings icon and adjusted the component logic accordingly. - Removed the Settings entry from the sidebar data to streamline navigation. * feat: codex channel (#2652) * feat: codex channel * feat: codex channel * feat: codex oauth flow * feat: codex refresh cred * feat: codex usage * fix: codex err message detail * fix: codex setting ui * feat: codex refresh cred task * fix: import err * fix: codex store must be false * fix: chat -> responses tool call * fix: chat -> responses tool call * feat(i18n): add missing translations * fix(i18n): restore missing translations for "360" and add "User Menu" in multiple locales - Reintroduced the translation for "360" in English, French, Japanese, Russian, Vietnamese, and Chinese locales. - Added the "User Menu" translation in the same languages to enhance user interface consistency. * fix: openAI function to gemini function field adjusted to whitelist mode * feat: TLS_INSECURE_SKIP_VERIFY env * fix: for chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently. * refactor(system-settings): restructure settings sections and navigation - Replaced SettingsAccordion with a unified SettingsSection component across various settings sections for consistency. - Introduced a section registry to manage general settings sections dynamically. - Updated navigation items in the system settings sidebar to utilize the new section registry. - Enhanced the GeneralSettings component to support section-based content rendering based on user selection. * fix(system-settings): remove type assertion for quotaDisplayType in GeneralSettings - Eliminated the type assertion for quotaDisplayType in the GeneralSettings component to improve type inference and maintain cleaner code. * refactor(system-settings): update zod import syntax in general settings - Changed the import statement for zod from a default import to a namespace import for better clarity and consistency in the codebase. * fix: the login method cannot be displayed under the aff link. * feat(system-settings): implement generic settings page and enhance navigation - Added a new generic SettingsPage component to handle loading states, data fetching, and section rendering. - Integrated section registry for general and authentication settings to streamline navigation and content management. - Updated URL utility functions to improve query parameter handling for active navigation states. - Enhanced the system settings sidebar to include authentication section items dynamically. * refactor(system-settings): replace SettingsAccordion with SettingsSection across authentication settings - Updated BasicAuthSection, BotProtectionSection, OAuthSection, and PasskeySection to use the new SettingsSection component for consistency. - Introduced a section registry to manage authentication settings dynamically, enhancing navigation and content rendering. * feat(system-settings): enhance request limits settings with new section and unified component - Added a new Request Limits section to the system settings sidebar, integrating it with the section registry for improved navigation. - Replaced SettingsAccordion with SettingsSection in RateLimitSection, SensitiveWordsSection, and SSRFSection for consistency. - Updated RequestLimitsSettings to utilize the new SettingsPage component for better data handling and rendering. - Implemented a search schema for request limits to streamline navigation and section management. * feat(system-settings): integrate content settings sections with unified component and registry - Added a new Content section to the system settings sidebar, incorporating it into the section registry for improved navigation. - Replaced SettingsAccordion with SettingsSection in multiple content-related components for consistency. - Created a section registry to manage content settings dynamically, enhancing the rendering and navigation experience. - Updated the ContentSettings component to utilize the new section registry and streamline content display. * feat(system-settings): enhance integrations settings with unified section registry and components - Introduced a new section registry for integrations settings, consolidating various settings components for better organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple integration-related components for consistency. - Updated IntegrationSettings to utilize the new SettingsPage component, improving data handling and rendering. - Added a new integrations section to the system settings sidebar, enhancing user experience and accessibility. * feat(system-settings): unify model settings with new section registry and components - Introduced a section registry for model settings, consolidating various model-related components for improved organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple model settings components for consistency. - Updated ModelSettings to utilize the new SettingsPage component, enhancing data handling and rendering. - Added a new Models section to the system settings sidebar, improving user experience and accessibility. * feat(system-settings): enhance maintenance settings with unified section registry and components - Introduced a new section registry for maintenance settings, consolidating various maintenance-related components for improved organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple maintenance components for consistency. - Updated MaintenanceSettings to utilize the new section registry, enhancing data handling and rendering. - Added a new Maintenance section to the system settings sidebar, improving user experience and accessibility. * feat(system-settings): update section titles for improved clarity and consistency - Renamed various section titles across content, integrations, maintenance, models, and request limits to enhance clarity and better reflect their functionalities. - Adjusted titles such as 'Dashboard' to 'Data Dashboard', 'API Info' to 'API Addresses', and 'Update Checker' to 'System maintenance' for improved user understanding. - Ensured consistency in naming conventions across all settings sections to streamline user experience and navigation. * feat(nav-group): enhance collapsible menu behavior and URL matching logic - Added controlled state management for collapsible menu items to automatically expand based on active sub-item paths. - Updated the URL matching logic in checkIsActive to improve handling of query parameters and ensure accurate navigation state detection. - Refactored the collapsible component to utilize the new state management, enhancing user experience in the sidebar navigation. * feat(system-settings): update system settings navigation and redirect logic - Changed the link in the profile dropdown to point directly to the general section of system settings with a search parameter for section identification. - Implemented a redirect in the general settings route to ensure users are directed to the default section if no section parameter is provided, enhancing navigation consistency. * feat(system-settings): unify route configuration for settings sections - Refactored route configuration for various system settings sections (auth, content, general, integrations, maintenance, models, request limits) to utilize a new `createSettingsRouteConfig` function. - This change consolidates the repetitive logic of creating search schemas and handling redirects, improving code maintainability and readability. - Enhanced navigation by ensuring default sections are loaded when no section parameter is provided. * feat(url-utils): enhance URL handling and matching logic - Introduced a new utility function `urlToString` to convert various URL formats (string and object) into a standardized string format. - Updated the `checkIsActive` function to utilize `urlToString`, improving the accuracy of URL matching and handling of query parameters. - Refactored URL comparison logic to ensure consistent behavior across different URL types, enhancing navigation state detection. * feat(system-settings): validate DataExportDefaultTime for improved data handling - Introduced a new function `validateDataExportDefaultTime` to ensure the `DataExportDefaultTime` value is either 'week', 'hour', or 'day', defaulting to 'hour' for unexpected values. - Updated the `DataExportDefaultTime` assignment in the settings section to utilize this validation function, enhancing data integrity and user experience. * perf(system-settings): Improve the i18n of system settings content - Changed button labels in various sections to use consistent capitalization and translation functions, enhancing user experience. - Updated validation messages in schemas to utilize translation functions for improved internationalization support. - Ensured all user-facing strings are properly translated, improving accessibility for non-English users. * fix(system-settings): update ApiInfoFormValues type inference for improved schema validation - Changed the type inference for ApiInfoFormValues to utilize ReturnType of createApiInfoSchema, ensuring accurate type representation and enhancing type safety in the API info section. * fix(chat-settings): improve validation logic for chat settings schema - Updated the validation logic to ensure that null values are correctly handled and that only objects are accepted as valid items in the chat settings schema. - Simplified error handling by removing the error message from the catch block, providing a consistent user-facing message for invalid JSON strings. * fix(system-settings): enhance validation error handling in uptime-kuma schema - Updated the validation logic for category name, URL, and slug fields to use an object format for error messages, improving clarity and consistency in user feedback. - Ensured that all validation messages are properly structured to enhance internationalization support. * fix(i18n): add translations for Uptime Kuma group management - Added English, French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support. - Included validation messages for category name and slug fields across multiple languages to improve user feedback and accessibility. * fix(system-settings): improve validation error message structure for SystemName - Updated the validation logic for the SystemName field to use an object format for error messages, enhancing clarity and consistency in user feedback. - This change aligns with recent improvements in internationalization support across the system settings schemas. * perf(i18n): add new validation error message translations - Added translations for the new validation error message "Invalid JSON format or values out of allowed range" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update enhances internationalization support by ensuring users receive clear feedback across multiple languages. * fix(i18n): update Japanese translation for payment method configuration message - Corrected the Japanese translation for the message regarding payment methods configuration to use the term "メソッド" instead of "方法" for improved accuracy and consistency in user feedback. - This change enhances the clarity of the message for Japanese-speaking users. * fix(i18n): remove unnecessary loading messages from French translations - Removed the French translations for "Loading settings...", "Loading maintenance settings...", and "Loading content settings..." to streamline the localization file. - This change improves the clarity and relevance of the translations provided to users. * fix(i18n): add translations for Uptime Kuma group management in multiple languages - Added French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support. - This update improves user experience by providing clear and consistent messaging across different languages. * fix(validation): enhance pricing schema error messages and add translations - Updated the pricing schema to include localized error messages for validation, ensuring users receive clear feedback when input values are invalid. - Added new translations for "Exchange rate is required" and "Exchange rate must be greater than 0" in English, French, Japanese, and Chinese to improve internationalization support. - This change enhances user experience by providing accurate and contextually relevant messages across multiple languages. * fix: codex Unsupported parameter: max_output_tokens * fix(model-mapping-editor): simplify JSON parsing logic in useEffect * fix: jimeng i2v support multi image by metadata * refactor(models): restructure models section handling and improve UI components - Replaced tab-based navigation with section-based navigation for better clarity and organization. - Introduced a new section registry to manage model sections, including 'metadata' and 'deployments'. - Updated the ModelsContent component to reflect the new section structure and added a Create Deployment button. - Removed the ModelsTabs component as it was no longer needed. - Enhanced internationalization support by adding new translations for section descriptions and management tasks. - Adjusted sidebar configuration to accommodate the new section structure. * fix: update warning threshold label from '5$' to '2$' * fix: video content api Priority use url field * fix: update abortWithOpenAiMessage function to use types.ErrorCode * feat(deployment): introduce CreateDeploymentDrawer component and update dialog references - Replaced the CreateDeploymentDialog with a new CreateDeploymentDrawer component for improved user experience. - Added comprehensive form handling for deployment creation, including validation and price estimation features. - Updated internationalization files to include new translations for UI elements and descriptions related to deployment configuration. - Enhanced the ModelsContent component to integrate the new drawer for creating deployments. * perf(i18n): enhance internationalization for models table and columns - Updated labels and titles in the ModelsTable and useModelsColumns components to utilize translation functions for improved localization. - Changed static text for vendor and sync status to dynamic translations, enhancing user experience for non-English speakers. - Updated empty state messages in the ModelsTable to support internationalization, ensuring clarity for all users. * fix: fix email send * fix: issue where consecutive calls to multiple tools in gemini all returned an index of 0 * fix: replace Alibaba's Claude-compatible interface with the new interface * fix: Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. * feat: log shows request conversion * feat: optimized display * feat: optimized display * feat: optimized display * fix: codex rm Temperature * Revert "fix: video content api Priority use url field" * feat: requestId time string use UTC * feat(qwen): support qwen image sync image model config * feat: sync old ui * feat: more ui sync * feat: replace theme * fix build * refactor(web): revert theme colors and variables in CSS Updated color variables for light and dark themes to improve consistency and visual appeal. * feat(deployment): enhance deployment access guard and model deployment settings - Introduced loading phase management in the DeploymentAccessGuard component to provide better user feedback during connection checks. - Updated the ModelsContent component to prefetch the deployments list while checking connection status, improving data readiness. - Implemented a caching mechanism for connection status in useModelDeploymentSettings to optimize performance and reduce unnecessary API calls. - Enhanced loading states and error handling for improved user experience during deployment settings retrieval and connection testing. * feat(i18n): add new translations for connection and loading states across multiple languages - Introduced translations for "Checking connection" and "Loading configuration" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update enhances the internationalization support, providing clearer user feedback during connection checks and loading phases. * refactor(pagination): adjust layout and styling for pagination component - Updated the pagination component to improve layout by removing unnecessary width constraints and enhancing responsiveness. - Increased minimum width for pagination text to ensure better visibility and alignment across different screen sizes. * feat(i18n): implement translations for various UI elements across multiple components - Updated several components to utilize the translation function for titles and placeholders, enhancing internationalization support. - Added new translation entries for "Filter by name or key..." and "Log Type" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update improves user experience by providing localized text in the ChannelsTable, SummaryCards, ApiKeysTable, RedemptionsTable, UsageLogsTable, and UsersTable components. * feat(i18n): integrate translation support in SummaryCards component - Added the useTranslation hook to the SummaryCards component to enhance internationalization. - This update allows for localized text rendering, improving user experience for diverse language speakers. * feat(dashboard): refactor dashboard structure and introduce section-based navigation - Removed the tab navigation in favor of a section-based approach, enhancing user experience by providing clearer context for the dashboard content. - Introduced a new section registry to manage dashboard sections, allowing for easier expansion and maintenance. - Updated sidebar configuration to reflect the new section structure, ensuring proper navigation links are displayed. - Added translations for new section titles and descriptions to support internationalization. * feat(i18n): update time range labels and enhance translation support - Changed time range labels from shorthand (e.g., '1D') to full text (e.g., '1 Day') for better clarity. - Updated various components to utilize the translation function for time range labels, improving internationalization. - Added new translation entries for time ranges in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience across languages. * feat(dashboard): enhance type safety and improve component structure - Updated the Dashboard component to use specific types for model data and filters, enhancing type safety. - Introduced new types for announcements and FAQs, improving clarity and maintainability. - Refactored LogStatCards and UptimePanel components to utilize AbortController for better data fetching management. - Optimized the rendering of announcements and FAQs by using unique keys based on item IDs. - Improved theme management in ModelCharts by caching the ThemeManager import to reduce dynamic imports. * feat(agents): add comprehensive guidelines for React and Next.js development - Introduced a new set of best practices and optimization techniques for React and Next.js applications, aimed at enhancing performance and maintainability. - Included detailed rules covering various aspects such as event handling, API routes, rendering strategies, and state management. - Added extensive documentation in AGENTS.md and SKILL.md to support developers in adhering to these practices. - This update serves as a foundational resource for improving code quality and efficiency in React-based projects. * chore(web): update package.json dependencies - Removed outdated dependencies including @base-ui/react, @clerk/clerk-react, and others to streamline the project. - Updated remaining dependencies to their latest versions for improved performance and security. - This cleanup enhances the overall maintainability of the project. * feat(usage-logs): implement section-based navigation and enhance log management - Introduced a section registry for usage logs, allowing for better organization and navigation between different log categories (common, drawing, task). - Updated the UsageLogsContent component to dynamically render titles and descriptions based on the selected section. - Refactored UsageLogsTable and UsageLogsPrimaryButtons components to accept the active log category as a prop, improving modularity. - Enhanced sidebar configuration to support new section navigation, ensuring users can easily access different log types. - Updated routing to redirect to the default section if none is specified, improving user experience. * feat(i18n): enhance internationalization across usage logs components - Integrated the useTranslation hook in various components related to usage logs, including CommonLogsStats, UsageLogsTable, and column helpers. - Updated labels, titles, and messages to utilize translation functions, improving localization support. - Added new translation entries for log-related terms in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience for diverse language speakers. * feat(datetime-picker): integrate dayjs for date formatting - Added dayjs as a dependency to the project for improved date handling. - Updated the DateTimePicker component to use dayjs for formatting dates, enhancing consistency and readability of date displays. * feat(date-handling): replace date-fns with dayjs for improved date management - Updated the project to use dayjs instead of date-fns for date formatting and manipulation, enhancing consistency across components. - Refactored DatePicker, DateTimePicker, and other components to utilize dayjs for date-related functionalities. - Added a new dayjs configuration file to extend its capabilities with relative time support. - Updated AGENTS.md to reflect the new technology stack, emphasizing the use of dayjs for date handling. * refactor(agents): streamline front-end development guidelines and update technology stack - Revised AGENTS.md to condense front-end development standards and best practices, making it more accessible for developers and AI assistants. - Updated the technology stack section to reflect current dependencies, emphasizing the use of Bun, React 19, TypeScript, and other key libraries. - Enhanced the document structure with a new table format for better readability and navigation, including a comprehensive table of contents for quick access to sections. * feat(i18n): enhance date picker and datetime picker localization support - Integrated internationalization support in DatePicker and DateTimePicker components by adding locale handling for multiple languages (English, French, Japanese, Russian, Vietnamese, Chinese). - Updated the calendar component to accept a locale prop, ensuring proper localization of month and weekday labels. - Improved user experience by allowing date selection to adapt based on the user's language preference. * feat(layout): add SectionPageLayout component for structured page layouts - Introduced a new SectionPageLayout component to facilitate structured layouts for pages with sections, enhancing the organization of content. - Added subcomponents for Title, Description, Actions, and Content to improve clarity and maintainability of page structures. - Updated AGENTS.md to include guidelines on avoiding unnecessary destructuring of props for better code readability. * feat(layout): refactor components to use SectionPageLayout for improved structure - Replaced AppHeader and Main components with SectionPageLayout across multiple features including Channels, Dashboard, ApiKeys, Models, Redemption Codes, Usage Logs, Users, and Wallet. - Enhanced page organization by utilizing SectionPageLayout's Title, Description, Actions, and Content subcomponents, improving clarity and maintainability. - This update standardizes the layout structure across the application, facilitating a more cohesive user experience. * feat(usage-logs): enhance URL state management and redirection logic - Added useEffect to synchronize column filters with URL search changes, preventing infinite loops caused by inline references. - Improved redirection logic in usage logs to clear 'type' from the URL when the section is not 'common', enhancing user experience and URL cleanliness. * fix(usage-logs): disable global filter and update DataTableToolbar props - Disabled the global filter in the UsageLogsTable component to streamline the user interface. - Updated the DataTableToolbar component to accept a null customSearch prop, enhancing flexibility in toolbar configuration. * feat(routes): implement section-based routing for system settings and dashboard - Introduced section-based routing for system settings and dashboard features, enhancing navigation and organization. - Updated route definitions to include dynamic sections, allowing for more granular access to settings and dashboard components. - Refactored existing routes to redirect to default sections when no specific section is provided, improving user experience. - Added new section routes for models, usage logs, and system settings, ensuring consistency across the application. - Removed deprecated routes to streamline the routing structure and improve maintainability. * refactor(usage-logs): update column helper functions to require config parameter - Modified createFailReasonColumn and createProgressColumn functions to require a config parameter instead of allowing it to be optional. - Simplified destructuring of config to enhance clarity and ensure necessary properties are always provided, improving code reliability. * refactor(usage-logs): improve section ID validation and routing logic - Introduced a type guard function, isUsageLogsSectionId, to validate section IDs, enhancing type safety and reducing the need for casting. - Updated UsageLogsContent to utilize the new validation function for determining the active category, improving clarity and reliability. - Refactored routing logic to use isUsageLogsSectionId for section validation, ensuring proper redirection to the default section when necessary. * refactor(calendar): update locale documentation for i18n support - Revised the locale prop documentation in the Calendar component to specify the use of react-day-picker for internationalization, clarifying the expected locale setup for users. * chore(i18n): remove redundant user information description from locale files - Removed the user information description from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to streamline translations and improve clarity. * chore(i18n): streamline locale files by removing redundant entries - Removed unnecessary entries from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to enhance clarity and reduce clutter. - Adjusted translations for consistency and improved user experience across multiple languages. * chore(sidebar): remove deprecated usage logs route from sidebar config - Eliminated the '/usage-logs' entry from the sidebar configuration to streamline navigation and improve clarity in the sidebar structure. * refactor(redemption-codes): enhance internationalization support and improve UI consistency - Updated various components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Added meta labels for table columns to improve accessibility and clarity. - Revised confirmation and action texts in dialogs and tooltips to leverage translation, enhancing user experience. - Updated locale files to include new translations for improved clarity and consistency. * feat(masked-value-display): add MaskedValueDisplay component for sensitive data handling - Introduced a new MaskedValueDisplay component to display masked values with a popover for full value visibility and a copy button for easy access. - Updated api-keys-columns and redemptions-columns to utilize the new component, enhancing code reusability and UI consistency. - Revised translation keys in locale files to remove colons for improved clarity. * refactor(url-utils): simplify query parameter matching logic in checkIsActive function - Updated the checkIsActive function to streamline the logic for matching URLs with and without query parameters. - Removed unnecessary checks for query parameters when matching base paths, improving clarity and maintainability of the code. * fix(channels-table): update group filter label to use translation function - Replaced hardcoded 'All Groups' label with a translation function call to enhance internationalization support in the ChannelsTable component. * chore(api-keys): remove deprecated API key action messages and related exports - Deleted the api-key-actions.ts file, which contained action messages for enabling, disabling, and deleting API keys. - Updated index.ts to remove the export of getApiKeyActionMessage, streamlining the codebase by eliminating unused functionality. * refactor(i18n): enhance internationalization support across various components - Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Revised constants and labels in the channels and redemption codes features to use i18n keys, improving maintainability and clarity. - Ensured that success and error messages leverage translation functions, enhancing user experience and accessibility. - Streamlined the handling of i18n keys in the constants files for better organization and clarity. * refactor(i18n): enhance translation support across various components - Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Revised pagination and status labels to use i18n keys, improving maintainability and clarity. - Enhanced response time formatting to support internationalization, allowing for localized display of time values. - Updated locale files to include new translations for improved clarity and consistency. * docs(AGENTS): add type checking requirement for TypeScript changes - Included a new guideline stating that type checks must be executed after modifying TypeScript or TSX code, ensuring no type errors are left unresolved. - Updated the document to reflect this addition in the relevant section for better clarity on coding standards. * feat(combobox-input): add ComboboxInput component for enhanced token selection - Introduced a new ComboboxInput component to facilitate token name selection with search and filtering capabilities. - Integrated the ComboboxInput into the UsageLogsFilterDialog for improved user experience when filtering by token name. - Updated locale files to include new translations for user-facing strings related to token filtering. * feat(combobox): integrate translation support for custom value prompt - Added translation functionality to the Combobox component, replacing hardcoded text with a translatable string for the custom value prompt. - Utilized the useTranslation hook from react-i18next to enhance internationalization support, ensuring a consistent user experience across different languages. * refactor(i18n): improve Chinese translations for consistency and clarity - Adjusted spacing in various Chinese translations to enhance readability and maintain consistency across the locale file. - Updated multiple user-facing strings to ensure proper formatting and alignment with localization standards. * feat(calendar): add CalendarDropdown component for enhanced dropdown functionality - Introduced a new CalendarDropdown component to improve user interaction with dropdown selections in the calendar. - Implemented state management for dropdown visibility and selection handling, enhancing the overall user experience. - Updated styling for dropdown elements to ensure consistency and better alignment with the UI design. * fix(balance-query-dialog): handle null currentRow and improve usage query logic - Updated the BalanceQueryDialog component to safely access currentRow properties using optional chaining. - Added a check to ensure currentRow is not null before proceeding with usage queries, preventing potential runtime errors. - Refactored the handleQueryCodexUsage function to use a local variable for currentRow, enhancing code clarity. * feat(i18n): add new translations for batch creation and channel updates - Added new translation strings for batch creation instructions across multiple languages, enhancing user guidance. - Included translations for the "Update Channel" prompt to improve clarity in channel configuration settings. - Ensured consistency in terminology across locale files for better user experience. * feat(channel-mutate-drawer): improve API key input handling and update translations - Refactored the API key input logic in the ChannelMutateDrawer component to enhance readability and maintainability. - Added new placeholder translations for batch creation and existing key prompts in multiple languages, improving user guidance. - Ensured consistency in translation strings across locale files for better user experience. * feat(fetch-models-dialog): implement sorting for model categories - Added a new function to sort model categories alphabetically, placing 'Other' at the end for easier navigation. - Updated the rendering logic in the FetchModelsDialog component to utilize the new sorting function for both new and existing models, enhancing user experience. * refactor(wallet-stats-card): standardize props usage and improve layout consistency Standardizes props usage and improves layout consistency in wallet stats card Refactors the wallet stats card component to: - Use props directly instead of destructuring for consistency - Add min-w-0 to prevent content overflow - Adjust text sizing with break-all for proper wrapping - Implement responsive font sizes (3xl on mobile, 4xl on larger screens) - Improve leading and tracking for better readability Refactor wallet stats card for consistency and layout Standardizes props usage and improves layout consistency in wallet stats card - Uses props directly instead of destructuring for consistency - Adds min-w-0 to prevent content overflow - Adjusts text sizing with break-all for proper wrapping - Implements responsive font sizes (3xl on mobile, 4xl on larger screens) - Improves leading and tracking for better readability * feat(web): add subscription management and admin settings UI * feat(web): add subscription management and admin settings UI - Add subscription management module (plans, pricing, toggle status, and related dialogs/tables with Stripe/Creem integration notes) - Add channel affinity (rules and cache stats), Waffo integration, performance, and Grok model sections to system settings, with extended types and section registry - Add status code mapping validation/risk warnings, upstream update hooks, and utilities for channels; add available models and sidebar module cards to user profile - Add chat2link route and useMinimumLoadingTime, useTableCompactMode shared hooks Made-with: Cursor * fix: remove duplicate GenerateOAuthCode and add missing TaskBulkUpdate - remove duplicate GenerateOAuthCode from github.go since oauth.go already has the generic version. - add model.TaskBulkUpdate for bulk update by upstream task_id strings, fixing task_video.go build failure. * feat(router): add chat2link and subscriptions routes - register /chat2link page route under authenticated layout. - register /subscriptions/ page route under authenticated layout. - update auto-generated routeTree type definitions and route mappings. * feat(docker): add development environment setup with Docker Compose - Introduced docker-compose.dev.yml for local development, including services for new-api, Redis, and PostgreSQL. - Created Dockerfile.dev for backend-only builds, optimizing the development workflow. - Updated makefile to include new commands for starting backend services and frontend development. * feat(web): complete i18n coverage for setup wizard and add language switcher - wrap all hardcoded English strings in setup-wizard, database-step, usage-mode-step, and complete-step with t() calls, covering step titles, descriptions, form validation messages, and fallback strings. - add LanguageSwitcher component to the top-right corner of the setup page so users can switch language during initial setup. - register 25 dynamic i18n keys in static-keys.ts and provide full translations for zh/en/ja/fr/ru/vi. * feat(i18n): internationalize default version text in workspace-switcher - remove hardcoded 'Unknown version' default, use t('Unknown version') for i18n fallback - add "Unknown version" translation entries across all 6 locale files (zh/en/fr/ru/ja/vi) * feat(i18n): add full i18n coverage for channel-affinity settings page - replace Chinese t() keys with English keys across three channel-affinity components to align with new frontend i18n conventions. - add 51 translation entries to all 6 locale files (en/zh/ja/fr/ru/vi) covering main page, rule editor, and cache stats dialog. - register section-registry dynamic keys in static-keys.ts. * feat(i18n): add full i18n coverage for Waffo payment settings page - replace Chinese i18n keys with English keys in waffo-settings-section.tsx for consistency. - wrap previously hardcoded labels (Pay Method Type / Pay Method Name) with t(). - add 26 Waffo-related translation entries across all 6 locale files (en/zh/fr/ru/ja/vi). * feat(i18n): add missing translations for global model settings page - add all 6 locale translations for 3 missing t() keys in global-settings-card. - register dynamically used 'Grok' key in static-keys.ts for i18n scanner coverage. * feat(i18n): add full i18n coverage for Grok model settings page - add translations in all 6 locales (en/zh/fr/ja/ru/vi) for grok-settings-card t() calls. - cover violation fee toggle, amount input, and official docs link labels. - include section-registry descriptionKey translation entries. * feat(i18n): add full i18n coverage for performance settings page - migrate all t() keys from Chinese to English to align with project conventions. - add translations for all 6 locales (en/zh/ja/fr/ru/vi) covering disk cache, system monitoring, log management, and stats dashboard sections. - remove 71 obsolete Chinese-keyed entries from every locale file. * fix(i18n): add 116 missing English translation keys across all locales - scan all t() calls to identify English keys used in code but absent from locale files. - add translations for zh/en/fr/ja/ru/vi, keeping key sets and sort order consistent. - covers system-settings, channels, models, auth, wallet and other modules. * fix(i18n): add missing translations for log cleanup quick-select and confirm dialog - wrap quick-select button labels (24 hours ago / 7 days ago / 30 days ago) with t(). - replace hardcoded English strings in purge confirm dialog with t() calls and date interpolation. - add 5 new translation keys across all 6 locale files (zh/en/fr/ja/ru/vi). * refactor(web): unify all time display with dayjs formatting - replace all toLocaleString/toLocaleDateString/toLocaleTimeString and manual padStart concatenation with dayjs.format(). - standardize output: datetime as YYYY-MM-DD HH:mm:ss, date as YYYY-MM-DD, time as HH:mm:ss. - add formatDateTimeStr, formatDateStr, formatTimeStr dayjs-based helpers in lib/format.ts. - update 12 files across core utils and feature components. * refactor(web): replace native datetime-local input with DateTimePicker in announcements - swap browser-native datetime-local for the project's DateTimePicker component to match the UI used in log cleanup and other pages. - convert between Date objects and ISO strings to bridge the form's string-based schema. * refactor(web): replace native HTML elements with design system components - replace ~35 native <button> with <Button> across pricing, profile, channels modules - replace native <input>/<textarea>/<label> with <Input>/<Textarea>/<Label> for consistent form styling - replace native <table> with <Table> components, <details>/<summary> with <Collapsible> - replace decorative <hr> with <Separator> to ensure global UI consistency * refactor(web): enhance profile components with design system consistency - update ProfileSecurityCard to use buttons for security actions, improving accessibility and styling. - modify AccountBindingsTab layout to a grid for better responsiveness and visual alignment. - refactor NotificationTab to utilize icons for notification methods, enhancing user experience and clarity. * fix(i18n): complete i18n coverage for profile page components - wrap passkey card status badges (enabled/disabled, backup state) and last-used text with t() - fix hardcoded button labels in security dialogs (change password, access token, delete account) - internationalize all 2FA dialog strings (setup, disable, backup codes) - fix email bind dialog description and button state text missing i18n - wrap remaining hardcoded strings in notification tab and checkin calendar - add all missing translation entries to zh.json and en.json * fix(i18n): enhance error messages with translations for deployment access and settings - wrap connection error messages in DeploymentAccessGuard and IoNetDeploymentSettingsSection with t() for internationalization. - add missing translation key for "io.net model deployment is not enabled or api key missing" in all locale files (en, fr, ja, ru, vi, zh). * 🧹 chore(web): resolve all ESLint errors and warnings Align the Vite/React frontend with the current ESLint flat config and React Compiler–related rules by fixing violations instead of broad suppression where practical. - Replace `any` with concrete types (`unknown`, `Record<string, unknown>`, domain types) where upstream/API shapes allow - Fix duplicate imports, unused bindings, `no-console`, and empty blocks - Address react-hooks issues: reorder declarations, memoize unstable callbacks (`useCallback`), extend dependency arrays, and use targeted disables only where sync-from-props in `useEffect` is intentional - Refactor `motion.create` usage in ai-elements shimmer to avoid creating components during render (static-components) - Stabilize TanStack Query/Mutation hook usage (query keys, `mutate` in deps) and add narrowly scoped rule disables where the linter conflicts with library patterns - Disable `react-hooks/incompatible-library` in ESLint config for TanStack Table / RHF false positives - Add file-level `react-refresh/only-export-components` disables for registry/provider/column modules that intentionally mix exports `bun lint` completes with 0 errors and 0 warnings. * ✨ feat(web): add subscription management to sidebar and align drawer with project conventions - Register "Subscription Management" nav item in the admin sidebar group with CreditCard icon pointing to /subscriptions - Add subscription module to sidebar config defaults and URL mapping so it integrates with the admin sidebar modules toggle in system settings - Add subscription entry to sidebar-modules-section moduleMeta for the maintenance settings UI - Refactor SubscriptionsMutateDrawer to follow the same patterns used by users, redemption-codes, and other mutate drawers: - Use shadcn Form/FormField/FormItem/FormControl/FormLabel/FormMessage instead of raw register() + Label + manual error display - Move SheetFooter outside the form with form attribute association - Use SheetClose for the cancel button - Reset form state on drawer close - Align SheetContent width (sm:max-w-[600px]) and spacing conventions * ✨ feat(web): overhaul UI/UX with Vercel Geist design alignment Refactor the entire frontend UI/UX to align with Vercel/OpenAI design principles, covering layout, animations, skeleton loading, and overall visual polish. Motion & Page Transitions: - Add centralized motion system (lib/motion.ts) with Vercel-style transition presets, stagger variants for tables, cards, and sidebars - Implement AnimatedOutlet for route-level page enter animations using TanStack Router pathname keying - Add PageTransition, StaggerContainer, StaggerItem, CardStagger, and TableStagger wrapper components for progressive reveal effects Skeleton Loading — Vercel Geist Style: - Replace shadcn default `animate-pulse` with Geist-style shimmer sweep animation (linear-gradient + background-position keyframes) - Add `--skeleton-base` / `--skeleton-highlight` CSS variables tuned for both light and dark themes with neutral oklch tones - Override auto-skeleton-react inline styles via CSS to unify all skeleton elements under the same shimmer effect - Update TableSkeleton with varied column widths for a natural feel - Add ContentSkeleton and QuerySkeleton wrappers for auto-skeleton integration with React Query error/loading states - Respect prefers-reduced-motion: disable shimmer for accessibility Layout & Sidebar: - Upgrade sidebar expand/collapse transitions to cubic-bezier easing - Add hover micro-interactions (background-color, color, transform) to sidebar menu buttons with smooth 150ms transitions - Fix oklch color compatibility in sidebar outline variant - Integrate AnimatedOutlet into AuthenticatedLayout for unified route-level animations Theme & CSS: - Streamline theme.css with cleaner oklch color definitions - Add CSS table row stagger-in animations with nth-child delays - Fix hover-scrollbar color bug (hsl → color-mix for oklch compat) - Add content-auto utility for long list rendering optimization Cleanup: - Remove deprecated skeleton-wrapper.tsx - Remove unused imports and dead code across components - Add empty-state, error-state, and loading-state utility components * 🐛 fix(docker): track bun.lock to fix Docker build failure Remove `web/bun.lock` from `.gitignore` so the lock file is committed to version control. The Dockerfile `COPY web/bun.lock .` instruction requires this file to be present in the build context, and ignoring it caused the build to fail with a "not found" error. * ⬆️ chore(web): upgrade dependencies and fix all type/lint errors Upgrade all frontend dependencies to latest stable versions: - lucide-react 0.562 → 1.7 (major: brand icons removed) - shiki 3.x → 4.x, eslint 9.x → 10.x, knip 5.x → 6.x - @rsbuild/core 1.3 → 1.7, @types/node 24 → 25 - tailwindcss/postcss 4.1 → 4.2, motion 12.25 → 12.38 - @tanstack/react-query 5.90 → 5.95, zod 4.3.5 → 4.3.6 - react 19.2.3 → 19.2.4, axios 1.13.2 → 1.13.6 - prettier 3.7 → 3.8, typescript-eslint 8.52 → 8.57 - Add missing optional deps: @xyflow/react, embla-carousel-react Resolve all TypeScript compilation errors introduced by upgrades: - Replace lucide-react brand icons (Github) with react-icons/si - Fix react-hook-form Control/Resolver generics for zod v4 - Fix Record<string, unknown> type constraints across API utils - Fix axios interceptor return types in lib/api.ts - Add type assertions for useSettings/useStatus hook returns - Resolve Badge variant, spread type, and route path mismatches Resolve all ESLint 10 errors: - preserve-caught-error: attach cause to re-thrown errors - no-useless-assignment: refactor redundant variable assignments - prefer-as-const: use `as const` over literal type assertions - no-unused-vars: prefix type-only schemas with underscore Update tsconfig lib from ES2020 to ES2022 for Error.cause support. * 🐛 fix(web): stop pricing model row from centering its content Wrapping the row in shadcn <Button variant='ghost'> inherits `justify-center`, and the inner flex container had no width, so `justify-between` collapsed and the row appeared centered. * feat: add Waffo payment integration and related UI components - Introduced Waffo payment method with support for custom icons and settings. - Updated payment settings section to include Waffo settings. - Added Waffo payment request handling in the wallet API. - Enhanced wallet recharge form to support Waffo payment methods. - Implemented hooks for Waffo payment processing. - Updated localization files for new Waffo-related strings. - Added new payment type and icon for Waffo in constants and UI components. - Refactored topup info handling to include Waffo payment methods and configurations. * feat(profile): add admin-only upstream model update notification setting * fix(web): make sidebar module user settings actually take effect Previously, saving sidebar module preferences in profile had no effect because the client ignored user-level sidebar_modules entirely. This fix wires user config into useSidebarConfig so the sidebar updates immediately without a page refresh. Changes: - Add UserPermissions type with sidebar_settings/sidebar_modules fields - Refactor useSidebarConfig to merge admin × user config with AND logic - Sync sidebar_modules to auth store on save for immediate UI updates - Conditionally render SidebarModulesCard based on user permissions - Treat null/empty user config as "do not narrow" for legacy users * feat(web): add custom OAuth provider CRUD and login button support Migrate custom OAuth from v1 to v2: - Admin CRUD UI with provider table, form dialog, preset templates, and OIDC discovery - Login page renders dynamic buttons for custom OAuth providers - Fix account bindings display showing "Not bound" text when already bound * feat(web): add ServerAddress, SMTPForceAuthLogin, CreateCacheRatio and group special usable settings Migrate missing v1 system settings to v2: - ServerAddress input in General > System Information - SMTPForceAuthLogin toggle in Integrations > Email - CreateCacheRatio JSON editor in Models > Ratio - Group special usable group rules editor in Models > Ratio * feat(web): wire user subscriptions dialog to users table row actions The UserSubscriptionsDialog component already existed but had no entry point in the users table dropdown menu. Add "Manage Subscriptions" menu item. * chore(web): update i18n translations for new settings and custom OAuth * 💎 refactor(web): redesign pricing page with flat, typography-driven layout * 🌐 chore(i18n): complete missing translations and normalize project config - Add 425+ missing translations across fr, ja, ru, zh, vi locales for subscription management, sidebar navigation, Grok settings, upstream model updates, pricing page, and other UI components - Add 37 missing i18n keys used in t() calls but absent from locale files (pricing filters, display options, audio/cache labels, etc.) - Fix stale tech stack info in CLAUDE.md, AGENTS.md, and project.mdc: React 18 → 19, Vite → Rsbuild, Semi Design → Radix UI + Tailwind - Fix i18n key format description: "Chinese source strings" → English - Deduplicate .cursor/rules/project.mdc to avoid triple-loading the same rules already present in root CLAUDE.md and AGENTS.md - Add i18n-translate Cursor skill for repeatable translation workflow * 🎨 refactor(web): redesign dashboard with flat, typography-driven layout Replace Card-based dashboard components with a flat, border-driven design system consistent with the pricing page, following the ui-style.mdc conventions. Overview section: - StatCard: replace Card wrapper with flat flex layout, monospace tabular values, uppercase tracking-wider labels, layered opacity hierarchy - PanelWrapper: replace Card/CardHeader/CardContent with rounded-lg border container and border-b header - SummaryCards: merge three stat cards into a single bordered container with divide-x separators; decouple border from stagger animation to prevent border deformation during entrance transitions - ApiInfoPanel/Item: full-width list rows with border-b separators, monospace route names, layered opacity for URLs and descriptions - AnnouncementsPanel: native button rows with hover:bg-muted/40, i18n for "Click for details" hint - FAQPanel: lighter border-border/60 accordion dividers, muted answer text - UptimePanel: uppercase tracking-wider group headers with bg-muted/30 background, monospace uptime percentages, fine-grained border opacity Models section: - LogStatCards: replace Card with rounded-lg border + divide-x grid, fix react-hooks/exhaustive-deps by destructuring props before useEffect - ModelCharts: replace Card+Tabs with bordered container + custom segmented control matching ui-style spec - Suspense fallbacks: match new flat skeleton layout with accurate column structure Animation: - Wrap models section in FadeIn with staggered delay - Keep CardStagger for overview panel grid (each panel has own border) Other: - Add ui-style.mdc cursor rule documenting the design language - Disable react-refresh/only-export-components for src/routes/** in eslint config (TanStack Router route files always export Route objects) - Fix zh.json: "Token-based" translation "基于令牌的" → "按量计费" * ✨ refactor(web): adopt flat dot-and-text design for all status badges Replace the bordered/colored-background StatusBadge and Badge components across the entire frontend with a minimal flat design: a small colored dot followed by colored text, eliminating visual noise from heavy borders, backgrounds, and rounded pill shapes. Key changes: - Redesign StatusBadge to use dot + text instead of bordered pill style, removing cva-based background/border variants in favor of exported dotColorMap and textColorMap lookup tables - Add children prop support to StatusBadge for flexible content rendering alongside the existing label prop - Migrate all Badge usages (except pricing page) to StatusBadge with appropriate variant mappings (default→info, secondary→neutral, outline→neutral, destructive→danger) - Consolidate adjacent multi-badge groups into single-dot layouts with dot separators (·) to reduce visual clutter in: - Channel balance columns (used + remaining) - Channel type column (type + IO.NET indicator) - User invite info column (invited + revenue + inviter) - Usage log stats bar (usage + RPM + TPM) - Usage log time/FRT column (time + FRT + stream status) - Subscription plan counts (active + expired) - Channel affinity scope/regex/key-source columns - Prefill group card headers (type + ID) - Export dotColorMap and textColorMap for direct use in custom inline layouts that need consistent status colors without the full component * ✨ refactor(web): redesign public layout and landing page with modern UI Overhaul the public-facing layout, header, and homepage to deliver a polished, animation-rich landing experience inspired by contemporary SaaS design patterns. Header: - Replace sticky header with fixed floating navbar that compacts into a pill-shaped glass-morphism bar on scroll (backdrop-blur + ring) - Add smooth 700ms cubic-bezier transitions for scroll-based shrinking - Build full-screen mobile menu overlay with staggered entry animations - Remove background color from logo container, show logo image directly Homepage sections: - Hero: gradient text title, radial gradient + grid pattern background, interactive terminal demo showcasing API request/response - Terminal demo: auto-cycles through gpt-4o, claude-sonnet-4-20250514, gemini-2.5-pro, deepseek-chat with smooth cross-fade transitions, clickable model badges, dual theme support (light/dark), fixed height - Stats: animated counters driven by IntersectionObserver with cubic-bezier easing, supports integer and decimal modes - Features: Bento grid layout with gap-px border technique, each card includes contextual visuals (model list, security badge, workflow) - How It Works: new three-step process section (Configure → Connect → Monitor) with connecting gradient line and numbered badges - CTA: gradient mesh background with scale-in scroll animation - Footer: streamlined brand column + link columns layout New components: - AnimateInView: IntersectionObserver-based scroll animation component supporting fade-up, fade-in, scale-in, fade-left, fade-right - HeroTerminalDemo: themed terminal with model carousel and live request/response preview CSS: - Add landing page scroll-triggered keyframe animations - Add terminal demo animations (blink cursor, spinner, pulse indicator) - Respect prefers-reduced-motion throughout i18n: - Add 17 new translation keys across all 6 locales (en/zh/fr/ja/ru/vi) * ✨ feat(web): align usage logs and channels with legacy UI Usage logs - Show Refund (type 6) in detail dialog and hide conversion chain for refunds - Sync filter dialog state from URL for model, token, group, username, and requestId Channels - Support optional stream flag in channel test API, actions, and test dialog - Show upstream model update badges (+added / -removed) on fetchable channel types - Add form fields and drawer toggles for upstream model update check and auto-sync - Persist upstream model update flags in channel settings JSON for fetchable types i18n - Add locale strings for upstream model update UI (en, zh, fr, ja, ru, vi) * 🐛 fix(web): prevent transient vertical scrollbar on tables during animations Add overflow-y-clip to the shared Table container (data-slot=table-container) alongside overflow-x-auto. Setting overflow-x to auto implicitly pairs with overflow-y: auto in browsers, which made the table shell briefly show a vertical scrollbar during route enter motion (y/blur) and table row stagger. Remove the redundant descendant selector workaround from the model pricing GroupPricingSection; behavior is now covered globally by the Table component. * 🏗️ refactor(web): redesign console layout with fixed header, scrollable content, and pinned footer Overhaul the authenticated console layout to match the OpenAI dashboard pattern: header and page title bar stay fixed at the top, only the content area scrolls, and table pagination is pinned to the bottom. Layout architecture: - Lock SidebarInset to full viewport height (h-svh) so all inner regions are controlled by flexbox instead of document scroll - Convert Main from a generic div to a semantic <main> flex container with overflow-hidden, removing the legacy `fixed` prop and `data-layout` attribute - Strip scroll-shadow logic and `fixed` prop from Header/AppHeader; the header is now naturally fixed as a shrink-0 flex child - Restructure SectionPageLayout into three flex regions: a shrink-0 title bar, a flex-1 overflow-auto content area, and a shrink-0 footer portal target with empty:hidden - Add min-h-0 to AnimatedOutlet wrappers to prevent flex overflow Footer portal system: - Introduce PageFooterProvider / PageFooterPortal (React Context + createPortal) so deeply nested table components can render their DataTablePagination into the fixed footer without prop drilling - Migrate all 8 data tables (api-keys, channels, users, models, deployments, usage-logs, subscriptions, redemption-codes) to use PageFooterPortal for pagination Page-level fixes: - Profile: wrap content in a scrollable flex child with proper padding - SystemSettings: remove overflow-auto from wrapper to avoid nested scrollbars (sub-pages manage their own scroll) - Playground / Error pages: remove obsolete `fixed` props API keys UX improvement: - Replace inline key show/hide toggle with a Popover-based reveal, removing toggleKeyVisibility and keyVisibility state from the provider context Cleanup: - Remove dead CSS rule for body:has([data-layout='fixed']) - Remove unused `fixed` prop from Header, AppHeader, and Main types - Export PageFooterPortal from layout barrel file * 💅 refactor(web): polish table UI consistency and add pagination transitions - Standardize primary action buttons (Create, Add, Search) to size="sm" across all pages for visual consistency with channels and models - Redesign NumericSpinnerInput with minimal inline style: plain text by default, hover-revealed +/- buttons, click-to-edit — replacing the clunky bordered input with stacked chevron arrows - Fix vertical scrollbar in channels group column by replacing overflow-x-auto with overflow-hidden (redundant with +N collapse) - Simplify API keys group column: replace colorful StatusBadge pairs with clean typography using opacity hierarchy and dot separators - Move API key copy loading indicator from key text to the copy button itself, eliminating layout shift during key resolution - Reduce page title from text-2xl to text-lg and subtitle to text-sm in SectionPageLayout for a more compact header - Add smooth opacity transition (duration-150) on all 7 server-paginated tables during background data fetches (isFetching && !isLoading), with pointer-events-none to prevent interaction during loading - Constrain usage logs Details column width (size: 200, maxSize: 220) * 🐛 fix(web): restore missing padding on system settings content The console layout refactor in d2150469 moved padding ownership from Main onto each route, but SystemSettings was missed — its Outlet wrapper had no padding, so the content area sat flush against the sidebar and top nav. Add `px-4 pt-6 pb-4` to match the vertical rhythm used by SectionPageLayout and the Profile page. * 📱 refactor(web): standardize mobile responsive layout across all table pages Unify mobile experience for all data table pages (channels, keys, models, deployments, usage-logs, users, redemption-codes, subscriptions) with a consistent layout pattern and cleaner header area. DataTableToolbar: - Redesign mobile layout: full-width search input + collapsible filter toggle button with active filter count badge - Filters, additional search, and reset button collapse into an expandable section on mobile, keeping the default view compact - Desktop layout remains unchanged SectionPageLayout: - Tighten mobile spacing (padding, gaps) for higher content density - Scale down title (text-base) and description (text-xs) on mobile - Shrink action button gaps on small screens ChannelsPrimaryButtons: - Move Tag Mode and Sort by ID toggles into the "More" dropdown on mobile (via DropdownMenuCheckboxItem), freeing header space - Desktop toggle switches remain visible outside the dropdown MobileCardList (shared component): - Compact list-item layout with title + badge header row and side-by-side key fields, replacing individual card components - Structured (CompactRow) and fallback (FallbackRow) rendering modes driven by column meta (mobileTitle, mobileBadge, mobileHidden) New MobileCardList integration: - Users table: username as title, status as badge; hide id, display_name, invite_info on mobile - Redemptions table: name as title, status as badge; hide id, created_time, expired_time, used_user_id on mobile - Subscriptions table: plan title as title, enabled as badge; hide id, sort_order, reset, payment, total_amount, upgrade_group on mobile Column meta updates: - Add mobileTitle/mobileBadge/mobileHidden meta across all 8 table column definitions for consistent mobile field prioritization Minor fixes: - Hide Subscriptions Stripe/Creem alert on mobile - Disable card hover animations on mobile via CSS media query * 🐛 fix(web): sync favicon with custom system logo Favicon stayed at the hardcoded /logo.png while document.title already followed system_name, leaving tab icon and site branding out of sync. Apply the logo as favicon from localStorage cache on startup, refresh from getStatus(), and re-apply when useSystemConfig finishes preloading. Extract applyFaviconToDom helper into lib/dom-utils with idempotent guard to avoid redundant DOM writes. * ✨ feat(web): add channel affinity rule templates and CreateCacheRatio visual editing Port missing features from legacy frontend (b8650b9 merge) to the new React frontend: - Add Codex CLI and Claude CLI channel affinity rule templates with header passthrough presets (pass_headers operations for Originator, Session_id, X-Codex-*, X-Stainless-*, Anthropic-*, etc.) - Introduce "Add Rule" dropdown menu with blank, Codex CLI, and Claude CLI template options in the channel affinity settings page - Add "Fill Templates" button to batch-append both CLI templates with duplicate name resolution and confirmation dialog - Support templateKey prop in RuleEditorDialog to pre-fill form fields from selected template, auto-expanding advanced settings when a param_override_template is present - Add CreateCacheRatio support to the model ratio visual editor, edit dialog, and form — previously only editable in JSON mode, now fully integrated into the visual table column, add/edit dialog fields, and save/delete handlers * 🐛 fix(web): fix content-type detection bugs in About and Home pages - Fix About page URL detection: replace naive `startsWith('https://')` with proper `new URL()` validation to support both http and https, and handle untrimmed input that previously caused silent misdetection - Fix About page HTML detection: remove overly broad `startsWith('<')` and `endsWith('>')` checks that could misclassify Markdown or XML content; align with LegalDocument's regex-only `isLikelyHtml` approach - Fix Home page URL detection: same `startsWith('https://')` bug, replaced with `new URL()` protocol validation - Refactor About page to use early-return pattern instead of deeply nested ternary expressions for better readability - Replace About loading spinner with Skeleton placeholder consistent with LegalDocument - Add `prose prose-neutral dark:prose-invert` typography classes to About HTML/Markdown rendering for proper dark mode support - Remove unused `Code` icon import from About page * ✨ feat(web): port missing features from legacy frontend and complete i18n Backport and enhance several features from the old frontend (web/old) that were missing or incomplete in the new React frontend: - Playground & channel test: parse structured JSON error responses from SSE streams and non-streaming API calls, extract error codes, and display actionable UI for `model_price_error` (admin settings link) - User management: replace local quota manipulation with atomic server-side quota adjustments (add/subtract/override) via dedicated API endpoint, making the quota field read-only in the edit drawer - Subscriptions: display next quota reset time for active subscriptions - Dashboard: limit model ranking chart to top 20 models with an "Other" bucket, add dimension tooltips with sorted values and totals to model call trend and user consumption trend charts - i18n: add 24 new translation keys across all 6 locales (en, zh, fr, ja, ru, vi) for the newly introduced UI elements and messages * 🎨 feat: add backend-configurable frontend theme switching (default/classic) Introduce runtime frontend theme switching so administrators can switch between the new frontend (Radix UI + Tailwind) and the classic frontend (Semi Design) from the settings page without restarting the server. Directory restructuring: - Move new frontend from web/ to web/default/ - Move classic frontend from web/old/ to web/classic/ - One-frontend-per-folder layout for extensibility Backend (injection pattern): - Add setting/system_setting/theme.go with GlobalConfig.Register("theme") so the DB key "theme.frontend" is handled automatically by handleConfigUpdate — no switch-case in updateOptionMap needed - Use atomic.Value in common.GetTheme()/SetTheme() for lock-free concurrent reads on the hot path (static file middleware) - Add themeAwareFileSystem that delegates to the correct embedded FS based on the current theme at request time - Embed both frontends into the binary via go:embed - Add controller validation for theme.frontend values - Expose theme in GET /api/status response Frontend settings UI: - New frontend: add "Frontend Theme" select in System Information section using Radix UI Select + react-hook-form + Zod validation - Classic frontend: add "前端主题" select in Personalization section using Semi Design Form.Select Build system: - Update Dockerfile with multi-stage builds for both frontends - Update Makefile with separate build targets for each frontend - Update GitHub Actions release workflow for dual frontend builds i18n: - New frontend: add translations for all 6 locales (en/zh/fr/ja/ru/vi) - Classic frontend: add translations for all 7 locales (en/zh-TW/ja/fr/ru/vi) - Fix zh "AI Proxy Library" → "AI 代理库" Documentation: - Update CLAUDE.md, AGENTS.md, .cursor/rules/project.mdc to reflect the new web/default/ and web/classic/ directory structure * ✨ feat(web): add allow_speed passthrough for Claude channels, fix multi-key index and inference_geo scope - Add `allow_speed` toggle for Anthropic (type 14) channels to control Claude inference speed mode passthrough, with full form schema, settings persistence, and UI switch - Fix `allow_inference_geo` to also apply to Anthropic (type 14) channels, not just OpenAI (type 1), matching the backend behavior for Claude data residency region control - Fix multi-key management dialog to display 1-based key indices instead of 0-based (#{key.index + 1}) - Fix TypeScript type error in section-registry by adding type assertion for theme.frontend enum - Add i18n translations for all new keys across 6 locales (en, zh, fr, ja, ru, vi) * 🧹 chore: clean up editor configs, consolidate agent skills, and set classic as default theme - Add .cursor/ to .gitignore and remove tracked editor config files (.cursor/rules/, .cursor/skills/) from version control - Consolidate .agents/skills/vercel-react-best-practices by keeping only the compiled AGENTS.md and removing redundant SKILL.md and 57 individual rule files under rules/ - Change default frontend theme from "default" to "classic" in both common/constants.go init and setting/system_setting/theme.go * feat: Frontend Tiered Pricing, Waffo Payments, and Rsbuild 2 Upgrade (#24) * feat(ui): add codex extra limits, key last used, and admin audit surfaces - codex usage dialog: render `additional_rate_limits` with `RateLimitGroupSection` and typed base/secondary window data. - api keys table: add "Last Used" column from `accessed_time`. - usage log details: show top-up audit and manage operator for admins; extend `LogOtherData` audit fields; broaden IP display; warn when legacy records lack audit data. - billing history: show user id badge for admins; add zh i18n for new strings. * feat(web): add dynamic pricing breakdown and Waffo Pancake payments - add billing-expr parsing and DynamicPricingBreakdown; surface tiered_expr in model list/details. - extend PricingModel with billing_mode, billing_expr, and pricing_version for backend parity. - add Waffo Pancake integration settings, amount/pay APIs, hook, and recharge flow wiring. - update payment confirm/recharge UI and Chinese locale strings. * feat(pricing): add tiered billing editor and tool price settings - introduce tier-expr and extend billing-expr (time/param conditions, combine/split helpers, editor utilities) for visual tiers and request rules. - support tiered_expr in model ratio dialog, form, and visual editor with billing_setting fields and default JSON placeholders. - add TieredPricingEditor and tool price settings UI plus i18n updates. * chore(web): bump rsbuild to v2 and align build config - upgrade @rsbuild/core, @rsbuild/plugin-react, and Rspack 2 transitives; bump TanStack Router packages and refresh bun.lock. - replace deprecated performance.chunkSplit with top-level splitChunks cache groups for react, radix, and tanstack vendors. - factor dev server proxy into devProxy; set legalComments to none in prod; enable performance.buildCache keyed by VITE_REACT_APP_VERSION. - TanStack Router plugin: enable autoCodeSplitting only in production for faster dev navigation and HMR. * fix(i18n): update translations for API keys and Waffo Pancake settings - Corrected translations for "API Private Key" and "Merchant ID is required" across multiple languages. - Added new translation for "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups." - Updated various existing translations to ensure consistency and clarity in user interface text. * refactor(code-block): simplify code highlighting and improve theme handling - Updated the highlightCode function to support dual themes in a single call, reducing complexity. - Removed unnecessary state management for dark theme HTML, streamlining the component. - Enhanced CSS for Shiki themes to ensure proper token color application in dark mode. * refactor(wallet): use isWaffoPancakePayment for pancake payment dispatch - replace the waffo_pancake string literal with the shared helper for consistency with use-payment and PAYMENT_TYPES. - centralize the value so a constant change does not require hunting for typos in multiple call sites. * fix(wallet): validate waffo pancake checkout url and safe open - allow only parseable http/https redirect targets from the backend, rejecting dangerous schemes. - pass noopener and noreferrer in window.open to reduce reverse tabnabbing. - show a toast and abort on invalid URLs; add i18n entries across locales. * fix(wallet): harden payment icon image URLs - add normalizeHttpIconUrl to allow only http(s) after resolution and reject userinfo in URLs. - set referrerPolicy, lazy loading, and async decode on the icon <img> to cut referrer leakage. - fall back to built-in icons on invalid URLs, same as when iconUrl is missing. * fix(pricing): label param() conditions as body param in dynamic pricing - non-header request rules map to `param()`, not query strings. - align with tiered pricing editor by using the existing `Body param` string. * fix(rsbuild): update legalComments handling in build config - Rely on Rsbuild's default legalComments setting in all modes to ensure compliance with open-source licensing requirements. - Clarified comments to explain the implications of omitting legalComments in production. * fix(i18n): correct billing and codex UI strings in locale files - restore ~83 en.json values to English (tool pricing, audit text, alipay label, etc.). - add proper fr/ru/vi/ja strings so those locales no longer copy zh. - change five locale files only; zh.json unchanged. * fix(i18n): update locale files for improved translations and sync report - Added missing translations and corrected existing strings in English, French, Japanese, Russian, Vietnamese, and Chinese locale files. - Updated the sync report to reflect zero missing translations across multiple locales. - Enhanced the untranslated count for Japanese locale to ensure completeness. - Changed the base locale from zh.json to en.json for better alignment. * chore(agents): add i18n-translate agent skill - add `.agents/skills/i18n-translate/SKILL.md` documenting locale layout under `web/default` and `bun run i18n:sync` usage. - capture a repeatable maintainer workflow with embedded script examples to find missing keys and untranslated values. - give agents a clear path to complete and verify translations across en, zh, fr, ja, ru, and vi. * feat(settings): hide frontend theme setting (#25) * feat(settings): hide frontend theme setting - add a local hidden feature flag with window.newapiUnlock support. - hide the frontend theme option by default and reveal it immediately after unlock. * feat(settings): support click unlock for frontend theme setting - add a shared hidden click unlock hook for repeated-click gated UI. - reveal the frontend theme option after triple-clicking the system information title. - preserve the Doubao API address ten-click unlock behavior and remove global unlock functions. * feat(sync 59337e9): Sync classic tiered billing, upstream price synchronization, and model management features to web/default (#26) * feat(skill): add classic-to-default-sync skill for auditing and syncing web/classic changes to web/default - Introduced a new skill to inspect a given commit's changes in web/classic and synchronize features and fixes to web/default. - Documented workflow steps for extracting diffs, mapping changes, triaging, implementing, and reporting on the synchronization process. - Emphasized quality standards and internationalization considerations for new user-visible strings. * feat(web/default): sync billing and model management features from classic - add `len` condition variable (total input context length); introduce BILLING_PRICING_VARS / BILLING_CONDITION_VARS to separate pricing vars from condition-only vars; fix tier condition regex to accept `len`. - rewrite upstream ratio sync components to support per-model grouped rows and new ratio types (create_cache, image, audio, billing_expr). - add LlmPromptHelper component; update tiered presets to use `len` for conditions; add GLM-4.5 Air, Doubao Seed 1.8, Qwen3 Omni Flash, and weekend-discount presets. - add created_at / last_login_at columns to users table; add "Removed Models" tab to FetchModelsDialog for mapping source keys not in the models list. - add extractMappingSourceModels helper; update dynamic-pricing-breakdown to use system currency settings; add 19 i18n keys across all locales. * ✨ feat(default): surface tiered billing in usage logs and gate Passkey ops behind 2FA Continues the classic-to-default sync (commit 1be6cdb) by porting the remaining audit-log, pricing-hint, and Passkey lifecycle features from web/classic to web/default using the default frontend's component patterns (Radix UI, Tailwind, shadcn-style dialogs). * feat(usage-logs): show tiered_expr breakdown and matched tier in details - Extend `LogOtherData` with `billing_mode`, `expr_b64`, and `matched_tier` fields populated by the backend for tiered logs. - Add `decodeBillingExprB64`, `resolveMatchedTier`, and `getTieredBillingSummary` helpers in `usage-logs/lib/format.ts` that centralise tiered-billing parsing on top of the canonical `parseTiersFromExpr` / `BILLING_PRICING_VARS` from the pricing feature, instead of duplicating the classic-frontend renderer. - Render `<DynamicPricingBreakdown>` inside the consume-log details dialog with the matched tier row highlighted in emerald and tagged "Matched"; suppress the legacy claude/audio/image cost rows when a tiered expression is in effect. - Surface per-tier prices and the matched tier label in log row segments and the billing breakdown table. * feat(pricing): show tier-count, time-based, and request-based hints in model list - Add `summarizeTieredExpr` that derives compact dynamic-pricing metadata (tier count + presence of time/request conditions) from a `tiered_expr` model, computed once per render via `useMemo`, so users can tell *what kind* of dynamic pricing applies before drilling into the model details. - Render the hints alongside the existing "Dynamic Pricing" badge in `<ModelRow>`. - Extend `<DynamicPricingBreakdown>` with a `matchedTierLabel` prop so the same component can be reused from the usage-log details dialog to highlight the tier that actually fired. * feat(profile): require Security Verification for Passkey register/remove - Wire `usePasskeyManagement` through `useSecureVerification` and `<SecureVerificationDialog>` in `<PasskeyCard>`. - Registration prompts for 2FA before issuing the Passkey credential (only when 2FA is already enabled — otherwise the browser-level Passkey prompt itself acts as proof of presence and we register directly). - Removal prompts for 2FA or Passkey, whichever the account has enabled, with informative toasts when neither method is available or the device lacks Passkey support. - Scope the dialog method set to the required factor so users cannot fall back to a weaker method, and propagate cancellation cleanly. * refactor: tighten upstream-ratio-sync and fix tier editor narrowing - Drop the unused `hasSynced` state and dead `getOrderedRatioTypes` / `isSelectableUpstreamValue` imports from `upstream-ratio-sync.tsx`. - In the cost estimator, narrow `BILLING_EXTRA_VARS` entries with a null-`field` guard to silence the type checker and make the "pricing variables only" contract explicit. - Apply Prettier-consistent formatting to the upstream-ratio-sync table/columns, channel mutate drawer, system info section, tier-expr, and wallet helpers (no behaviour change). * i18n: add 9 keys across en/zh/fr/ja/ru/vi - `{{count}} tiers`, `Billing Process`, `Matched`, `Matched Tier`, `Request-based`, `Security verification`, `Time-based`, plus the two new Passkey verification description strings. * 🔧 refactor(default): align upstream price sync, tiered billing, and fetch-models with classic 59337e9 Port and optimize the remaining web/classic features from commit 59337e9 to web/default, covering upstream price synchronization, tiered billing expressions, model fetching, and channel preset detection. Improve component architecture, memoization, and i18n coverage. Upstream Price Sync - Extend sync to all ratio fields: CacheRatio, CreateCacheRatio, ImageRatio, AudioRatio, AudioCompletionRatio in addition to ModelRatio / CompletionRatio / ModelPrice - Add tiered billing sync (billing_mode + billing_expr) with auto-pairing so selecting one upstream tier value populates the other from the same source - Bulk select / unselect per upstream column with indeterminate checkbox state reflecting partial selection - Confidence indicators warn when an upstream entry is heuristically derived - Conflict confirm dialog gains loading state and disables actions during sync - Default endpoint per channel: /api/pricing for official preset, /api/models.dev for the models.dev preset, /api/v1/models for OpenRouter, with the rest falling back to the global default - Rename tab label from "Upstream sync" to "Upstream price sync" for clarity Tiered Pricing Editor - Add `len` (full input length, including cache hits) as a tier-condition variable to avoid mis-routing when cache hits reduce `p` - When inserting a new tier, automatically convert the previous catch-all into a bounded tier with a `len <= X` upper bound - Cap each tier at 0~2 conditions and disable the add-condition button at the limit, with an Alert explaining the recommended `len` usage - Extend presets with Multimodal (img / img_o / ai / ao), Request rule (header/param matching), and Time-based (hour / weekday) entries - Embed an LLM prompt helper that copies a model-aware template for designing expressions with ChatGPT / Claude Fetch Models Dialog - Add a "Removed Models" tab listing models still in the local selection but no longer returned by the upstream listing - Exclude `model_mapping` source keys from the removed view so aliases never appear as missing entries - Force-remount tab content on tab switch via `key` prop to clear stale state - Switch count placeholders to `{{count}}` interpolation across "Existing Models", "New Models", and "Removed Models" labels Channel Selector & Constants - Recognize the models.dev preset (id, base_url, name) alongside the existing official-channel preset detection - Add MODELS_DEV_PRESET_* and OPENROUTER_* constants and reorder ENDPOINT_OPTIONS so `pricing` is preferred over `ratio_config` - Expose the new ratio types in RATIO_TYPE_OPTIONS for the sync filter Types - Add optional `type` field to UpstreamChannel for endpoint inference - Extend RatioType union with create_cache_ratio, image_ratio, audio_ratio, audio_completion_ratio, billing_mode, and billing_expr Code Quality & Performance - Extract upstream-ratio-sync-helpers.ts to host shared types (RatioDifferenceEntry, ModelRow, ResolutionsMap), field ordering (RATIO_SYNC_FIELDS, SYNC_FIELD_ORDER, NUMERIC_SYNC_FIELDS), and selection logic (getPreferredSyncField, isSelectableUpstreamValue, getSyncFieldLabel) - Memoize the column definitions in useUpstreamRatioSyncColumns and pull the per-cell rendering into a renderUpstreamValue helper to remove inline IIFEs - Wrap handleBulkSelect / handleBulkUnselect in useCallback for stable refs; rename the misleading `_upstream` parameter to `upstream` - Convert parsedRatios from useCallback (returning a function) to useMemo (returning the value) and update all call sites to read it as a value - Memoize the channels list with useMemo so the endpoint-init effect no longer fires on every render due to a fresh `?? []` reference i18n - Add and translate new keys ("Upstream price sync", "Audio Ratio", "Audio Completion Ratio", "Cache Create Ratio", "Image Ratio", "Expression Billing", "Fixed Price", "{{n}} model(s) selected", tier guidance, etc.) across en, zh, fr, ja, ru, vi - Fix truncated keys ("Existing Models (", "New Models (", "Removed Models (") to proper {{count}} interpolated forms in every locale - bun run i18n:sync reports 0 missing and 0 extra keys for every locale Verification - bun run typecheck: pass - bun run lint: pass - bun run i18n:sync: pass (0 missing / 0 extras across all locales) * 🐛 fix(default): port classic 73e5557 tiered-billing fixes and dedupe Title-Case ratio i18n keys Sync the web/classic frontend fixes from upstream merge 73e5557 to web/default, and clean up duplicated Title-Case ratio labels in the upstream sync UI that were shadowing the canonical sentence-case i18n keys. Cache-token filter for tiered model price (port of9f8a4ec05) - The matched-tier breakdown shown in the usage-log details dialog and in the log table previously listed every cache-related price (Cache Read, Cache Write, Cache Write 1h) regardless of whether the request actually consumed cache tokens. - `getTieredBillingSummary` in `usage-logs/lib/format.ts` now skips `cache`-group vars when none of `cache_tokens`, `cache_creation_tokens`, `cache_creation_tokens_5m`, or `cache_creation_tokens_1h` are positive, mirroring the classic `renderTieredModelPrice` / `renderTieredModelPriceSimple` logic. - Extract `hasAnyCacheTokens(other)` as an exported helper so the predicate is defined once. - Add a `hideCacheColumns?: boolean` prop to `DynamicPricingBreakdown` and wire it up from the log details dialog so the full tier table hides cache columns under the same condition. `model-details.tsx` keeps the default (show all configured prices), since that view represents the model's pricing structure rather than a specific call. `tiered_expr` ratio/price fallback during sync delays (port ofbee339d27) - When saving a model in tiered-expression mode, the visual editor used to delete every ratio/price map entry for the model and only write `billing_setting.billing_mode` / `billing_setting.billing_expr`. In multi-instance deployments, instances that had not yet observed the billing_setting update fell back to ratios that no longer existed, breaking pricing. - `model-ratio-dialog.tsx`: `handleSubmit` always passes every form field (`price`, `ratio`, `cacheRatio`, `createCacheRatio`, `completionRatio`, `imageRatio`, `audioRatio`, `audioCompletionRatio`) into the data object regardless of `pricingMode`, so a switch from per-token to tiered_expr no longer drops the previously entered ratios. - `model-ratio-visual-editor.tsx`: - The row builder now also surfaces ratio/price values for `tiered_expr` rows, so they survive the edit-and-save round trip and the next save. - `handleSave` factors out a `setIfPresent` helper and persists ratio/price entries for `tiered_expr` models alongside billing_mode / billing_expr. These act purely as fallback because the backend's `ModelPriceHelper` checks `billing_mode` first. - Cell rendering mutes ratio/price values whenever the row is `tiered_expr` (in addition to the existing per-request muting), making it visually clear the values are fallback, not the active pricing source. i18n: dedupe Title-Case ratio labels in upstream sync - `upstream-ratio-sync` `RATIO_TYPE_OPTIONS` previously used Title-Case labels (`Model Ratio`, `Cache Ratio`, `Audio Completion Ratio`, …) that were rendered through `t()` but never existed as canonical keys in the catalogue. The form-field side has used sentence-case (`Model ratio`, `Cache ratio`, …) for some time, leaving two parallel translation entries per ratio type and causing the upstream sync UI to fall back to the English source string in zh/ja/ru/fr/vi. - Rewrite `RATIO_TYPE_OPTIONS` in `system-settings/models/constants.ts` and the conflict-detection labels in `upstream-ratio-sync.tsx` to reuse the sentence-case keys. - Drop the duplicate Title-Case entries from every locale and promote the better translations onto the surviving sentence-case keys (e.g. zh `Image ratio` keeps "图片倍率", `Audio completion ratio` keeps "音频补全倍率"). - Add a comment to `RATIO_TYPE_OPTIONS` warning future contributors not to switch back to Title Case without updating the catalogue. Note on backend fix4e93148d9- The backend portion of the merge (allocating a fresh map in `updateConfigFromMap` so removed keys are properly cleared) is already on HEAD; no additional change is needed. Verification - `bun run typecheck`: pass - `bun run lint`: pass - `bun run i18n:sync`: 0 missing / 0 extras across en / zh / fr / ja / ru / vi --------- Co-authored-by: Seefs <40468931+seefs001@users.noreply.github.com> Co-authored-by: Seefs <i@seefs.me> Co-authored-by: feitianbubu <feitianbubu@qq.com> Co-authored-by: Calcium-Ion <i@caion.me> Co-authored-by: Xyfacai <xyfacai@gmail.com> Co-authored-by: xiangsx <1984871009@qq.com> Co-authored-by: 郑伯涛 <351175318@qq.com> Co-authored-by: RedwindA <austinaosid@gmail.com> Co-authored-by: dean <1006393151@qq.com> Co-authored-by: QuentinHsu <xuquentinyang@gmail.com> Co-authored-by: Bliod <bliod@bliod.lan> Co-authored-by: Apple\Apple <zeraturing@foxmail.com>
This commit is contained in:
+614
@@ -0,0 +1,614 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
GetModelsParams,
|
||||
GetModelsResponse,
|
||||
GetModelResponse,
|
||||
GetVendorsResponse,
|
||||
GetVendorResponse,
|
||||
Model,
|
||||
Vendor,
|
||||
SearchModelsParams,
|
||||
SyncUpstreamResponse,
|
||||
PreviewUpstreamDiffResponse,
|
||||
MissingModelsResponse,
|
||||
PrefillGroupsResponse,
|
||||
SyncLocale,
|
||||
SyncSource,
|
||||
SyncOverwritePayload,
|
||||
DeploymentSettingsResponse,
|
||||
ListDeploymentsResponse,
|
||||
} from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Model CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get paginated list of models
|
||||
*/
|
||||
export async function getModels(
|
||||
params: GetModelsParams = {}
|
||||
): Promise<GetModelsResponse> {
|
||||
const res = await api.get('/api/models/', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Search models with filters
|
||||
*/
|
||||
export async function searchModels(
|
||||
params: SearchModelsParams
|
||||
): Promise<GetModelsResponse> {
|
||||
const res = await api.get('/api/models/search', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single model by ID
|
||||
*/
|
||||
export async function getModel(id: number): Promise<GetModelResponse> {
|
||||
const res = await api.get(`/api/models/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new model
|
||||
*/
|
||||
export async function createModel(
|
||||
data: Partial<Model>
|
||||
): Promise<{ success: boolean; message?: string; data?: Model }> {
|
||||
const res = await api.post('/api/models/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing model
|
||||
*/
|
||||
export async function updateModel(
|
||||
data: Partial<Model> & { id: number }
|
||||
): Promise<{ success: boolean; message?: string; data?: Model }> {
|
||||
const res = await api.put('/api/models/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update model status only
|
||||
*/
|
||||
export async function updateModelStatus(
|
||||
id: number,
|
||||
status: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.put('/api/models/?status_only=true', { id, status })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete model
|
||||
*/
|
||||
export async function deleteModel(
|
||||
id: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete(`/api/models/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Vendor Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get paginated list of vendors
|
||||
*/
|
||||
export async function getVendors(params?: {
|
||||
p?: number
|
||||
page_size?: number
|
||||
}): Promise<GetVendorsResponse> {
|
||||
const res = await api.get('/api/vendors/', {
|
||||
params: params || { page_size: 1000 },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Search vendors
|
||||
*/
|
||||
export async function searchVendors(params: {
|
||||
keyword?: string
|
||||
p?: number
|
||||
page_size?: number
|
||||
}): Promise<GetVendorsResponse> {
|
||||
const res = await api.get('/api/vendors/search', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single vendor by ID
|
||||
*/
|
||||
export async function getVendor(id: number): Promise<GetVendorResponse> {
|
||||
const res = await api.get(`/api/vendors/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new vendor
|
||||
*/
|
||||
export async function createVendor(
|
||||
data: Partial<Vendor>
|
||||
): Promise<{ success: boolean; message?: string; data?: Vendor }> {
|
||||
const res = await api.post('/api/vendors/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing vendor
|
||||
*/
|
||||
export async function updateVendor(
|
||||
data: Partial<Vendor> & { id: number }
|
||||
): Promise<{ success: boolean; message?: string; data?: Vendor }> {
|
||||
const res = await api.put('/api/vendors/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete vendor
|
||||
*/
|
||||
export async function deleteVendor(
|
||||
id: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete(`/api/vendors/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sync Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sync upstream models (missing only or with overwrite)
|
||||
*/
|
||||
export async function syncUpstream(params?: {
|
||||
locale?: SyncLocale
|
||||
source?: SyncSource
|
||||
overwrite?: SyncOverwritePayload[]
|
||||
}): Promise<SyncUpstreamResponse> {
|
||||
const res = await api.post('/api/models/sync_upstream', params)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview upstream diff
|
||||
*/
|
||||
export async function previewUpstreamDiff(params?: {
|
||||
locale?: SyncLocale
|
||||
source?: SyncSource
|
||||
}): Promise<PreviewUpstreamDiffResponse> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.locale) {
|
||||
searchParams.set('locale', params.locale)
|
||||
}
|
||||
if (params?.source) {
|
||||
searchParams.set('source', params.source)
|
||||
}
|
||||
const queryString = searchParams.toString()
|
||||
const url = queryString
|
||||
? `/api/models/sync_upstream/preview?${queryString}`
|
||||
: '/api/models/sync_upstream/preview'
|
||||
const res = await api.get(url)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply upstream overwrite
|
||||
*/
|
||||
export async function applyUpstreamOverwrite(params: {
|
||||
overwrite: SyncOverwritePayload[]
|
||||
locale?: SyncLocale
|
||||
source?: SyncSource
|
||||
}): Promise<SyncUpstreamResponse> {
|
||||
return syncUpstream(params)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get missing models (used but not configured)
|
||||
*/
|
||||
export async function getMissingModels(): Promise<MissingModelsResponse> {
|
||||
const res = await api.get('/api/models/missing')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prefill groups
|
||||
*/
|
||||
export async function getPrefillGroups(
|
||||
type?: 'model' | 'tag' | 'endpoint'
|
||||
): Promise<PrefillGroupsResponse> {
|
||||
const res = await api.get('/api/prefill_group', {
|
||||
params: type ? { type } : undefined,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create prefill group
|
||||
*/
|
||||
export async function createPrefillGroup(data: {
|
||||
name: string
|
||||
type: 'model' | 'tag' | 'endpoint'
|
||||
items: string | string[]
|
||||
description?: string
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/prefill_group', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update prefill group
|
||||
*/
|
||||
export async function updatePrefillGroup(data: {
|
||||
id: number
|
||||
type?: 'model' | 'tag' | 'endpoint'
|
||||
name?: string
|
||||
items?: string | string[]
|
||||
description?: string
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.put('/api/prefill_group', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete prefill group
|
||||
*/
|
||||
export async function deletePrefillGroup(
|
||||
id: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete(`/api/prefill_group/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deployment Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get deployment settings (io.net config)
|
||||
*/
|
||||
export async function getDeploymentSettings(): Promise<DeploymentSettingsResponse> {
|
||||
const res = await api.get('/api/deployments/settings')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Test deployment connection
|
||||
*/
|
||||
export async function testDeploymentConnection(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> {
|
||||
const config = { skipErrorHandler: true } as unknown as Parameters<
|
||||
typeof api.post
|
||||
>[2]
|
||||
const res = await api.post(
|
||||
'/api/deployments/settings/test-connection',
|
||||
{},
|
||||
config
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Test deployment connection with optional api_key (allow test before saving)
|
||||
*/
|
||||
export async function testDeploymentConnectionWithKey(
|
||||
apiKey?: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> {
|
||||
const payload =
|
||||
typeof apiKey === 'string' && apiKey.trim()
|
||||
? { api_key: apiKey.trim() }
|
||||
: {}
|
||||
const config = { skipErrorHandler: true } as unknown as Parameters<
|
||||
typeof api.post
|
||||
>[2]
|
||||
const res = await api.post(
|
||||
'/api/deployments/settings/test-connection',
|
||||
payload,
|
||||
config
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* List deployments
|
||||
*/
|
||||
export async function listDeployments(params: {
|
||||
p?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
}): Promise<ListDeploymentsResponse> {
|
||||
const res = await api.get('/api/deployments/', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Search deployments (keyword + status)
|
||||
*
|
||||
* Backend exposes a dedicated `/api/deployments/search` route that supports
|
||||
* filtering by keyword (and status). Use this when keyword is provided.
|
||||
*/
|
||||
export async function searchDeployments(params: {
|
||||
p?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
keyword?: string
|
||||
}): Promise<ListDeploymentsResponse> {
|
||||
const res = await api.get('/api/deployments/search', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deployment details
|
||||
*/
|
||||
export async function getDeployment(id: string | number): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}> {
|
||||
const res = await api.get(`/api/deployments/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* List deployment containers
|
||||
*/
|
||||
export async function listDeploymentContainers(
|
||||
deploymentId: string | number
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
total?: number
|
||||
containers?: Array<Record<string, unknown>>
|
||||
}
|
||||
}> {
|
||||
const res = await api.get(`/api/deployments/${deploymentId}/containers`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single container details
|
||||
*/
|
||||
export async function getDeploymentContainerDetails(
|
||||
deploymentId: string | number,
|
||||
containerId: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}> {
|
||||
const res = await api.get(
|
||||
`/api/deployments/${deploymentId}/containers/${encodeURIComponent(containerId)}`
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete deployment
|
||||
*/
|
||||
export async function deleteDeployment(
|
||||
id: string | number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete(`/api/deployments/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deployment logs (raw)
|
||||
*/
|
||||
export async function getDeploymentLogs(
|
||||
deploymentId: string | number,
|
||||
params: {
|
||||
container_id: string
|
||||
stream?: 'stdout' | 'stderr' | 'all' | string
|
||||
level?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
}
|
||||
): Promise<{ success: boolean; message?: string; data?: string }> {
|
||||
const res = await api.get(`/api/deployments/${deploymentId}/logs`, { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hardware types for deployment
|
||||
*/
|
||||
export async function getHardwareTypes(): Promise<{
|
||||
success: boolean
|
||||
data?: { hardware_types?: Array<Record<string, unknown>> }
|
||||
}> {
|
||||
const res = await api.get('/api/deployments/hardware-types')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locations for deployment
|
||||
*/
|
||||
export async function getDeploymentLocations(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: { locations?: Array<Record<string, unknown>>; total?: number }
|
||||
}> {
|
||||
const res = await api.get('/api/deployments/locations')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available replicas
|
||||
*/
|
||||
export async function getAvailableReplicas(params: {
|
||||
hardware_id: string
|
||||
gpu_count: number
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
data?: { replicas?: Array<Record<string, unknown>> }
|
||||
}> {
|
||||
const res = await api.get('/api/deployments/available-replicas', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate deployment price
|
||||
*/
|
||||
export async function estimatePrice(params: {
|
||||
location_ids: Array<string | number>
|
||||
hardware_id: string | number
|
||||
gpus_per_container: number
|
||||
duration_hours: number
|
||||
replica_count: number
|
||||
currency?: string
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}> {
|
||||
const locationIds = (params.location_ids || [])
|
||||
.map((x) => Number(x))
|
||||
.filter((n) => Number.isInteger(n) && n > 0)
|
||||
const hardwareId = Number(params.hardware_id)
|
||||
const duration = Number(params.duration_hours)
|
||||
const gpus = Number(params.gpus_per_container)
|
||||
const replicaCount = Number(params.replica_count)
|
||||
const currency =
|
||||
typeof params.currency === 'string' && params.currency.trim()
|
||||
? params.currency.trim().toLowerCase()
|
||||
: 'usdc'
|
||||
|
||||
const payload = {
|
||||
location_ids: locationIds,
|
||||
hardware_id: hardwareId,
|
||||
gpus_per_container: gpus,
|
||||
duration_hours: duration,
|
||||
replica_count: replicaCount,
|
||||
currency,
|
||||
duration_type: 'hour',
|
||||
duration_qty: duration,
|
||||
hardware_qty: gpus,
|
||||
}
|
||||
|
||||
const res = await api.post('/api/deployments/price-estimation', payload)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create deployment
|
||||
*/
|
||||
export async function createDeployment(data: {
|
||||
resource_private_name: string
|
||||
duration_hours: number
|
||||
gpus_per_container: number
|
||||
hardware_id: number
|
||||
location_ids: number[]
|
||||
container_config: {
|
||||
replica_count: number
|
||||
env_variables?: Record<string, string>
|
||||
secret_env_variables?: Record<string, string>
|
||||
entrypoint?: string[]
|
||||
traffic_port?: number
|
||||
args?: string[]
|
||||
}
|
||||
registry_config: {
|
||||
image_url: string
|
||||
registry_username?: string
|
||||
registry_secret?: string
|
||||
}
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}> {
|
||||
const res = await api.post('/api/deployments/', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update deployment configuration
|
||||
*/
|
||||
export async function updateDeployment(
|
||||
id: string | number,
|
||||
data: {
|
||||
env_variables?: Record<string, string>
|
||||
secret_env_variables?: Record<string, string>
|
||||
entrypoint?: string[]
|
||||
traffic_port?: number | null
|
||||
image_url?: string
|
||||
registry_username?: string
|
||||
registry_secret?: string
|
||||
args?: string[]
|
||||
command?: string
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}> {
|
||||
const payload: Record<string, unknown> = { ...data }
|
||||
if (data.traffic_port === null) {
|
||||
delete payload.traffic_port
|
||||
}
|
||||
const res = await api.put(`/api/deployments/${id}`, payload)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update deployment name
|
||||
*/
|
||||
export async function updateDeploymentName(
|
||||
id: string | number,
|
||||
name: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}> {
|
||||
const res = await api.put(`/api/deployments/${id}/name`, { name })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend deployment duration
|
||||
*/
|
||||
export async function extendDeployment(
|
||||
id: string | number,
|
||||
durationHours: number
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}> {
|
||||
const res = await api.post(`/api/deployments/${id}/extend`, {
|
||||
duration_hours: durationHours,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Check cluster name availability
|
||||
*/
|
||||
export async function checkClusterNameAvailability(name: string): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: { available?: boolean; name?: string }
|
||||
}> {
|
||||
const res = await api.get('/api/deployments/check-name', {
|
||||
params: { name },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Power, PowerOff, Trash2, Copy } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import {
|
||||
handleBatchEnableModels,
|
||||
handleBatchDisableModels,
|
||||
handleBatchDeleteModels,
|
||||
} from '../lib'
|
||||
import type { Model } from '../types'
|
||||
|
||||
interface DataTableBulkActionsProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
}: DataTableBulkActionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
|
||||
const id = (row.original as Model).id
|
||||
|
||||
if (typeof id === 'number') {
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}, [])
|
||||
|
||||
const selectedModels = selectedRows.map((row) => row.original as Model)
|
||||
|
||||
const handleClearSelection = () => {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const handleEnableAll = () => {
|
||||
handleBatchEnableModels(selectedIds, queryClient, handleClearSelection)
|
||||
}
|
||||
|
||||
const handleDisableAll = () => {
|
||||
handleBatchDisableModels(selectedIds, queryClient, handleClearSelection)
|
||||
}
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
handleBatchDeleteModels(selectedIds, queryClient, () => {
|
||||
setShowDeleteConfirm(false)
|
||||
handleClearSelection()
|
||||
})
|
||||
}
|
||||
|
||||
const handleCopyNames = async () => {
|
||||
const names = selectedModels.map((m) => m.model_name).join(',')
|
||||
const success = await copyToClipboard(names)
|
||||
if (success) {
|
||||
toast.success(t('Model names copied to clipboard'))
|
||||
} else {
|
||||
toast.error(t('Failed to copy model names'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BulkActionsToolbar table={table} entityName='model'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleEnableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Enable selected models')}
|
||||
title={t('Enable selected models')}
|
||||
>
|
||||
<Power />
|
||||
<span className='sr-only'>{t('Enable selected models')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Enable selected models')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleDisableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Disable selected models')}
|
||||
title={t('Disable selected models')}
|
||||
>
|
||||
<PowerOff />
|
||||
<span className='sr-only'>{t('Disable selected models')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Disable selected models')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleCopyNames}
|
||||
className='size-8'
|
||||
aria-label={t('Copy model names')}
|
||||
title={t('Copy model names')}
|
||||
>
|
||||
<Copy />
|
||||
<span className='sr-only'>{t('Copy model names')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Copy model names')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete selected models')}
|
||||
title={t('Delete selected models')}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected models')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Delete selected models')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</BulkActionsToolbar>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Delete Models?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Are you sure you want to delete')} {selectedIds.length}{' '}
|
||||
{t('model(s)? This action cannot be undone.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDeleteAll}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import { MoreHorizontal, Pencil, Power, PowerOff, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
handleDeleteModel,
|
||||
handleToggleModelStatus,
|
||||
isModelEnabled,
|
||||
} from '../lib'
|
||||
import type { Model } from '../types'
|
||||
import { useModels } from './models-provider'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<Model>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const model = row.original
|
||||
const { setOpen, setCurrentRow } = useModels()
|
||||
const queryClient = useQueryClient()
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
|
||||
const isEnabled = isModelEnabled(model)
|
||||
|
||||
const handleEdit = () => {
|
||||
setCurrentRow(model)
|
||||
setOpen('update-model')
|
||||
}
|
||||
|
||||
const handleToggleStatus = () => {
|
||||
handleToggleModelStatus(model.id, model.status, queryClient)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{/* Edit */}
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
{t('Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<Pencil size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Enable/Disable */}
|
||||
<DropdownMenuItem onClick={handleToggleStatus}>
|
||||
{isEnabled ? (
|
||||
<>
|
||||
{t('Disable')}
|
||||
<DropdownMenuShortcut>
|
||||
<PowerOff size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('Enable')}
|
||||
<DropdownMenuShortcut>
|
||||
<Power size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setDeleteConfirmOpen(true)
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
title={t('Delete Model')}
|
||||
desc={`Are you sure you want to delete "${model.model_name}"? This action cannot be undone.`}
|
||||
confirmText='Delete'
|
||||
destructive
|
||||
handleConfirm={() => {
|
||||
handleDeleteModel(model.id, queryClient)
|
||||
setDeleteConfirmOpen(false)
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
Server,
|
||||
Settings,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type LoadingPhase = 'idle' | 'settings' | 'connection' | 'done'
|
||||
type StepStatus = 'pending' | 'loading' | 'done'
|
||||
|
||||
function getSettingsStatus(phase: LoadingPhase): StepStatus {
|
||||
if (phase === 'settings') return 'loading'
|
||||
if (phase === 'connection' || phase === 'done') return 'done'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function getConnectionStatus(
|
||||
phase: LoadingPhase,
|
||||
connectionOk: boolean | null
|
||||
): StepStatus {
|
||||
if (phase === 'connection') return 'loading'
|
||||
if (phase === 'done' && connectionOk) return 'done'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
interface DeploymentAccessGuardProps {
|
||||
children: ReactNode
|
||||
loading: boolean
|
||||
loadingPhase?: LoadingPhase
|
||||
isEnabled: boolean
|
||||
connectionLoading: boolean
|
||||
connectionOk: boolean | null
|
||||
connectionError: string | null
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
function LoadingStep({
|
||||
label,
|
||||
status,
|
||||
}: {
|
||||
label: string
|
||||
status: 'pending' | 'loading' | 'done'
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center gap-3'>
|
||||
{status === 'loading' && (
|
||||
<Loader2 className='text-primary h-5 w-5 animate-spin' />
|
||||
)}
|
||||
{status === 'done' && <CheckCircle2 className='h-5 w-5 text-green-500' />}
|
||||
{status === 'pending' && (
|
||||
<Circle className='text-muted-foreground/40 h-5 w-5' />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
status === 'loading' && 'text-foreground font-medium',
|
||||
status === 'done' && 'text-muted-foreground',
|
||||
status === 'pending' && 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentAccessGuard({
|
||||
children,
|
||||
loading,
|
||||
loadingPhase = 'settings',
|
||||
isEnabled,
|
||||
connectionLoading,
|
||||
connectionOk,
|
||||
connectionError,
|
||||
onRetry,
|
||||
}: DeploymentAccessGuardProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
navigate({ to: '/system-settings/integrations' })
|
||||
}
|
||||
|
||||
// Combined loading state with step indicator
|
||||
if (loading || connectionLoading) {
|
||||
const settingsStatus = getSettingsStatus(loadingPhase)
|
||||
const connectionStatus = getConnectionStatus(loadingPhase, connectionOk)
|
||||
|
||||
return (
|
||||
<div className='mx-auto mt-8 max-w-md'>
|
||||
<div className='flex flex-col items-center justify-center py-12'>
|
||||
<Loader2 className='text-primary mb-6 h-10 w-10 animate-spin' />
|
||||
<div className='space-y-3'>
|
||||
<LoadingStep
|
||||
label={t('Loading configuration')}
|
||||
status={settingsStatus}
|
||||
/>
|
||||
<LoadingStep
|
||||
label={t('Checking connection')}
|
||||
status={connectionStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
if (!isEnabled) {
|
||||
return (
|
||||
<div className='mx-auto mt-8 max-w-md'>
|
||||
<div className='text-center'>
|
||||
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/20'>
|
||||
<Server className='h-8 w-8 text-amber-600 dark:text-amber-400' />
|
||||
</div>
|
||||
<h3 className='mb-6 text-xl font-semibold'>
|
||||
{t('Model deployment service is disabled')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
<Alert variant='default'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>{t('Configuration required')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Please enable io.net model deployment service and configure an API key in System Settings.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button onClick={handleGoToSettings} className='w-full'>
|
||||
<Settings className='mr-2 h-4 w-4' />
|
||||
{t('Go to settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Connection error state
|
||||
if (connectionOk === false && connectionError) {
|
||||
return (
|
||||
<div className='mx-auto mt-8 max-w-md'>
|
||||
<div className='text-center'>
|
||||
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20'>
|
||||
<WifiOff className='h-8 w-8 text-red-600 dark:text-red-400' />
|
||||
</div>
|
||||
<h3 className='mb-6 text-xl font-semibold'>
|
||||
{t('Connection failed')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>{t('Connection error')}</AlertTitle>
|
||||
<AlertDescription>{t(connectionError)}</AlertDescription>
|
||||
</Alert>
|
||||
<div className='flex gap-2'>
|
||||
<Button variant='outline' onClick={onRetry} className='flex-1'>
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
<Button onClick={handleGoToSettings} className='flex-1'>
|
||||
<Settings className='mr-2 h-4 w-4' />
|
||||
{t('Go to settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { getDeploymentStatusConfig } from '../constants'
|
||||
import {
|
||||
formatRemainingMinutes,
|
||||
normalizeDeploymentStatus,
|
||||
} from '../lib/deployments-utils'
|
||||
import type { Deployment } from '../types'
|
||||
|
||||
export function useDeploymentsColumns(opts: {
|
||||
onViewLogs: (id: string | number) => void
|
||||
onViewDetails: (id: string | number) => void
|
||||
onUpdateConfig: (id: string | number) => void
|
||||
onExtend: (id: string | number) => void
|
||||
onRename: (id: string | number, currentName: string) => void
|
||||
onDelete: (deployment: Deployment) => void
|
||||
}): ColumnDef<Deployment>[] {
|
||||
const { t } = useTranslation()
|
||||
const STATUS = getDeploymentStatusConfig(t)
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('ID')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.id
|
||||
return (
|
||||
<StatusBadge
|
||||
label={String(id)}
|
||||
variant='neutral'
|
||||
copyText={String(id)}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) =>
|
||||
row.container_name || row.deployment_name || row.name || '',
|
||||
meta: { label: t('Name'), mobileTitle: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Name')} />
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const name = String(getValue() || '-') || '-'
|
||||
return (
|
||||
<StatusBadge
|
||||
label={name}
|
||||
variant='neutral'
|
||||
copyText={name}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
)
|
||||
},
|
||||
minSize: 220,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
header: t('Status'),
|
||||
cell: ({ row }) => {
|
||||
const raw = row.original.status
|
||||
const key = normalizeDeploymentStatus(raw)
|
||||
const config = STATUS[key] || {
|
||||
label:
|
||||
typeof raw === 'string' && raw.trim() ? raw.trim() : t('Unknown'),
|
||||
variant: 'neutral' as const,
|
||||
}
|
||||
return (
|
||||
<StatusBadge
|
||||
label={config.label}
|
||||
variant={config.variant}
|
||||
showDot={config.showDot}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
rounded='full'
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
value.includes('all')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const status = normalizeDeploymentStatus(row.getValue(id))
|
||||
return value.includes(status)
|
||||
},
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'provider',
|
||||
meta: { label: t('Provider') },
|
||||
header: t('Provider'),
|
||||
cell: ({ row }) => {
|
||||
const provider = row.original.provider
|
||||
if (!provider)
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
return (
|
||||
<StatusBadge
|
||||
label={String(provider)}
|
||||
autoColor={String(provider)}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
rounded='full'
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'time_remaining',
|
||||
meta: { label: t('Time remaining') },
|
||||
header: t('Time remaining'),
|
||||
cell: ({ row }) => {
|
||||
const status = normalizeDeploymentStatus(row.original.status)
|
||||
const remainingText =
|
||||
typeof row.original.time_remaining === 'string' &&
|
||||
row.original.time_remaining.trim()
|
||||
? row.original.time_remaining.trim()
|
||||
: '-'
|
||||
const remainingHuman = formatRemainingMinutes(
|
||||
row.original.compute_minutes_remaining
|
||||
)
|
||||
const percentUsed =
|
||||
typeof row.original.completed_percent === 'number' &&
|
||||
Number.isFinite(row.original.completed_percent)
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(row.original.completed_percent))
|
||||
)
|
||||
: null
|
||||
const percentRemain =
|
||||
percentUsed === null
|
||||
? null
|
||||
: Math.max(0, Math.min(100, 100 - percentUsed))
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1 text-sm'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>{remainingText}</span>
|
||||
{status === 'running' && percentRemain !== null ? (
|
||||
<StatusBadge
|
||||
label={`${percentRemain}%`}
|
||||
variant='info'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
rounded='full'
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{remainingHuman ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Approx.')} {remainingHuman}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
minSize: 220,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'hardware',
|
||||
meta: { label: t('Hardware'), mobileHidden: true },
|
||||
header: t('Hardware'),
|
||||
accessorFn: (row) =>
|
||||
row.hardware_info || row.hardware_name || row.brand_name || '',
|
||||
cell: ({ row }) => {
|
||||
const hardware =
|
||||
row.original.hardware_name ||
|
||||
(typeof row.original.hardware_info === 'string'
|
||||
? row.original.hardware_info
|
||||
: '')
|
||||
const qty =
|
||||
typeof row.original.hardware_quantity === 'number'
|
||||
? row.original.hardware_quantity
|
||||
: null
|
||||
if (!hardware)
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<StatusBadge
|
||||
label={String(hardware)}
|
||||
variant='neutral'
|
||||
copyText={String(hardware)}
|
||||
size='sm'
|
||||
/>
|
||||
{qty !== null ? (
|
||||
<span className='text-muted-foreground text-xs'>×{qty}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
minSize: 220,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
meta: { label: t('Created'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Created')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const ts =
|
||||
typeof row.original.created_at === 'number'
|
||||
? row.original.created_at
|
||||
: typeof row.original.created_at === 'string'
|
||||
? Number(row.original.created_at)
|
||||
: undefined
|
||||
return (
|
||||
<div className='min-w-[140px] font-mono text-sm'>
|
||||
{formatTimestampToDate(ts)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.id
|
||||
const currentName =
|
||||
row.original.container_name ||
|
||||
row.original.deployment_name ||
|
||||
row.original.name ||
|
||||
''
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => opts.onViewLogs(id)}
|
||||
title={t('View logs')}
|
||||
>
|
||||
<Eye className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => opts.onViewDetails(id)}
|
||||
title={t('View details')}
|
||||
>
|
||||
<Info className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => opts.onUpdateConfig(id)}
|
||||
title={t('Update configuration')}
|
||||
>
|
||||
<Settings2 className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => opts.onExtend(id)}
|
||||
title={t('Extend deployment')}
|
||||
>
|
||||
<Timer className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => opts.onRename(id, String(currentName))}
|
||||
title={t('Rename deployment')}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => opts.onDelete(row.original)}
|
||||
title={t('Delete')}
|
||||
>
|
||||
<Trash2 className='h-4 w-4 text-red-500' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTableToolbar,
|
||||
MobileCardList,
|
||||
TableEmpty,
|
||||
TableSkeleton,
|
||||
} from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import { deleteDeployment, listDeployments, searchDeployments } from '../api'
|
||||
import { getDeploymentStatusOptions } from '../constants'
|
||||
import { deploymentsQueryKeys } from '../lib'
|
||||
import type { Deployment } from '../types'
|
||||
import { useDeploymentsColumns } from './deployments-columns'
|
||||
import { ExtendDeploymentDialog } from './dialogs/extend-deployment-dialog'
|
||||
import { RenameDeploymentDialog } from './dialogs/rename-deployment-dialog'
|
||||
import { UpdateConfigDialog } from './dialogs/update-config-dialog'
|
||||
import { ViewDetailsDialog } from './dialogs/view-details-dialog'
|
||||
import { ViewLogsDialog } from './dialogs/view-logs-dialog'
|
||||
|
||||
const route = getRouteApi('/_authenticated/models/$section')
|
||||
|
||||
export function DeploymentsTable() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
// URL state (use dedicated keys so it won't collide with metadata table)
|
||||
const {
|
||||
globalFilter,
|
||||
onGlobalFilterChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
ensurePageInRange,
|
||||
} = useTableUrlState({
|
||||
search: route.useSearch(),
|
||||
navigate: route.useNavigate(),
|
||||
pagination: {
|
||||
pageKey: 'dPage',
|
||||
pageSizeKey: 'dPageSize',
|
||||
defaultPage: 1,
|
||||
defaultPageSize: 10,
|
||||
},
|
||||
globalFilter: { enabled: true, key: 'dFilter' },
|
||||
columnFilters: [
|
||||
{ columnId: 'status', searchKey: 'dStatus', type: 'array' },
|
||||
],
|
||||
})
|
||||
|
||||
const keyword = globalFilter ?? ''
|
||||
const statusFilter =
|
||||
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
|
||||
const activeStatus =
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined
|
||||
|
||||
// Dialog state
|
||||
const [logsOpen, setLogsOpen] = useState(false)
|
||||
const [logsDeploymentId, setLogsDeploymentId] = useState<
|
||||
string | number | null
|
||||
>(null)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [detailsDeploymentId, setDetailsDeploymentId] = useState<
|
||||
string | number | null
|
||||
>(null)
|
||||
const [updateOpen, setUpdateOpen] = useState(false)
|
||||
const [updateDeploymentId, setUpdateDeploymentId] = useState<
|
||||
string | number | null
|
||||
>(null)
|
||||
const [extendOpen, setExtendOpen] = useState(false)
|
||||
const [extendDeploymentId, setExtendDeploymentId] = useState<
|
||||
string | number | null
|
||||
>(null)
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [renameDeploymentId, setRenameDeploymentId] = useState<
|
||||
string | number | null
|
||||
>(null)
|
||||
const [renameCurrentName, setRenameCurrentName] = useState<string>('')
|
||||
|
||||
// Delete confirm
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Deployment | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: deploymentsQueryKeys.list({
|
||||
keyword,
|
||||
status: activeStatus,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (keyword.trim()) {
|
||||
return searchDeployments({
|
||||
keyword,
|
||||
status: activeStatus,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
}
|
||||
return listDeployments({
|
||||
status: activeStatus,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const deployments = data?.data?.items || []
|
||||
const totalCount = data?.data?.total || 0
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await deleteDeployment(deleteTarget.id)
|
||||
if (res?.success) {
|
||||
toast.success(t('Deleted successfully'))
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKeys.lists(),
|
||||
})
|
||||
} else {
|
||||
toast.error(res?.message || t('Delete failed'))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('Delete failed'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
setDeleteOpen(false)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const columns = useDeploymentsColumns({
|
||||
onViewLogs: (id) => {
|
||||
setLogsDeploymentId(id)
|
||||
setLogsOpen(true)
|
||||
},
|
||||
onViewDetails: (id) => {
|
||||
setDetailsDeploymentId(id)
|
||||
setDetailsOpen(true)
|
||||
},
|
||||
onUpdateConfig: (id) => {
|
||||
setUpdateDeploymentId(id)
|
||||
setUpdateOpen(true)
|
||||
},
|
||||
onExtend: (id) => {
|
||||
setExtendDeploymentId(id)
|
||||
setExtendOpen(true)
|
||||
},
|
||||
onRename: (id, currentName) => {
|
||||
setRenameCurrentName(currentName)
|
||||
setRenameDeploymentId(id)
|
||||
setRenameOpen(true)
|
||||
},
|
||||
onDelete: (deployment) => {
|
||||
setDeleteTarget(deployment)
|
||||
setDeleteOpen(true)
|
||||
},
|
||||
})
|
||||
|
||||
const table = useReactTable({
|
||||
data: deployments,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalCount / pagination.pageSize),
|
||||
state: {
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
globalFilter,
|
||||
},
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [ensurePageInRange, pageCount])
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
return [...getDeploymentStatusOptions(t)].map((opt) => ({
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
}))
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Search deployments...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: statusFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyTitle={t('No Deployments Found')}
|
||||
emptyDescription={t(
|
||||
'No deployments available. Create one to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border transition-opacity duration-150',
|
||||
isFetching && !isLoading && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton
|
||||
table={table}
|
||||
keyPrefix='deployment-skeleton'
|
||||
/>
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
title={t('No Deployments Found')}
|
||||
description={t(
|
||||
'No deployments available. Create one to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageFooterPortal>
|
||||
<DataTablePagination
|
||||
table={table as ReturnType<typeof useReactTable>}
|
||||
/>
|
||||
</PageFooterPortal>
|
||||
|
||||
<ViewLogsDialog
|
||||
open={logsOpen}
|
||||
onOpenChange={(open) => {
|
||||
setLogsOpen(open)
|
||||
if (!open) setLogsDeploymentId(null)
|
||||
}}
|
||||
deploymentId={logsDeploymentId}
|
||||
/>
|
||||
|
||||
<ViewDetailsDialog
|
||||
open={detailsOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDetailsOpen(open)
|
||||
if (!open) setDetailsDeploymentId(null)
|
||||
}}
|
||||
deploymentId={detailsDeploymentId}
|
||||
/>
|
||||
|
||||
<UpdateConfigDialog
|
||||
open={updateOpen}
|
||||
onOpenChange={(open) => {
|
||||
setUpdateOpen(open)
|
||||
if (!open) setUpdateDeploymentId(null)
|
||||
}}
|
||||
deploymentId={updateDeploymentId}
|
||||
/>
|
||||
|
||||
<ExtendDeploymentDialog
|
||||
open={extendOpen}
|
||||
onOpenChange={(open) => {
|
||||
setExtendOpen(open)
|
||||
if (!open) setExtendDeploymentId(null)
|
||||
}}
|
||||
deploymentId={extendDeploymentId}
|
||||
/>
|
||||
|
||||
<RenameDeploymentDialog
|
||||
open={renameOpen}
|
||||
onOpenChange={(open) => {
|
||||
setRenameOpen(open)
|
||||
if (!open) setRenameDeploymentId(null)
|
||||
}}
|
||||
deploymentId={renameDeploymentId}
|
||||
currentName={renameCurrentName}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Confirm delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
'Are you sure you want to delete deployment "{{name}}"? This action cannot be undone.',
|
||||
{
|
||||
name:
|
||||
deleteTarget?.container_name ||
|
||||
deleteTarget?.deployment_name ||
|
||||
deleteTarget?.id,
|
||||
}
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
>
|
||||
{isDeleting ? t('Deleting...') : t('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useModels } from './models-provider'
|
||||
|
||||
type DescriptionCellProps = {
|
||||
modelName: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function DescriptionCell({
|
||||
modelName,
|
||||
description,
|
||||
}: DescriptionCellProps) {
|
||||
const { setOpen, setDescriptionData } = useModels()
|
||||
|
||||
if (!description) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setDescriptionData({ modelName, description })
|
||||
setOpen('description')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='max-w-[150px]'>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={handleClick}
|
||||
className='text-muted-foreground hover:text-foreground block h-auto w-full cursor-pointer overflow-hidden p-0 text-left text-sm text-ellipsis whitespace-nowrap no-underline'
|
||||
>
|
||||
{description}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+747
@@ -0,0 +1,747 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import {
|
||||
checkClusterNameAvailability,
|
||||
createDeployment,
|
||||
estimatePrice,
|
||||
getAvailableReplicas,
|
||||
getHardwareTypes,
|
||||
} from '../../api'
|
||||
import { deploymentsQueryKeys } from '../../lib'
|
||||
|
||||
const BUILTIN_IMAGE = 'ollama/ollama:latest'
|
||||
const DEFAULT_TRAFFIC_PORT = 11434
|
||||
|
||||
const schema = z.object({
|
||||
resource_private_name: z.string().min(1),
|
||||
image_url: z.string().min(1),
|
||||
traffic_port: z.coerce.number().int().min(1).max(65535),
|
||||
hardware_id: z.string().min(1),
|
||||
gpus_per_container: z.coerce.number().int().min(1),
|
||||
location_ids: z.array(z.string()).min(1),
|
||||
replica_count: z.coerce.number().int().min(1),
|
||||
duration_hours: z.coerce.number().int().min(1),
|
||||
// Advanced
|
||||
env_json: z.string().optional(),
|
||||
secret_env_json: z.string().optional(),
|
||||
entrypoint: z.string().optional(),
|
||||
args: z.string().optional(),
|
||||
registry_username: z.string().optional(),
|
||||
registry_secret: z.string().optional(),
|
||||
currency: z.string().optional(),
|
||||
})
|
||||
|
||||
// NOTE: react-hook-form resolver uses the schema input type (coerce input is unknown)
|
||||
type FormValues = z.input<typeof schema>
|
||||
|
||||
function toNumber(value: unknown, fallback: number) {
|
||||
const n = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
export function CreateDeploymentDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
resource_private_name: '',
|
||||
image_url: BUILTIN_IMAGE,
|
||||
traffic_port: DEFAULT_TRAFFIC_PORT,
|
||||
hardware_id: '',
|
||||
gpus_per_container: 1,
|
||||
location_ids: [],
|
||||
replica_count: 1,
|
||||
duration_hours: 1,
|
||||
env_json: '',
|
||||
secret_env_json: '',
|
||||
entrypoint: '',
|
||||
args: '',
|
||||
registry_username: '',
|
||||
registry_secret: '',
|
||||
currency: 'usdc',
|
||||
},
|
||||
})
|
||||
|
||||
const hardwareId = form.watch('hardware_id')
|
||||
const gpuCount = toNumber(form.watch('gpus_per_container'), 1)
|
||||
const locationIds = form.watch('location_ids')
|
||||
const durationHours = toNumber(form.watch('duration_hours'), 1)
|
||||
const replicaCount = toNumber(form.watch('replica_count'), 1)
|
||||
const trafficPort = toNumber(form.watch('traffic_port'), DEFAULT_TRAFFIC_PORT)
|
||||
const currency = form.watch('currency')
|
||||
const resourceName = form.watch('resource_private_name')
|
||||
|
||||
const { data: hardwareTypesData, isLoading: isLoadingHardware } = useQuery({
|
||||
queryKey: ['deployment-hardware-types'],
|
||||
queryFn: getHardwareTypes,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const hardwareOptions = useMemo(() => {
|
||||
const items = hardwareTypesData?.data?.hardware_types || []
|
||||
if (!Array.isArray(items)) return []
|
||||
return items.map((h: Record<string, unknown>) => ({
|
||||
label:
|
||||
(h?.brand_name ? `${h.brand_name} ` : '') + String(h?.name ?? h?.id),
|
||||
value: String(h?.id),
|
||||
max_gpus: Number(h?.max_gpus || 1),
|
||||
}))
|
||||
}, [hardwareTypesData])
|
||||
|
||||
// Keep gpus_per_container <= max_gpus
|
||||
useEffect(() => {
|
||||
if (!hardwareId) return
|
||||
const hw = hardwareOptions.find((x) => x.value === hardwareId)
|
||||
if (!hw) return
|
||||
const max =
|
||||
Number.isFinite(hw.max_gpus) && hw.max_gpus > 0 ? hw.max_gpus : 1
|
||||
if (gpuCount > max) {
|
||||
form.setValue('gpus_per_container', max)
|
||||
}
|
||||
}, [hardwareId, hardwareOptions, gpuCount, form])
|
||||
|
||||
const { data: replicasData, isLoading: isLoadingReplicas } = useQuery({
|
||||
queryKey: ['deployment-available-replicas', hardwareId, gpuCount],
|
||||
queryFn: () =>
|
||||
getAvailableReplicas({
|
||||
hardware_id: hardwareId,
|
||||
gpu_count: gpuCount,
|
||||
}),
|
||||
enabled: open && Boolean(hardwareId) && gpuCount > 0,
|
||||
})
|
||||
|
||||
const locationOptions = useMemo(() => {
|
||||
const replicas = replicasData?.data?.replicas || []
|
||||
if (!Array.isArray(replicas)) return []
|
||||
const map = new Map<string, { label: string; value: string }>()
|
||||
replicas.forEach((r: Record<string, unknown>) => {
|
||||
const id = (r?.location_id ??
|
||||
(r?.location as Record<string, unknown>)?.id) as string | undefined
|
||||
if (id === null || id === undefined) return
|
||||
const name = (r?.location_name ??
|
||||
(r?.location as Record<string, unknown>)?.name ??
|
||||
r?.name ??
|
||||
String(id)) as string
|
||||
const key = String(id)
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { label: String(name), value: key })
|
||||
}
|
||||
})
|
||||
return Array.from(map.values())
|
||||
}, [replicasData])
|
||||
|
||||
const { data: priceData, isLoading: _isLoadingPrice } = useQuery({
|
||||
queryKey: [
|
||||
'deployment-price',
|
||||
hardwareId,
|
||||
gpuCount,
|
||||
durationHours,
|
||||
replicaCount,
|
||||
locationIds,
|
||||
currency,
|
||||
],
|
||||
queryFn: () =>
|
||||
estimatePrice({
|
||||
location_ids: locationIds,
|
||||
hardware_id: hardwareId,
|
||||
gpus_per_container: gpuCount,
|
||||
duration_hours: durationHours,
|
||||
replica_count: replicaCount,
|
||||
currency: currency || 'usdc',
|
||||
}),
|
||||
enabled:
|
||||
open &&
|
||||
Boolean(hardwareId) &&
|
||||
gpuCount > 0 &&
|
||||
durationHours > 0 &&
|
||||
replicaCount > 0 &&
|
||||
locationIds.length > 0,
|
||||
})
|
||||
|
||||
const { data: nameCheckData, isFetching: isCheckingName } = useQuery({
|
||||
queryKey: ['deployment-name-check', resourceName],
|
||||
queryFn: async () => {
|
||||
const name = (resourceName || '').trim()
|
||||
if (!name) return null
|
||||
return await checkClusterNameAvailability(name)
|
||||
},
|
||||
enabled: open && Boolean(resourceName && resourceName.trim().length > 0),
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
const nameAvailable =
|
||||
nameCheckData?.success === true ? nameCheckData?.data?.available : undefined
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: FormValues) => {
|
||||
const env =
|
||||
values.env_json && values.env_json.trim()
|
||||
? (JSON.parse(values.env_json) as Record<string, unknown>)
|
||||
: undefined
|
||||
const secretEnv =
|
||||
values.secret_env_json && values.secret_env_json.trim()
|
||||
? (JSON.parse(values.secret_env_json) as Record<string, unknown>)
|
||||
: undefined
|
||||
|
||||
const envVariables =
|
||||
env && typeof env === 'object' && !Array.isArray(env)
|
||||
? (Object.fromEntries(
|
||||
Object.entries(env).map(([k, v]) => [k, String(v)])
|
||||
) as Record<string, string>)
|
||||
: undefined
|
||||
|
||||
const secretEnvVariables =
|
||||
secretEnv && typeof secretEnv === 'object' && !Array.isArray(secretEnv)
|
||||
? (Object.fromEntries(
|
||||
Object.entries(secretEnv).map(([k, v]) => [k, String(v)])
|
||||
) as Record<string, string>)
|
||||
: undefined
|
||||
|
||||
const gpusPerContainer = Number(values.gpus_per_container)
|
||||
const durationHoursVal = Number(values.duration_hours)
|
||||
const replicaCountVal = Number(values.replica_count)
|
||||
|
||||
const entrypoint = values.entrypoint
|
||||
? values.entrypoint
|
||||
.split(' ')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
: undefined
|
||||
|
||||
const args = values.args
|
||||
? values.args
|
||||
.split(' ')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
: undefined
|
||||
|
||||
const location_ids = (values.location_ids || [])
|
||||
.map((x) => Number(x))
|
||||
.filter((n) => Number.isInteger(n) && n > 0)
|
||||
|
||||
const payload = {
|
||||
resource_private_name: values.resource_private_name.trim(),
|
||||
duration_hours: Number.isFinite(durationHoursVal)
|
||||
? durationHoursVal
|
||||
: 1,
|
||||
gpus_per_container: Number.isFinite(gpusPerContainer)
|
||||
? gpusPerContainer
|
||||
: 1,
|
||||
hardware_id: Number(values.hardware_id),
|
||||
location_ids,
|
||||
container_config: {
|
||||
replica_count: Number.isFinite(replicaCountVal) ? replicaCountVal : 1,
|
||||
traffic_port: Number.isFinite(trafficPort)
|
||||
? trafficPort
|
||||
: DEFAULT_TRAFFIC_PORT,
|
||||
...(entrypoint?.length ? { entrypoint } : {}),
|
||||
...(args?.length ? { args } : {}),
|
||||
...(envVariables ? { env_variables: envVariables } : {}),
|
||||
...(secretEnvVariables
|
||||
? { secret_env_variables: secretEnvVariables }
|
||||
: {}),
|
||||
},
|
||||
registry_config: {
|
||||
image_url: values.image_url,
|
||||
...(values.registry_username?.trim()
|
||||
? { registry_username: values.registry_username.trim() }
|
||||
: {}),
|
||||
...(values.registry_secret?.trim()
|
||||
? { registry_secret: values.registry_secret.trim() }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
|
||||
return await createDeployment(payload)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data?.success) {
|
||||
toast.success(t('Deployment created successfully'))
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKeys.lists(),
|
||||
})
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
toast.error(data?.message || t('Failed to create deployment'))
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t('Failed to create deployment'))
|
||||
},
|
||||
})
|
||||
|
||||
// Reset form when opening
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.reset({
|
||||
resource_private_name: '',
|
||||
image_url: BUILTIN_IMAGE,
|
||||
traffic_port: DEFAULT_TRAFFIC_PORT,
|
||||
hardware_id: '',
|
||||
gpus_per_container: 1,
|
||||
location_ids: [],
|
||||
replica_count: 1,
|
||||
duration_hours: 1,
|
||||
env_json: '',
|
||||
secret_env_json: '',
|
||||
entrypoint: '',
|
||||
args: '',
|
||||
registry_username: '',
|
||||
registry_secret: '',
|
||||
currency: 'usdc',
|
||||
})
|
||||
}, [open, form])
|
||||
|
||||
const priceSummary = useMemo<string>(() => {
|
||||
const est = priceData?.data
|
||||
if (!est || typeof est !== 'object') return ''
|
||||
const total =
|
||||
(est as Record<string, unknown>)?.total_cost ??
|
||||
(est as Record<string, unknown>)?.total ??
|
||||
''
|
||||
const currency = (est as Record<string, unknown>)?.currency ?? ''
|
||||
if (total === '' && currency === '') return ''
|
||||
return `${total} ${currency}`.trim()
|
||||
}, [priceData])
|
||||
void priceSummary
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v)
|
||||
if (!v) {
|
||||
form.reset()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
|
||||
<SheetHeader className='text-start'>
|
||||
<SheetTitle>{t('Create deployment')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('Configure and deploy a new container instance.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='deployment-form'
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
createMutation.mutate(values)
|
||||
)}
|
||||
className='flex-1 space-y-6 overflow-y-auto px-4'
|
||||
>
|
||||
{/* Basic Configuration */}
|
||||
<div className='space-y-4'>
|
||||
<h3 className='text-sm font-medium'>
|
||||
{t('Basic Configuration')}
|
||||
</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='resource_private_name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Container name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('Enter a name')} {...field} />
|
||||
</FormControl>
|
||||
{open && field.value?.trim() ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{isCheckingName
|
||||
? t('Checking name...')
|
||||
: nameAvailable === true
|
||||
? t('Name is available')
|
||||
: nameAvailable === false
|
||||
? t('Name is not available')
|
||||
: ''}
|
||||
</div>
|
||||
) : null}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='image_url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Image')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource Configuration */}
|
||||
<div className='space-y-4'>
|
||||
<h3 className='text-sm font-medium'>
|
||||
{t('Resource Configuration')}
|
||||
</h3>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='hardware_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Hardware type')}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
disabled={isLoadingHardware}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder={t('Select')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{hardwareOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='location_ids'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Deployment location')}</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelect
|
||||
options={locationOptions}
|
||||
selected={(field.value || []) as string[]}
|
||||
onChange={(vals) => {
|
||||
if (isLoadingReplicas || !hardwareId) return
|
||||
field.onChange(vals)
|
||||
}}
|
||||
placeholder={
|
||||
isLoadingReplicas
|
||||
? t('Loading...')
|
||||
: t('Select locations')
|
||||
}
|
||||
className={
|
||||
isLoadingReplicas || !hardwareId
|
||||
? 'pointer-events-none opacity-60'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='gpus_per_container'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('GPU count')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={toNumber(field.value, gpuCount)}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === '' ? 0 : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='replica_count'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Replica count')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={toNumber(field.value, replicaCount)}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === '' ? 0 : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='duration_hours'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Duration (hours)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={toNumber(field.value, durationHours)}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === '' ? 0 : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='traffic_port'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Port')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
value={toNumber(field.value, trafficPort)}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === '' ? 0 : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Estimation */}
|
||||
<div className='space-y-4'>
|
||||
<h3 className='text-sm font-medium'>{t('Price estimation')}</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Price estimation description')}
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='currency'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Billing currency')}</FormLabel>
|
||||
<Select
|
||||
value={field.value || 'usdc'}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder={t('Select')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='usdc'>USDC</SelectItem>
|
||||
<SelectItem value='iocoin'>IOCOIN</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Configuration */}
|
||||
<div className='space-y-4'>
|
||||
<h3 className='text-sm font-medium'>
|
||||
{t('Advanced Configuration')}
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Optional settings for advanced container configuration.')}
|
||||
</p>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='entrypoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Entrypoint (space separated)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='bash -lc' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='args'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Args (space separated)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='--foo bar' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='env_json'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Environment variables (JSON)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-24 font-mono text-xs'
|
||||
placeholder='{"KEY":"VALUE"}'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='secret_env_json'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Secret environment variables (JSON)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-24 font-mono text-xs'
|
||||
placeholder='{"SECRET":"VALUE"}'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='registry_username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Registry username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete='off' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='registry_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Registry secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<SheetFooter className='gap-2'>
|
||||
<SheetClose asChild>
|
||||
<Button variant='outline'>{t('Cancel')}</Button>
|
||||
</SheetClose>
|
||||
<Button
|
||||
form='deployment-form'
|
||||
type='submit'
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? t('Submitting...') : t('Create')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
type DescriptionDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
modelName: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function DescriptionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
modelName,
|
||||
description,
|
||||
}: DescriptionDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{modelName}</DialogTitle>
|
||||
<DialogDescription>{t('Model Description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className='max-h-96'>
|
||||
<div className='space-y-2 pr-4'>
|
||||
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { estimatePrice, extendDeployment, getDeployment } from '../../api'
|
||||
import { deploymentsQueryKeys } from '../../lib'
|
||||
|
||||
function toInt(value: unknown, fallback: number) {
|
||||
const n = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(n) ? Math.max(0, Math.round(n)) : fallback
|
||||
}
|
||||
|
||||
export function ExtendDeploymentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
deploymentId,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
deploymentId: string | number | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [hours, setHours] = useState(1)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setHours(1)
|
||||
}, [open])
|
||||
|
||||
const { data: detailsRes, isLoading: isLoadingDetails } = useQuery({
|
||||
queryKey: ['deployment-details-for-extend', deploymentId],
|
||||
queryFn: () => (deploymentId ? getDeployment(deploymentId) : null),
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
const details = detailsRes?.data
|
||||
|
||||
const priceParams = useMemo(() => {
|
||||
if (!details) return null
|
||||
const hardwareId = toInt(details.hardware_id, 0)
|
||||
const gpusPerContainer = toInt(details.gpus_per_container, 0)
|
||||
const replicaCount = toInt(details.total_containers, 0)
|
||||
const locations = Array.isArray(details.locations) ? details.locations : []
|
||||
const locationIds = locations
|
||||
.map((x) => {
|
||||
if (!x || typeof x !== 'object') return 0
|
||||
return toInt((x as Record<string, unknown>)?.id, 0)
|
||||
})
|
||||
.filter((x) => x > 0)
|
||||
|
||||
if (
|
||||
hardwareId <= 0 ||
|
||||
gpusPerContainer <= 0 ||
|
||||
replicaCount <= 0 ||
|
||||
locationIds.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
hardware_id: hardwareId,
|
||||
gpus_per_container: gpusPerContainer,
|
||||
replica_count: replicaCount,
|
||||
location_ids: locationIds,
|
||||
}
|
||||
}, [details])
|
||||
|
||||
const {
|
||||
data: priceRes,
|
||||
isLoading: isLoadingPrice,
|
||||
isFetching: isFetchingPrice,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-extend-price', deploymentId, hours, priceParams],
|
||||
queryFn: () =>
|
||||
priceParams
|
||||
? estimatePrice({
|
||||
location_ids: priceParams.location_ids,
|
||||
hardware_id: priceParams.hardware_id,
|
||||
gpus_per_container: priceParams.gpus_per_container,
|
||||
replica_count: priceParams.replica_count,
|
||||
duration_hours: hours,
|
||||
currency: 'usdc',
|
||||
})
|
||||
: null,
|
||||
enabled: open && Boolean(priceParams) && hours > 0,
|
||||
})
|
||||
|
||||
const priceSummary = useMemo(() => {
|
||||
const data = priceRes?.data
|
||||
if (!data || typeof data !== 'object') return ''
|
||||
const record = data as Record<string, unknown>
|
||||
const breakdown = record.price_breakdown
|
||||
let total: unknown = record.total_cost
|
||||
if (
|
||||
breakdown &&
|
||||
typeof breakdown === 'object' &&
|
||||
!Array.isArray(breakdown)
|
||||
) {
|
||||
const b = breakdown as Record<string, unknown>
|
||||
total = b.total_cost ?? b.totalCost ?? b.TotalCost ?? total
|
||||
}
|
||||
const currency = record.currency ?? 'USDC'
|
||||
if (total === undefined || total === null) return ''
|
||||
return `${String(total)} ${String(currency).toUpperCase()}`.trim()
|
||||
}, [priceRes])
|
||||
|
||||
const canSubmit = Boolean(deploymentId) && hours > 0 && !isSubmitting
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!deploymentId) return
|
||||
const h = toInt(hours, 1)
|
||||
if (h <= 0) {
|
||||
toast.error(t('Please enter a valid duration'))
|
||||
return
|
||||
}
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await extendDeployment(deploymentId, h)
|
||||
if (res.success) {
|
||||
toast.success(t('Extended successfully'))
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['deployment-details'] })
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
toast.error(res.message || t('Extend failed'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('Extend failed'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Extend deployment')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoadingDetails ? (
|
||||
<div className='flex items-center justify-center py-10'>
|
||||
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Deployment ID')}:{' '}
|
||||
<span className='font-mono'>{deploymentId}</span>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
value={hours}
|
||||
onChange={(e) => setHours(toInt(e.target.value, 1))}
|
||||
/>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('This will extend the deployment by the specified hours.')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{isLoadingPrice || isFetchingPrice ? (
|
||||
<span className='inline-flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
{t('Calculating...')}
|
||||
</span>
|
||||
) : priceParams ? (
|
||||
priceSummary || t('Not available')
|
||||
) : (
|
||||
t('Not available')
|
||||
)}
|
||||
</div>
|
||||
{!priceParams ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Unable to estimate price for this deployment.')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className='mt-4'>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void onSubmit()} disabled={!canSubmit}>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Extend')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronLeft, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { getMissingModels } from '../../api'
|
||||
import { DEFAULT_PAGE_SIZE } from '../../constants'
|
||||
import { modelsQueryKeys } from '../../lib'
|
||||
import type { Model } from '../../types'
|
||||
import { useModels } from '../models-provider'
|
||||
|
||||
type MissingModelsDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MissingModelsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MissingModelsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen, setCurrentRow } = useModels()
|
||||
const isMobile = useIsMobile()
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: modelsQueryKeys.missing(),
|
||||
queryFn: getMissingModels,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const missingModels = useMemo(() => data?.data || [], [data?.data])
|
||||
const pageSize = DEFAULT_PAGE_SIZE
|
||||
|
||||
const handleConfigureModel = (modelName: string) => {
|
||||
setCurrentRow({ model_name: modelName } as unknown as Model)
|
||||
setOpen('create-model')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSearchTerm('')
|
||||
|
||||
setCurrentPage(1)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return missingModels
|
||||
}
|
||||
const keyword = searchTerm.toLowerCase().trim()
|
||||
return missingModels.filter((modelName) =>
|
||||
modelName.toLowerCase().includes(keyword)
|
||||
)
|
||||
}, [missingModels, searchTerm])
|
||||
|
||||
const totalItems = filteredModels.length
|
||||
const totalPages =
|
||||
totalItems === 0 ? 1 : Math.ceil(totalItems / Math.max(1, pageSize))
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCurrentPage(Math.max(1, totalPages))
|
||||
}
|
||||
}, [currentPage, totalPages])
|
||||
|
||||
const paginatedModels = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize
|
||||
const endIndex = startIndex + pageSize
|
||||
return filteredModels.slice(startIndex, endIndex)
|
||||
}, [filteredModels, currentPage, pageSize])
|
||||
|
||||
const displayStart = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1
|
||||
const displayEnd =
|
||||
totalItems === 0 ? 0 : Math.min(currentPage * pageSize, totalItems)
|
||||
const showPagination = totalItems > pageSize
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (isMobile) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 text-start'>
|
||||
<DialogTitle>{t('Missing Models')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Models that are being used but not configured in the system')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Loader2 className='h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
) : missingModels.length === 0 ? (
|
||||
<div className='text-muted-foreground py-12 text-center'>
|
||||
<p>{t('No missing models found.')}</p>
|
||||
<p className='text-sm'>
|
||||
{t('All models in use are properly configured.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
|
||||
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
|
||||
<div className='text-muted-foreground text-sm whitespace-nowrap'>
|
||||
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
|
||||
{totalItems}
|
||||
</div>
|
||||
<div className='relative w-48'>
|
||||
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(event) => {
|
||||
setSearchTerm(event.target.value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
placeholder={t('Search models...')}
|
||||
className='pl-9'
|
||||
aria-label={t('Search missing models')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty className='border'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Search className='h-5 w-5' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t('No matches found')}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{t('Try adjusting your search to locate a missing model.')}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className='flex-shrink-0 rounded-lg border'>
|
||||
<div className='divide-y'>
|
||||
{paginatedModels.map((modelName) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className='flex items-center justify-between gap-3 p-3'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<StatusBadge
|
||||
label={modelName}
|
||||
variant='neutral'
|
||||
copyText={modelName}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
className='flex-shrink-0 gap-1'
|
||||
onClick={() => handleConfigureModel(modelName)}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
{showPagination && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||
}
|
||||
disabled={currentPage === 1}
|
||||
aria-label={t('Previous page')}
|
||||
>
|
||||
<ChevronLeft className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) =>
|
||||
Math.min(totalPages, prev + 1)
|
||||
)
|
||||
}
|
||||
disabled={currentPage === totalPages}
|
||||
aria-label={t('Next page')}
|
||||
>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Layers3,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { deletePrefillGroup, getPrefillGroups } from '../../api'
|
||||
import { prefillGroupsQueryKeys } from '../../lib'
|
||||
import type { PrefillGroup } from '../../types'
|
||||
import {
|
||||
PREFILL_GROUP_TYPE_META,
|
||||
parseEndpointKeys,
|
||||
parseStringItems,
|
||||
} from '../prefill-group-shared'
|
||||
|
||||
type PrefillGroupManagementDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreateGroup: () => void
|
||||
onEditGroup: (group: PrefillGroup) => void
|
||||
}
|
||||
|
||||
export function PrefillGroupManagementDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreateGroup,
|
||||
onEditGroup,
|
||||
}: PrefillGroupManagementDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const isMobile = useIsMobile()
|
||||
const [deleteState, setDeleteState] = useState<{
|
||||
open: boolean
|
||||
group: PrefillGroup | null
|
||||
}>({ open: false, group: null })
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch: refetchGroups,
|
||||
} = useQuery({
|
||||
queryKey: prefillGroupsQueryKeys.list(),
|
||||
queryFn: () => getPrefillGroups(),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const groups = useMemo(() => data?.data ?? [], [data?.data])
|
||||
|
||||
const sortedGroups = useMemo(
|
||||
() =>
|
||||
[...groups].sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
return a.type.localeCompare(b.type)
|
||||
}),
|
||||
[groups]
|
||||
)
|
||||
|
||||
const normalizedGroups = useMemo(
|
||||
() =>
|
||||
sortedGroups.map((group) => {
|
||||
const meta = PREFILL_GROUP_TYPE_META[group.type] || {
|
||||
label: group.type,
|
||||
badge: 'neutral' as const,
|
||||
}
|
||||
const parsedItems =
|
||||
group.type === 'endpoint'
|
||||
? parseEndpointKeys(group.items)
|
||||
: parseStringItems(group.items)
|
||||
return { group, meta, parsedItems }
|
||||
}),
|
||||
[sortedGroups]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDeleteState({ open: false, group: null })
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleDeleteClick = (group: PrefillGroup) => {
|
||||
setDeleteState({ open: true, group })
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteState.group) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await deletePrefillGroup(deleteState.group.id)
|
||||
if (response.success) {
|
||||
toast.success(`Deleted "${deleteState.group.name}"`)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: prefillGroupsQueryKeys.lists(),
|
||||
})
|
||||
setDeleteState({ open: false, group: null })
|
||||
} else {
|
||||
toast.error(response.message || 'Failed to delete group')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Failed to delete group')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className='prefill-dialog-content !top-4 !flex !-translate-y-0 !flex-col !gap-0 !border-none !bg-transparent !p-0 !shadow-none sm:!top-1/2 sm:!-translate-y-1/2'
|
||||
style={{ maxWidth: 'min(100vw, 64rem)' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'prefill-dialog-panel border-border/70 bg-background flex max-h-[calc(100dvh-1.5rem)] flex-col overflow-hidden border shadow-2xl',
|
||||
isMobile ? 'rounded-none' : 'rounded-2xl'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-col gap-3 border-b px-4 py-4 sm:px-6 sm:py-5',
|
||||
isMobile && 'pt-[calc(env(safe-area-inset-top,0px)+1rem)]'
|
||||
)}
|
||||
>
|
||||
<DialogHeader className='max-w-3xl gap-3 pr-12 text-start sm:pr-0'>
|
||||
<DialogTitle className='flex flex-wrap items-center gap-2 text-xl'>
|
||||
<Layers3 className='text-foreground/80 h-5 w-5' />
|
||||
{t('Prefill Group Management')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-base leading-relaxed sm:text-sm'>
|
||||
{t(
|
||||
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='text-muted-foreground hover:text-foreground absolute top-4 right-4 rounded-full border border-transparent sm:top-5 sm:right-6'
|
||||
>
|
||||
<span className='sr-only'>{t('Close dialog')}</span>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-3 border-b px-4 py-3 text-sm sm:px-6'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button size='sm' onClick={onCreateGroup}>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('New Group')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
onClick={() => refetchGroups()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<RefreshCcw className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<StatusBadge
|
||||
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 flex-col overflow-hidden px-4 py-4 sm:px-6 sm:py-6',
|
||||
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1.5rem)]'
|
||||
)}
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{(error as Error).message ||
|
||||
'Please retry or refresh the page.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className='flex flex-col items-center justify-center gap-2 py-16 text-center'>
|
||||
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Fetching prefill groups...')}
|
||||
</p>
|
||||
</div>
|
||||
) : normalizedGroups.length === 0 ? (
|
||||
<Empty className='border border-dashed'>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Layers3 className='h-6 w-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{t('No prefill groups yet')}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{t(
|
||||
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
|
||||
)}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyDescription>
|
||||
{t(
|
||||
'Prefill groups help you keep complex configurations in sync.'
|
||||
)}
|
||||
</EmptyDescription>
|
||||
</Empty>
|
||||
) : isMobile ? (
|
||||
<div className='space-y-4'>
|
||||
{normalizedGroups.map(({ group, meta, parsedItems }) => (
|
||||
<Card key={group.id} className='border-border/60'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div className='space-y-2'>
|
||||
<CardTitle className='flex flex-wrap items-center gap-2'>
|
||||
{group.name}
|
||||
<StatusBadge
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
>
|
||||
{meta.label}
|
||||
<span className='text-muted-foreground/30'>
|
||||
·
|
||||
</span>
|
||||
<span className='text-muted-foreground font-mono'>
|
||||
#{group.id}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</CardTitle>
|
||||
{group.description ? (
|
||||
<CardDescription className='line-clamp-2'>
|
||||
{group.description}
|
||||
</CardDescription>
|
||||
) : (
|
||||
<CardDescription className='text-muted-foreground italic'>
|
||||
No description provided
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
<span className='sr-only'>Edit group</span>
|
||||
</Button>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<span className='sr-only'>Delete group</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
|
||||
<span>Items</span>
|
||||
<StatusBadge
|
||||
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
{parsedItems.length > 0 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{parsedItems.slice(0, 6).map((item) => (
|
||||
<StatusBadge
|
||||
key={item}
|
||||
label={item}
|
||||
autoColor={item}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
{parsedItems.length > 6 && (
|
||||
<StatusBadge
|
||||
label={`+${parsedItems.length - 6} more`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{group.type === 'endpoint'
|
||||
? 'No endpoint mappings configured.'
|
||||
: 'No items configured yet.'}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
<div className='w-full overflow-x-auto'>
|
||||
<Table className='min-w-[720px]'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead className='min-w-[280px]'>
|
||||
{t('Items')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[120px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{normalizedGroups.map(
|
||||
({ group, meta, parsedItems }) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className='align-top whitespace-normal'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>
|
||||
{group.name}
|
||||
</span>
|
||||
<StatusBadge
|
||||
label={`#${group.id}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='font-mono'
|
||||
/>
|
||||
</div>
|
||||
{group.description ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{group.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-xs italic'>
|
||||
No description provided
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='align-top'>
|
||||
<StatusBadge
|
||||
label={meta.label}
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='align-top whitespace-normal'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{parsedItems.length > 0 ? (
|
||||
<>
|
||||
{parsedItems
|
||||
.slice(0, 6)
|
||||
.map((item) => (
|
||||
<StatusBadge
|
||||
key={item}
|
||||
label={item}
|
||||
autoColor={item}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
{parsedItems.length > 6 && (
|
||||
<StatusBadge
|
||||
label={`+${parsedItems.length - 6} more`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{group.type === 'endpoint'
|
||||
? 'No endpoint mappings configured.'
|
||||
: 'No items configured yet.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
|
||||
{parsedItems.length} item
|
||||
{parsedItems.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='align-top'>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
<span className='sr-only'>
|
||||
Edit group
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<span className='sr-only'>
|
||||
Delete group
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteState.open}
|
||||
onOpenChange={(next) => setDeleteState({ open: next, group: null })}
|
||||
title={t('Delete group')}
|
||||
desc={
|
||||
<p>
|
||||
{t('Are you sure you want to delete')}{' '}
|
||||
<span className='font-medium'>{deleteState.group?.name}</span>
|
||||
{t('? This action cannot be undone.')}
|
||||
</p>
|
||||
}
|
||||
destructive
|
||||
confirmText={isDeleting ? 'Deleting...' : 'Delete'}
|
||||
isLoading={isDeleting}
|
||||
handleConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PrefillGroup } from '../../types'
|
||||
import { PrefillGroupFormDrawer } from '../drawers/prefill-group-form-drawer'
|
||||
import { PrefillGroupManagementDialog } from './prefill-group-management-dialog'
|
||||
|
||||
type PrefillGroupManagementProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type PrefillView = 'dialog' | 'drawer'
|
||||
|
||||
export function PrefillGroupManagement({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PrefillGroupManagementProps) {
|
||||
const [view, setView] = useState<PrefillView>('dialog')
|
||||
const [currentGroup, setCurrentGroup] = useState<PrefillGroup | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setView('dialog')
|
||||
|
||||
setCurrentGroup(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleDialogOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
setView('dialog')
|
||||
setCurrentGroup(null)
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setView('dialog')
|
||||
setCurrentGroup(null)
|
||||
}
|
||||
|
||||
const handleShowDrawer = (group: PrefillGroup | null) => {
|
||||
setCurrentGroup(group)
|
||||
setView('drawer')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrefillGroupManagementDialog
|
||||
open={open && view === 'dialog'}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onCreateGroup={() => handleShowDrawer(null)}
|
||||
onEditGroup={(group) => handleShowDrawer(group)}
|
||||
/>
|
||||
<PrefillGroupFormDrawer
|
||||
open={open && view === 'drawer'}
|
||||
onClose={handleDrawerClose}
|
||||
currentGroup={currentGroup}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
|
||||
import { deploymentsQueryKeys } from '../../lib'
|
||||
|
||||
export function RenameDeploymentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
deploymentId,
|
||||
currentName,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
deploymentId: string | number | null
|
||||
currentName?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState(currentName || '')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setName(currentName || '')
|
||||
}, [open, currentName])
|
||||
|
||||
const trimmed = name.trim()
|
||||
|
||||
const { data: checkRes, isFetching: isChecking } = useQuery({
|
||||
queryKey: ['deployment-rename-check', trimmed],
|
||||
queryFn: () => (trimmed ? checkClusterNameAvailability(trimmed) : null),
|
||||
enabled: open && Boolean(trimmed),
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
const available =
|
||||
checkRes?.success === true ? checkRes?.data?.available : undefined
|
||||
|
||||
const helper = useMemo(() => {
|
||||
if (!trimmed) return t('Enter a new name')
|
||||
if (isChecking) return t('Checking name...')
|
||||
if (available === true) return t('Name is available')
|
||||
if (available === false) return t('Name is not available')
|
||||
return ''
|
||||
}, [available, isChecking, t, trimmed])
|
||||
|
||||
const canSubmit =
|
||||
Boolean(deploymentId) &&
|
||||
Boolean(trimmed) &&
|
||||
available !== false &&
|
||||
!isSubmitting
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!deploymentId) return
|
||||
if (!trimmed) {
|
||||
toast.error(t('Please enter a name'))
|
||||
return
|
||||
}
|
||||
if (available === false) {
|
||||
toast.error(t('Name is not available'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await updateDeploymentName(deploymentId, trimmed)
|
||||
if (res.success) {
|
||||
toast.success(t('Renamed successfully'))
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['deployment-details'] })
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
toast.error(res.message || t('Rename failed'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('Rename failed'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Rename deployment')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Deployment ID')}:{' '}
|
||||
<span className='font-mono'>{deploymentId}</span>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('Enter a new name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<div className='text-muted-foreground text-xs'>{helper}</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className='mt-4'>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void onSubmit()} disabled={!canSubmit}>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { syncUpstream, previewUpstreamDiff } from '../../api'
|
||||
import { getSyncLocaleOptions, getSyncSourceOptions } from '../../constants'
|
||||
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
|
||||
import type { SyncLocale, SyncSource } from '../../types'
|
||||
import { useModels } from '../models-provider'
|
||||
|
||||
type SyncWizardDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SyncWizardDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: SyncWizardDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
setOpen,
|
||||
setUpstreamConflicts,
|
||||
setSyncWizardOptions,
|
||||
syncWizardOptions,
|
||||
} = useModels()
|
||||
const isMobile = useIsMobile()
|
||||
const [locale, setLocale] = useState<SyncLocale>('zh')
|
||||
const [source, setSource] = useState<SyncSource>('official')
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
|
||||
// Get translated options
|
||||
const SYNC_SOURCE_OPTIONS = getSyncSourceOptions(t)
|
||||
const SYNC_LOCALE_OPTIONS = getSyncLocaleOptions(t)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLocale(syncWizardOptions.locale || 'zh')
|
||||
const preferredSource = SYNC_SOURCE_OPTIONS.find(
|
||||
(option) => option.value === syncWizardOptions.source
|
||||
)
|
||||
setSource(
|
||||
preferredSource && !preferredSource.disabled
|
||||
? (preferredSource.value as SyncSource)
|
||||
: 'official'
|
||||
)
|
||||
}
|
||||
}, [open, syncWizardOptions, SYNC_SOURCE_OPTIONS])
|
||||
|
||||
const handleSync = async () => {
|
||||
setIsSyncing(true)
|
||||
try {
|
||||
setSyncWizardOptions({ locale, source })
|
||||
const previewRes = await previewUpstreamDiff({ locale, source })
|
||||
|
||||
if (!previewRes.success) {
|
||||
throw new Error(previewRes.message || 'Failed to preview upstream diff')
|
||||
}
|
||||
|
||||
const conflicts = previewRes.data?.conflicts || []
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
toast.warning(
|
||||
`Found ${conflicts.length} conflict${conflicts.length > 1 ? 's' : ''}. Please resolve them first.`
|
||||
)
|
||||
setUpstreamConflicts(conflicts)
|
||||
setOpen('upstream-conflict')
|
||||
return
|
||||
}
|
||||
|
||||
// No conflicts, proceed with sync
|
||||
const response = await syncUpstream({ locale, source })
|
||||
|
||||
if (response.success) {
|
||||
const { created_models, created_vendors, updated_models } =
|
||||
response.data || {}
|
||||
toast.success(
|
||||
`Sync completed! Created ${created_models || 0} models, updated ${updated_models || 0}, and added ${created_vendors || 0} vendors.`
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: vendorsQueryKeys.lists() })
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(response.message || 'Sync failed')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || 'Sync failed')
|
||||
} finally {
|
||||
setIsSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6'
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (isMobile) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 text-start'>
|
||||
<DialogTitle>{t('Sync Upstream Models')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Synchronize models and vendors from an upstream source')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto'>
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<Label className='text-base'>{t('Select Sync Source')}</Label>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Choose where to fetch upstream metadata.')}
|
||||
</p>
|
||||
</div>
|
||||
<RadioGroup
|
||||
value={source}
|
||||
onValueChange={(value) => {
|
||||
const selected = SYNC_SOURCE_OPTIONS.find(
|
||||
(option) => option.value === value
|
||||
)
|
||||
if (!selected || selected.disabled) return
|
||||
setSource(selected.value)
|
||||
}}
|
||||
className='grid gap-3 md:grid-cols-2'
|
||||
>
|
||||
{SYNC_SOURCE_OPTIONS.map((option) => {
|
||||
const isActive = source === option.value
|
||||
const isDisabled = option.disabled
|
||||
return (
|
||||
<Label
|
||||
key={option.value}
|
||||
htmlFor={`sync-source-${option.value}`}
|
||||
className={cn(
|
||||
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
|
||||
isActive && 'border-primary ring-primary ring-1',
|
||||
isDisabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'hover:border-primary/60 cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-start gap-3'>
|
||||
<RadioGroupItem
|
||||
value={option.value}
|
||||
id={`sync-source-${option.value}`}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium'>{option.label}</span>
|
||||
{option.value === 'official' && (
|
||||
<StatusBadge
|
||||
label='Default'
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label className='text-base'>{t('Select Language')}</Label>
|
||||
<RadioGroup
|
||||
value={locale}
|
||||
onValueChange={(v) => setLocale(v as SyncLocale)}
|
||||
className='grid gap-3 sm:grid-cols-3'
|
||||
>
|
||||
{SYNC_LOCALE_OPTIONS.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className='flex items-center space-x-2 rounded-lg border p-3'
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.value}
|
||||
id={`locale-${option.value}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`locale-${option.value}`}
|
||||
className='cursor-pointer font-normal'
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/50 rounded-lg border p-4'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className='flex-shrink-0 gap-2 sm:justify-end'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSync} disabled={isSyncing}>
|
||||
{isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
{isSyncing ? 'Syncing...' : 'Sync Now'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm, type Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getDeployment, updateDeployment } from '../../api'
|
||||
import { deploymentsQueryKeys } from '../../lib'
|
||||
|
||||
const schema = z.object({
|
||||
image_url: z.string().optional(),
|
||||
traffic_port: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
entrypoint: z.string().optional(),
|
||||
args: z.string().optional(),
|
||||
command: z.string().optional(),
|
||||
registry_username: z.string().optional(),
|
||||
registry_secret: z.string().optional(),
|
||||
env_json: z.string().optional(),
|
||||
secret_env_json: z.string().optional(),
|
||||
})
|
||||
|
||||
type Values = z.input<typeof schema>
|
||||
|
||||
function normalizeJsonObject(input?: string) {
|
||||
if (!input || !input.trim()) return undefined
|
||||
const parsed = JSON.parse(input)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('JSON must be an object')
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [
|
||||
k,
|
||||
String(v),
|
||||
])
|
||||
) as Record<string, string>
|
||||
}
|
||||
|
||||
export function UpdateConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
deploymentId,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
deploymentId: string | number | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema) as unknown as Resolver<Values>,
|
||||
defaultValues: {
|
||||
image_url: '',
|
||||
traffic_port: undefined,
|
||||
entrypoint: '',
|
||||
args: '',
|
||||
command: '',
|
||||
registry_username: '',
|
||||
registry_secret: '',
|
||||
env_json: '',
|
||||
secret_env_json: '',
|
||||
},
|
||||
})
|
||||
|
||||
const { data: detailsRes, isLoading } = useQuery({
|
||||
queryKey: ['deployment-details-for-update', deploymentId],
|
||||
queryFn: () => (deploymentId ? getDeployment(deploymentId) : null),
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
const details = detailsRes?.data
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !details) return
|
||||
const containerConfig =
|
||||
details.container_config && typeof details.container_config === 'object'
|
||||
? (details.container_config as Record<string, unknown>)
|
||||
: {}
|
||||
const imageUrl =
|
||||
typeof containerConfig.image_url === 'string'
|
||||
? containerConfig.image_url
|
||||
: ''
|
||||
const trafficPort =
|
||||
typeof containerConfig.traffic_port === 'number'
|
||||
? containerConfig.traffic_port
|
||||
: undefined
|
||||
const entrypointArr = Array.isArray(containerConfig.entrypoint)
|
||||
? (containerConfig.entrypoint as unknown[])
|
||||
.map((x) => (typeof x === 'string' ? x : ''))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
const envVars =
|
||||
containerConfig.env_variables &&
|
||||
typeof containerConfig.env_variables === 'object' &&
|
||||
!Array.isArray(containerConfig.env_variables)
|
||||
? (containerConfig.env_variables as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
form.reset({
|
||||
image_url: imageUrl,
|
||||
traffic_port: trafficPort,
|
||||
entrypoint: entrypointArr.join(' '),
|
||||
args: '',
|
||||
command: '',
|
||||
registry_username: '',
|
||||
registry_secret: '',
|
||||
env_json: Object.keys(envVars).length
|
||||
? JSON.stringify(envVars, null, 2)
|
||||
: '',
|
||||
secret_env_json: '',
|
||||
})
|
||||
}, [open, details, form])
|
||||
|
||||
const title = useMemo(
|
||||
() =>
|
||||
deploymentId
|
||||
? `${t('Update configuration')} - ${deploymentId}`
|
||||
: t('Update configuration'),
|
||||
[deploymentId, t]
|
||||
)
|
||||
|
||||
const onSubmit = async (values: Values) => {
|
||||
if (!deploymentId) return
|
||||
try {
|
||||
const env_variables = normalizeJsonObject(values.env_json)
|
||||
const secret_env_variables = normalizeJsonObject(values.secret_env_json)
|
||||
const entrypoint = values.entrypoint
|
||||
? values.entrypoint
|
||||
.split(' ')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
: undefined
|
||||
const args = values.args
|
||||
? values.args
|
||||
.split(' ')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
: undefined
|
||||
|
||||
const res = await updateDeployment(deploymentId, {
|
||||
image_url: values.image_url?.trim() || undefined,
|
||||
traffic_port:
|
||||
typeof values.traffic_port === 'number'
|
||||
? values.traffic_port
|
||||
: undefined,
|
||||
registry_username: values.registry_username?.trim() || undefined,
|
||||
registry_secret: values.registry_secret?.trim() || undefined,
|
||||
command: values.command?.trim() || undefined,
|
||||
...(entrypoint?.length ? { entrypoint } : {}),
|
||||
...(args?.length ? { args } : {}),
|
||||
...(env_variables ? { env_variables } : {}),
|
||||
...(secret_env_variables ? { secret_env_variables } : {}),
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
toast.success(t('Updated successfully'))
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['deployment-details'] })
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
toast.error(res.message || t('Update failed'))
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : t('Update failed')
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-10'>
|
||||
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-[72vh] overflow-y-auto py-2 pr-1'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
autoComplete='off'
|
||||
className='space-y-4'
|
||||
>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='image_url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Image')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='ollama/ollama:latest'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='traffic_port'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Port')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={65535}
|
||||
value={
|
||||
typeof field.value === 'number' ||
|
||||
typeof field.value === 'string'
|
||||
? field.value
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
field.onChange(v === '' ? undefined : Number(v))
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='entrypoint'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Entrypoint (space separated)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='bash -lc' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='args'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Args (space separated)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='--foo bar' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='command'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Command')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Optional' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Collapsible className='rounded-md border p-3'>
|
||||
<CollapsibleTrigger className='cursor-pointer text-sm'>
|
||||
{t('Registry (optional)')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className='mt-3 grid gap-4 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='registry_username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Registry username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete='off' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='registry_secret'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Registry secret')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='password'
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible className='rounded-md border p-3'>
|
||||
<CollapsibleTrigger className='cursor-pointer text-sm'>
|
||||
{t('Environment variables')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className='mt-3 grid gap-4 md:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='env_json'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Env (JSON object)')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-40 font-mono text-xs'
|
||||
placeholder='{"KEY":"VALUE"}'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='secret_env_json'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Secret env (JSON object)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='min-h-40 font-mono text-xs'
|
||||
placeholder='{"SECRET":"VALUE"}'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<DialogFooter className='pt-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Update')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
import {
|
||||
Search,
|
||||
Info,
|
||||
MousePointerClick,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { applyUpstreamOverwrite } from '../../api'
|
||||
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
|
||||
import type { SyncOverwritePayload } from '../../types'
|
||||
import { useModels } from '../models-provider'
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
description: 'Description',
|
||||
icon: 'Icon',
|
||||
tags: 'Tags',
|
||||
vendor: 'Vendor',
|
||||
name_rule: 'Name Rule',
|
||||
status: 'Status',
|
||||
endpoints: 'Endpoints',
|
||||
quota_types: 'Quota Types',
|
||||
enable_groups: 'Enable Groups',
|
||||
}
|
||||
|
||||
const formatValue = (value: unknown) => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'string') return value || '—'
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
type UpstreamConflictDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type ConflictFieldRow = {
|
||||
id: string
|
||||
modelName: string
|
||||
fieldKey: string
|
||||
fieldLabel: string
|
||||
localValue: unknown
|
||||
upstreamValue: unknown
|
||||
}
|
||||
|
||||
function ValuePreview({ value }: { value: unknown }) {
|
||||
return (
|
||||
<pre className='bg-muted/70 text-muted-foreground max-h-32 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-words whitespace-pre-wrap'>
|
||||
{formatValue(value)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpstreamConflictDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: UpstreamConflictDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
upstreamConflicts = [],
|
||||
setUpstreamConflicts,
|
||||
syncWizardOptions,
|
||||
} = useModels()
|
||||
const isMobile = useIsMobile()
|
||||
const [search, setSearch] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRowSelection({})
|
||||
setSearch('')
|
||||
setPageIndex(0)
|
||||
}
|
||||
}, [open, upstreamConflicts])
|
||||
|
||||
const conflictRows = useMemo<ConflictFieldRow[]>(() => {
|
||||
return upstreamConflicts.flatMap((conflict) => {
|
||||
if (!conflict.fields?.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return conflict.fields.map((field) => ({
|
||||
id: `${conflict.model_name}-${field.field}`,
|
||||
modelName: conflict.model_name,
|
||||
fieldKey: field.field,
|
||||
fieldLabel: FIELD_LABELS[field.field] || field.field,
|
||||
localValue: field.local,
|
||||
upstreamValue: field.upstream,
|
||||
}))
|
||||
})
|
||||
}, [upstreamConflicts])
|
||||
|
||||
const totalModels = upstreamConflicts.length
|
||||
const totalFields = conflictRows.length
|
||||
const normalizedSearch = search.trim().toLowerCase()
|
||||
|
||||
const { matchingModelNames, visibleRowIds } = useMemo(() => {
|
||||
if (!normalizedSearch) {
|
||||
return { matchingModelNames: null, visibleRowIds: null }
|
||||
}
|
||||
|
||||
const modelMatches = new Set<string>()
|
||||
upstreamConflicts.forEach((conflict) => {
|
||||
const modelMatch = conflict.model_name
|
||||
?.toLowerCase()
|
||||
.includes(normalizedSearch)
|
||||
|
||||
if (modelMatch) {
|
||||
modelMatches.add(conflict.model_name)
|
||||
return
|
||||
}
|
||||
|
||||
const fieldMatch = conflict.fields?.some((field) => {
|
||||
const label = FIELD_LABELS[field.field] || field.field
|
||||
return (
|
||||
label.toLowerCase().includes(normalizedSearch) ||
|
||||
field.field.toLowerCase().includes(normalizedSearch)
|
||||
)
|
||||
})
|
||||
|
||||
if (fieldMatch) {
|
||||
modelMatches.add(conflict.model_name)
|
||||
}
|
||||
})
|
||||
|
||||
const rowIdSet = new Set<string>()
|
||||
conflictRows.forEach((row) => {
|
||||
if (
|
||||
modelMatches.has(row.modelName) ||
|
||||
row.fieldLabel.toLowerCase().includes(normalizedSearch) ||
|
||||
row.fieldKey.toLowerCase().includes(normalizedSearch)
|
||||
) {
|
||||
rowIdSet.add(row.id)
|
||||
}
|
||||
})
|
||||
|
||||
return { matchingModelNames: modelMatches, visibleRowIds: rowIdSet }
|
||||
}, [normalizedSearch, upstreamConflicts, conflictRows])
|
||||
|
||||
const columns = useMemo<ColumnDef<ConflictFieldRow>[]>(() => {
|
||||
const modelColumn: ColumnDef<ConflictFieldRow> = {
|
||||
accessorKey: 'modelName',
|
||||
header: 'Model',
|
||||
cell: ({ row }) => (
|
||||
<div className='flex items-start gap-3'>
|
||||
{isMobile ? (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label='Select row'
|
||||
/>
|
||||
) : null}
|
||||
<div className='space-y-1'>
|
||||
<p className='leading-none font-medium'>{row.original.modelName}</p>
|
||||
<span className='text-muted-foreground font-mono text-xs'>
|
||||
{row.original.fieldKey}
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<StatusBadge
|
||||
label={row.original.fieldLabel}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size: 220,
|
||||
}
|
||||
|
||||
const diffColumn: ColumnDef<ConflictFieldRow> = {
|
||||
id: 'actions',
|
||||
header: 'Diff',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className={isMobile ? 'h-7 w-7 p-0' : 'h-7 gap-2 px-2 text-xs'}
|
||||
>
|
||||
<MousePointerClick className='h-3.5 w-3.5' />
|
||||
{!isMobile && 'View diff'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[min(90vw,24rem)] space-y-4 text-sm'>
|
||||
<div>
|
||||
<StatusBadge
|
||||
label='Local'
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='mb-1'
|
||||
/>
|
||||
<pre className='bg-muted rounded-md p-2 text-xs'>
|
||||
{formatValue(row.original.localValue)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge
|
||||
label='Upstream'
|
||||
variant='info'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='mb-1'
|
||||
/>
|
||||
<pre className='bg-muted rounded-md p-2 text-xs'>
|
||||
{formatValue(row.original.upstreamValue)}
|
||||
</pre>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
size: isMobile ? 50 : 140,
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return [modelColumn, diffColumn]
|
||||
}
|
||||
|
||||
const selectionColumn: ColumnDef<ConflictFieldRow> = {
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label='Select row'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
}
|
||||
|
||||
return [
|
||||
selectionColumn,
|
||||
modelColumn,
|
||||
{
|
||||
accessorKey: 'fieldLabel',
|
||||
header: 'Field',
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
label={row.original.fieldLabel}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
},
|
||||
{
|
||||
accessorKey: 'localValue',
|
||||
header: 'Local Value',
|
||||
cell: ({ row }) => <ValuePreview value={row.original.localValue} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'upstreamValue',
|
||||
header: 'Upstream Value',
|
||||
cell: ({ row }) => <ValuePreview value={row.original.upstreamValue} />,
|
||||
},
|
||||
]
|
||||
}, [isMobile])
|
||||
|
||||
const table = useReactTable({
|
||||
data: conflictRows,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const totalSelectedFields = table.getSelectedRowModel().rows.length
|
||||
const hasSelection = totalSelectedFields > 0
|
||||
const allRows = table.getRowModel().rows
|
||||
const filteredRows = visibleRowIds
|
||||
? allRows.filter((row) => visibleRowIds.has(row.id))
|
||||
: allRows
|
||||
|
||||
const totalFilteredFields = filteredRows.length
|
||||
const totalPages =
|
||||
totalFilteredFields === 0 ? 1 : Math.ceil(totalFilteredFields / pageSize)
|
||||
|
||||
useEffect(() => {
|
||||
setPageIndex((prev) => Math.min(prev, Math.max(0, totalPages - 1)))
|
||||
}, [totalPages])
|
||||
|
||||
const pageStart = pageIndex * pageSize
|
||||
const paginatedRows = filteredRows.slice(pageStart, pageStart + pageSize)
|
||||
const displayStart = totalFilteredFields === 0 ? 0 : pageStart + 1
|
||||
const displayEnd =
|
||||
totalFilteredFields === 0
|
||||
? 0
|
||||
: Math.min(pageStart + pageSize, totalFilteredFields)
|
||||
const currentPageDisplay = totalFilteredFields === 0 ? 0 : pageIndex + 1
|
||||
const totalPagesDisplay = totalFilteredFields === 0 ? 0 : totalPages
|
||||
|
||||
const visibleModelCount = matchingModelNames?.size ?? totalModels
|
||||
const visibleFieldCount = totalFilteredFields
|
||||
const hasConflicts = totalFields > 0
|
||||
const showSearchEmptyState = hasConflicts && totalFilteredFields === 0
|
||||
|
||||
const clearSelections = useCallback(() => {
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
|
||||
const handleApplyOverwrite = async () => {
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
const groupedSelections = selectedRows.reduce<Record<string, Set<string>>>(
|
||||
(acc, row) => {
|
||||
const key = row.original.modelName
|
||||
if (!acc[key]) {
|
||||
acc[key] = new Set()
|
||||
}
|
||||
acc[key].add(row.original.fieldKey)
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const payload: SyncOverwritePayload[] = Object.entries(groupedSelections)
|
||||
.map(([modelName, fields]) => ({
|
||||
model_name: modelName,
|
||||
fields: Array.from(fields),
|
||||
}))
|
||||
.filter((item) => item.fields.length > 0)
|
||||
|
||||
if (payload.length === 0) {
|
||||
toast.warning(t('Select at least one field to overwrite.'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const response = await applyUpstreamOverwrite({
|
||||
overwrite: payload,
|
||||
locale: syncWizardOptions.locale,
|
||||
source: syncWizardOptions.source,
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
toast.success(t('Selected conflicts were overwritten successfully.'))
|
||||
queryClient.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: vendorsQueryKeys.lists() })
|
||||
setUpstreamConflicts([])
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to apply overwrite.'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || t('Failed to apply overwrite.'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setUpstreamConflicts([])
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-5xl sm:p-6'
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (isMobile) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
|
||||
<DialogHeader className='flex-shrink-0 text-start'>
|
||||
<DialogTitle>{t('Resolve Conflicts')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!hasConflicts ? (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
|
||||
{t('No conflict entries available.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium'>
|
||||
{visibleModelCount} {t('model')}
|
||||
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{visibleFieldCount} {t('field')}
|
||||
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
|
||||
{totalSelectedFields} {t('selected')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value)
|
||||
setPageIndex(0)
|
||||
}}
|
||||
placeholder={t('Search models or fields...')}
|
||||
className='pl-9'
|
||||
aria-label={t('Search conflicting models or fields')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={clearSelections}
|
||||
disabled={!hasSelection}
|
||||
>
|
||||
{t('Clear selection')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSearchEmptyState ? (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
|
||||
{t('No conflicts match your search.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedRows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
|
||||
{visibleFieldCount} {t('field')}
|
||||
{visibleFieldCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'>
|
||||
<div className='flex items-center gap-1.5 text-xs sm:gap-2'>
|
||||
<span className='hidden sm:inline'>
|
||||
{t('Rows per page')}
|
||||
</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(Number(value))
|
||||
setPageIndex(0)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[5, 10, 20, 50].map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-7 w-7 sm:h-8 sm:w-8'
|
||||
onClick={() =>
|
||||
setPageIndex((prev) => Math.max(0, prev - 1))
|
||||
}
|
||||
disabled={pageIndex === 0}
|
||||
aria-label={t('Previous page')}
|
||||
>
|
||||
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
<span className='text-xs font-medium'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPageDisplay,
|
||||
total: totalPagesDisplay,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-7 w-7 sm:h-8 sm:w-8'
|
||||
onClick={() =>
|
||||
setPageIndex((prev) =>
|
||||
Math.min(totalPages - 1, prev + 1)
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
pageIndex >= totalPages - 1 ||
|
||||
totalFilteredFields === 0
|
||||
}
|
||||
aria-label={t('Next page')}
|
||||
>
|
||||
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className='flex-shrink-0'>
|
||||
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
|
||||
<Info className='h-4 w-4 flex-shrink-0' />
|
||||
<span>
|
||||
{t(
|
||||
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setUpstreamConflicts([])
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApplyOverwrite}
|
||||
disabled={isSubmitting || !hasSelection}
|
||||
>
|
||||
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createVendor, updateVendor } from '../../api'
|
||||
import { vendorsQueryKeys, modelsQueryKeys } from '../../lib'
|
||||
import { vendorFormSchema, type Vendor } from '../../types'
|
||||
|
||||
type VendorMutateDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentVendor?: Vendor | null
|
||||
}
|
||||
|
||||
export function VendorMutateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentVendor,
|
||||
}: VendorMutateDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const isEdit = Boolean(currentVendor?.id)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(vendorFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
status: 1,
|
||||
},
|
||||
})
|
||||
|
||||
// Load vendor data for editing
|
||||
useEffect(() => {
|
||||
if (open && isEdit && currentVendor) {
|
||||
form.reset({
|
||||
id: currentVendor.id,
|
||||
name: currentVendor.name,
|
||||
description: currentVendor.description || '',
|
||||
icon: currentVendor.icon || '',
|
||||
status: currentVendor.status || 1,
|
||||
})
|
||||
} else if (open && !isEdit) {
|
||||
form.reset({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
status: 1,
|
||||
})
|
||||
}
|
||||
}, [open, isEdit, currentVendor, form])
|
||||
|
||||
const onSubmit = async (values: Record<string, unknown>) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const response = isEdit
|
||||
? await updateVendor({ ...values, id: currentVendor!.id })
|
||||
: await createVendor(values)
|
||||
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
isEdit ? 'Vendor updated successfully' : 'Vendor created successfully'
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: vendorsQueryKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(response.message || 'Operation failed')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || 'Operation failed')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? t('Edit Vendor') : t('Create Vendor')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? t('Update vendor information for {{name}}', {
|
||||
name: currentVendor?.name,
|
||||
})
|
||||
: t('Add a new vendor to the system')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Vendor Name *')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('OpenAI, Anthropic, etc.')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The unique name for this vendor')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('Describe this vendor...')}
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Icon')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('OpenAI, Anthropic, Google, etc.')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('@lobehub/icons key name')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{isSaving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Copy, ExternalLink, Loader2, RefreshCcw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { getDeployment, listDeploymentContainers } from '../../api'
|
||||
|
||||
export function ViewDetailsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
deploymentId,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
deploymentId: string | number | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
data: detailsRes,
|
||||
isLoading: isLoadingDetails,
|
||||
refetch: refetchDetails,
|
||||
isFetching: isFetchingDetails,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-details', deploymentId],
|
||||
queryFn: () => (deploymentId ? getDeployment(deploymentId) : null),
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
const {
|
||||
data: containersRes,
|
||||
isLoading: isLoadingContainers,
|
||||
refetch: refetchContainers,
|
||||
isFetching: isFetchingContainers,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-details-containers', deploymentId],
|
||||
queryFn: () =>
|
||||
deploymentId ? listDeploymentContainers(deploymentId) : null,
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
const details = detailsRes?.data
|
||||
const containers = useMemo(() => {
|
||||
const items = containersRes?.data?.containers
|
||||
return Array.isArray(items) ? items : []
|
||||
}, [containersRes?.data?.containers])
|
||||
|
||||
const locations = useMemo(() => {
|
||||
const items = details?.locations
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map((x) => {
|
||||
if (!x || typeof x !== 'object') return null
|
||||
const name = (x as Record<string, unknown>)?.name
|
||||
const iso2 = (x as Record<string, unknown>)?.iso2
|
||||
const id = (x as Record<string, unknown>)?.id
|
||||
return `${String(name ?? id ?? '')}${iso2 ? ` (${iso2})` : ''}`.trim()
|
||||
})
|
||||
.filter(Boolean) as string[]
|
||||
}, [details])
|
||||
|
||||
const handleCopyId = async () => {
|
||||
if (deploymentId === null || deploymentId === undefined) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(deploymentId))
|
||||
toast.success(t('Copied'))
|
||||
} catch {
|
||||
toast.error(t('Copy failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchDetails()
|
||||
refetchContainers()
|
||||
}
|
||||
|
||||
const payloadJson = useMemo(() => {
|
||||
if (!details) return ''
|
||||
try {
|
||||
return JSON.stringify(details, null, 2)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}, [details])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Deployment details')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='max-h-[72vh] space-y-4 overflow-y-auto py-2 pr-1'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Deployment ID')}:{' '}
|
||||
<span className='font-mono'>{deploymentId}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button variant='outline' size='sm' onClick={handleCopyId}>
|
||||
<Copy className='mr-2 h-4 w-4' />
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetchingDetails || isFetchingContainers}
|
||||
>
|
||||
{isFetchingDetails || isFetchingContainers ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<RefreshCcw className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{isLoadingDetails || isLoadingContainers ? (
|
||||
<div className='flex items-center justify-center py-10'>
|
||||
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
|
||||
</div>
|
||||
) : !detailsRes?.success ? (
|
||||
<div className='text-muted-foreground py-10 text-center text-sm'>
|
||||
{detailsRes?.message || t('Failed to fetch deployment details')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Status')}
|
||||
</div>
|
||||
<div className='mt-1 font-medium'>
|
||||
{String(details?.status ?? '-')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Hardware')}
|
||||
</div>
|
||||
<div className='mt-1 font-medium'>
|
||||
{String(details?.brand_name ?? '')}{' '}
|
||||
{String(details?.hardware_name ?? '')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Total GPUs')}
|
||||
</div>
|
||||
<div className='mt-1 font-medium'>
|
||||
{String(
|
||||
details?.total_gpus ?? details?.hardware_qty ?? '-'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Containers')}
|
||||
</div>
|
||||
<div className='mt-1 font-medium'>{containers.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{locations.length ? (
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Locations')}
|
||||
</div>
|
||||
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
|
||||
{locations.map((x) => (
|
||||
<span key={x} className='bg-muted rounded-md px-2 py-1'>
|
||||
{x}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{containers.length ? (
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground mb-2 text-xs'>
|
||||
{t('Containers')}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
{containers.map((c) => {
|
||||
const id = c?.container_id
|
||||
if (typeof id !== 'string' || !id) return null
|
||||
const status =
|
||||
typeof c?.status === 'string' ? c.status : undefined
|
||||
const url =
|
||||
typeof c?.public_url === 'string' ? c.public_url : ''
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
|
||||
>
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{id}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{status ? `${t('Status')}: ${status}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{url ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
>
|
||||
<ExternalLink className='mr-2 h-4 w-4' />
|
||||
{t('Open')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Collapsible className='rounded-lg border p-3'>
|
||||
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
|
||||
{t('Raw JSON')}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
|
||||
{payloadJson || '-'}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
|
||||
|
||||
interface ViewLogsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
deploymentId: string | number | null
|
||||
}
|
||||
|
||||
export function ViewLogsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
deploymentId,
|
||||
}: ViewLogsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [autoRefresh, setAutoRefresh] = useState(false)
|
||||
const [stream, setStream] = useState<'stdout' | 'stderr' | 'all'>('stdout')
|
||||
const [containerId, setContainerId] = useState<string>('')
|
||||
|
||||
const {
|
||||
data: containersData,
|
||||
isLoading: isLoadingContainers,
|
||||
refetch: refetchContainers,
|
||||
isFetching: isFetchingContainers,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-containers', deploymentId],
|
||||
queryFn: () =>
|
||||
deploymentId ? listDeploymentContainers(deploymentId) : null,
|
||||
enabled: open && deploymentId !== null,
|
||||
})
|
||||
|
||||
const containers = useMemo(() => {
|
||||
const items = containersData?.data?.containers
|
||||
return Array.isArray(items) ? items : []
|
||||
}, [containersData?.data?.containers])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setContainerId('')
|
||||
|
||||
setStream('stdout')
|
||||
|
||||
setAutoScroll(true)
|
||||
|
||||
setAutoRefresh(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (open && containers.length > 0 && !containerId) {
|
||||
const first = containers[0]?.container_id
|
||||
if (typeof first === 'string' && first) {
|
||||
setContainerId(first)
|
||||
}
|
||||
}
|
||||
}, [open, containers, containerId])
|
||||
|
||||
const {
|
||||
data: logsData,
|
||||
isLoading: isLoadingLogs,
|
||||
refetch: refetchLogs,
|
||||
isFetching: isFetchingLogs,
|
||||
} = useQuery({
|
||||
queryKey: ['deployment-logs', deploymentId, containerId, stream],
|
||||
queryFn: () =>
|
||||
deploymentId && containerId
|
||||
? getDeploymentLogs(deploymentId, {
|
||||
container_id: containerId,
|
||||
stream,
|
||||
limit: 500,
|
||||
})
|
||||
: null,
|
||||
enabled: open && deploymentId !== null && Boolean(containerId),
|
||||
refetchInterval: open && autoRefresh ? 5000 : false,
|
||||
})
|
||||
|
||||
const logsText = useMemo(() => {
|
||||
const raw = logsData?.data
|
||||
return typeof raw === 'string' ? raw : ''
|
||||
}, [logsData?.data])
|
||||
|
||||
const logLines = useMemo(() => {
|
||||
const normalized = logsText.replace(/\r\n?/g, '\n')
|
||||
return normalized ? normalized.split('\n') : []
|
||||
}, [logsText])
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [logLines, autoScroll])
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!logsText.trim()) return
|
||||
const blob = new Blob([logsText], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `deployment-${deploymentId}-${containerId || 'logs'}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='flex h-[80vh] max-w-4xl flex-col'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<Terminal className='h-5 w-5' />
|
||||
{t('Deployment logs')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Deployment ID')}: {deploymentId}
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
refetchContainers()
|
||||
refetchLogs()
|
||||
}}
|
||||
disabled={isFetchingLogs || isFetchingContainers}
|
||||
>
|
||||
{isFetchingLogs || isFetchingContainers ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<RefreshCcw className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleDownload}
|
||||
disabled={!logsText.trim()}
|
||||
>
|
||||
<Download className='mr-2 h-4 w-4' />
|
||||
{t('Download')}
|
||||
</Button>
|
||||
<div className='flex items-center gap-2 rounded-md border px-3 py-1.5'>
|
||||
<span className='text-xs'>{t('Auto refresh')}</span>
|
||||
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-3 grid gap-3 sm:grid-cols-2'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Container')}
|
||||
</div>
|
||||
<Select
|
||||
value={containerId}
|
||||
onValueChange={(v) => setContainerId(v)}
|
||||
disabled={isLoadingContainers || containers.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingContainers
|
||||
? t('Loading...')
|
||||
: containers.length === 0
|
||||
? t('No containers')
|
||||
: t('Select')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((c) => {
|
||||
const id = c?.container_id
|
||||
if (typeof id !== 'string' || !id) return null
|
||||
const status =
|
||||
typeof c?.status === 'string' && c.status
|
||||
? ` (${c.status})`
|
||||
: ''
|
||||
return (
|
||||
<SelectItem key={id} value={id}>
|
||||
{id}
|
||||
{status}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
|
||||
<Select
|
||||
value={stream}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'stderr' || v === 'all' || v === 'stdout') {
|
||||
setStream(v)
|
||||
} else {
|
||||
setStream('stdout')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('Select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='stdout'>stdout</SelectItem>
|
||||
<SelectItem value='stderr'>stderr</SelectItem>
|
||||
<SelectItem value='all'>all</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className='flex-1 overflow-auto rounded-md border bg-black p-4'
|
||||
onScroll={(e) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
const isAtBottom =
|
||||
target.scrollHeight - target.scrollTop - target.clientHeight < 50
|
||||
setAutoScroll(isAtBottom)
|
||||
}}
|
||||
>
|
||||
{isLoadingContainers || isLoadingLogs ? (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
|
||||
</div>
|
||||
) : containers.length === 0 ? (
|
||||
<div className='py-8 text-center text-gray-400'>
|
||||
{t('No containers')}
|
||||
</div>
|
||||
) : !containerId ? (
|
||||
<div className='py-8 text-center text-gray-400'>
|
||||
{t('Please select a container')}
|
||||
</div>
|
||||
) : !logsText.trim() ? (
|
||||
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
|
||||
) : (
|
||||
<div className='font-mono text-sm'>
|
||||
{logLines.map((line, idx) => (
|
||||
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+364
@@ -0,0 +1,364 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { JsonEditor } from '@/components/json-editor'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TagInput } from '@/components/tag-input'
|
||||
import { createPrefillGroup, updatePrefillGroup } from '../../api'
|
||||
import { ENDPOINT_TEMPLATES } from '../../constants'
|
||||
import { prefillGroupsQueryKeys } from '../../lib'
|
||||
import {
|
||||
prefillGroupFormSchema,
|
||||
type PrefillGroup,
|
||||
type PrefillGroupFormValues,
|
||||
} from '../../types'
|
||||
import {
|
||||
DEFAULT_FORM_VALUES,
|
||||
PREFILL_GROUP_TYPE_META,
|
||||
PREFILL_GROUP_TYPES,
|
||||
type PrefillGroupType,
|
||||
parseStringItems,
|
||||
serializeEndpointItems,
|
||||
} from '../prefill-group-shared'
|
||||
|
||||
type PrefillGroupFormDrawerProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
currentGroup: PrefillGroup | null
|
||||
}
|
||||
|
||||
export function PrefillGroupFormDrawer({
|
||||
open,
|
||||
onClose,
|
||||
currentGroup,
|
||||
}: PrefillGroupFormDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const isEdit = Boolean(currentGroup?.id)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const form = useForm<PrefillGroupFormValues>({
|
||||
resolver: zodResolver(prefillGroupFormSchema),
|
||||
defaultValues: DEFAULT_FORM_VALUES,
|
||||
})
|
||||
|
||||
const selectedType = form.watch('type')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (isEdit && currentGroup) {
|
||||
form.reset({
|
||||
id: currentGroup.id,
|
||||
name: currentGroup.name,
|
||||
description: currentGroup.description || '',
|
||||
type: currentGroup.type,
|
||||
items:
|
||||
currentGroup.type === 'endpoint'
|
||||
? serializeEndpointItems(currentGroup.items)
|
||||
: parseStringItems(currentGroup.items),
|
||||
})
|
||||
} else {
|
||||
form.reset(DEFAULT_FORM_VALUES)
|
||||
}
|
||||
}
|
||||
}, [open, isEdit, currentGroup, form])
|
||||
|
||||
useEffect(() => {
|
||||
const currentItems = form.getValues('items')
|
||||
if (selectedType === 'endpoint' && Array.isArray(currentItems)) {
|
||||
form.setValue('items', '', { shouldValidate: false })
|
||||
} else if (
|
||||
selectedType !== 'endpoint' &&
|
||||
typeof currentItems === 'string'
|
||||
) {
|
||||
form.setValue('items', [], { shouldValidate: false })
|
||||
}
|
||||
}, [selectedType, form])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: PrefillGroupFormValues) => {
|
||||
setIsSaving(true)
|
||||
const payload = {
|
||||
name: values.name.trim(),
|
||||
type: values.type,
|
||||
description: values.description?.trim() || '',
|
||||
items:
|
||||
values.type === 'endpoint'
|
||||
? typeof values.items === 'string'
|
||||
? values.items
|
||||
: ''
|
||||
: Array.isArray(values.items)
|
||||
? values.items
|
||||
: [],
|
||||
}
|
||||
|
||||
try {
|
||||
const response = isEdit
|
||||
? await updatePrefillGroup({
|
||||
id: currentGroup!.id,
|
||||
...payload,
|
||||
})
|
||||
: await createPrefillGroup(payload)
|
||||
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
isEdit ? 'Prefill group updated' : 'Prefill group created'
|
||||
)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: prefillGroupsQueryKeys.lists(),
|
||||
})
|
||||
onClose()
|
||||
} else {
|
||||
toast.error(response.message || 'Operation failed')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Operation failed')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const meta =
|
||||
PREFILL_GROUP_TYPE_META[selectedType] || PREFILL_GROUP_TYPE_META.model
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetContent className='flex w-full flex-col sm:max-w-2xl'>
|
||||
<SheetHeader className='text-start'>
|
||||
<SheetTitle>
|
||||
{isEdit ? t('Edit Prefill Group') : t('Create Prefill Group')}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isEdit
|
||||
? t('Update the reusable bundle below.')
|
||||
: t('Capture a reusable bundle of models, tags, or endpoints.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='prefill-group-form'
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='flex-1 space-y-6 overflow-y-auto px-4'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-sm font-semibold'>{t('Group details')}</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'Give the group a recognizable name and optional description.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Group Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Premium chat models')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Give this group a recognizable name.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
'Optional notes about when to use this group'
|
||||
)}
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Make it easier for teammates to pick the right group.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<h3 className='text-sm font-semibold'>{t('Configuration')}</h3>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Choose the bundle type and define the items inside it.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group Type</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value: PrefillGroupType) =>
|
||||
field.onChange(value)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='[&_[data-slot=select-value]_[data-prefill-description]]:hidden'>
|
||||
<SelectValue placeholder={t('Select a group type')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{PREFILL_GROUP_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className='flex flex-col text-left'>
|
||||
<span className='font-medium'>{type.label}</span>
|
||||
<span
|
||||
data-prefill-description
|
||||
className='text-muted-foreground text-xs'
|
||||
>
|
||||
{type.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t('Determines how this group is applied elsewhere.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='space-y-2 rounded-lg border p-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h4 className='text-sm font-medium'>{t('Project')}</h4>
|
||||
<StatusBadge
|
||||
label={meta.label}
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='items'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='sr-only'>{t('Items')}</FormLabel>
|
||||
<FormControl>
|
||||
{selectedType === 'endpoint' ? (
|
||||
<JsonEditor
|
||||
value={(field.value as string) || ''}
|
||||
onChange={field.onChange}
|
||||
keyPlaceholder='provider'
|
||||
valuePlaceholder='{"path": "/v1/...","method": "POST"}'
|
||||
keyLabel={t('Provider')}
|
||||
valueLabel={t('Endpoint config')}
|
||||
valueType='any'
|
||||
template={ENDPOINT_TEMPLATES}
|
||||
emptyMessage={t(
|
||||
'Define endpoint mappings for each provider.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TagInput
|
||||
value={
|
||||
Array.isArray(field.value) ? field.value : []
|
||||
}
|
||||
onChange={field.onChange}
|
||||
placeholder={t('Enter a value and press Enter')}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{selectedType === 'endpoint'
|
||||
? t(
|
||||
'Provide a JSON object where each key maps to an endpoint definition.'
|
||||
)
|
||||
: t('Add each model or tag you want to include.')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<SheetFooter className='gap-2'>
|
||||
<SheetClose asChild>
|
||||
<Button type='button' variant='outline' disabled={isSaving}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<Button type='submit' form='prefill-group-form' disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{isSaving
|
||||
? t('Saving...')
|
||||
: isEdit
|
||||
? t('Save changes')
|
||||
: t('Create')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
getModelStatusConfig,
|
||||
getNameRuleConfig,
|
||||
getQuotaTypeConfig,
|
||||
} from '../constants'
|
||||
import { parseModelTags, formatEndpointsDisplay } from '../lib'
|
||||
import type { Model, Vendor } from '../types'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
import { DescriptionCell } from './description-cell'
|
||||
|
||||
/**
|
||||
* Render limited items with "and X more" indicator
|
||||
*/
|
||||
function renderLimitedItems(
|
||||
items: React.ReactNode[],
|
||||
maxDisplay: number = 2
|
||||
): React.ReactNode {
|
||||
if (items.length === 0)
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
|
||||
const displayed = items.slice(0, maxDisplay)
|
||||
const remaining = items.length - maxDisplay
|
||||
|
||||
return (
|
||||
<div className='flex max-w-full items-center gap-1 overflow-x-auto'>
|
||||
{displayed}
|
||||
{remaining > 0 && (
|
||||
<StatusBadge
|
||||
label={`+${remaining}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='flex-shrink-0'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate models columns configuration
|
||||
*/
|
||||
export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Get translated configs
|
||||
const NAME_RULE_CONFIG = getNameRuleConfig(t)
|
||||
const MODEL_STATUS_CONFIG = getModelStatusConfig(t)
|
||||
const QUOTA_TYPE_CONFIG = getQuotaTypeConfig(t)
|
||||
|
||||
const vendorMap: Record<number, Vendor> = {}
|
||||
vendors.forEach((v) => {
|
||||
vendorMap[v.id] = v
|
||||
})
|
||||
|
||||
return [
|
||||
// Checkbox column
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label='Select row'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
|
||||
// ID column
|
||||
{
|
||||
accessorKey: 'id',
|
||||
meta: { label: t('ID'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='ID' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const id = row.getValue('id') as number
|
||||
return (
|
||||
<StatusBadge
|
||||
label={String(id)}
|
||||
variant='neutral'
|
||||
copyText={String(id)}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
|
||||
// Icon column
|
||||
{
|
||||
accessorKey: 'icon',
|
||||
meta: { label: t('Icon'), mobileHidden: true },
|
||||
header: t('Icon'),
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const iconKey =
|
||||
model.icon ||
|
||||
vendorMap[model.vendor_id || 0]?.icon ||
|
||||
model.model_name?.[0] ||
|
||||
'N'
|
||||
const icon = getLobeIcon(iconKey, 20)
|
||||
|
||||
return <div className='flex items-center justify-center'>{icon}</div>
|
||||
},
|
||||
size: 70,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Model Name column
|
||||
{
|
||||
accessorKey: 'model_name',
|
||||
meta: { label: t('Model Name'), mobileTitle: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Model Name')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue('model_name') as string
|
||||
return (
|
||||
<StatusBadge
|
||||
label={name}
|
||||
variant='neutral'
|
||||
copyText={name}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
)
|
||||
},
|
||||
minSize: 200,
|
||||
},
|
||||
|
||||
// Name Rule column
|
||||
{
|
||||
accessorKey: 'name_rule',
|
||||
meta: { label: t('Match Type') },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Match Type')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const rule = row.getValue('name_rule') as 0 | 1 | 2 | 3
|
||||
const model = row.original
|
||||
const config = NAME_RULE_CONFIG[rule]
|
||||
|
||||
let label = config.label
|
||||
if (rule !== 0 && model.matched_count) {
|
||||
label = `${config.label} (${model.matched_count})`
|
||||
}
|
||||
|
||||
const badge = (
|
||||
<StatusBadge
|
||||
label={label}
|
||||
variant={
|
||||
(config.color === 'error' ? 'danger' : config.color) as
|
||||
| 'neutral'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'info'
|
||||
}
|
||||
size='sm'
|
||||
/>
|
||||
)
|
||||
|
||||
// Show tooltip with matched models for non-exact rules
|
||||
if (
|
||||
rule !== 0 &&
|
||||
model.matched_models &&
|
||||
model.matched_models.length > 0
|
||||
) {
|
||||
const matchedBadges = model.matched_models.map((m, idx) => (
|
||||
<StatusBadge key={idx} label={m} autoColor={m} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{badge}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{matchedBadges}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return badge
|
||||
},
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Status column
|
||||
{
|
||||
accessorKey: 'status',
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
header: t('Status'),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as number
|
||||
const config =
|
||||
MODEL_STATUS_CONFIG[status as 0 | 1] || MODEL_STATUS_CONFIG[0]
|
||||
|
||||
return (
|
||||
<StatusBadge
|
||||
label={config.label}
|
||||
variant={config.variant}
|
||||
showDot={config.showDot}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
if (!value || value.length === 0 || value.includes('all')) return true
|
||||
const status = row.getValue(id) as number
|
||||
if (value.includes('enabled')) return status === 1
|
||||
if (value.includes('disabled')) return status !== 1
|
||||
return false
|
||||
},
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Vendor column
|
||||
{
|
||||
accessorKey: 'vendor_id',
|
||||
meta: { label: t('Vendor') },
|
||||
header: t('Vendor'),
|
||||
cell: ({ row }) => {
|
||||
const vendorId = row.getValue('vendor_id') as number
|
||||
const vendor = vendorMap[vendorId]
|
||||
|
||||
if (!vendor) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const icon = vendor.icon ? getLobeIcon(vendor.icon, 14) : null
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{icon}
|
||||
<StatusBadge
|
||||
label={vendor.name}
|
||||
autoColor={vendor.name}
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
if (!value || value.length === 0 || value.includes('all')) return true
|
||||
return value.includes(String(row.getValue(id)))
|
||||
},
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Description column
|
||||
{
|
||||
accessorKey: 'description',
|
||||
meta: { label: t('Description'), mobileHidden: true },
|
||||
header: t('Description'),
|
||||
cell: ({ row }) => {
|
||||
const description = row.getValue('description') as string
|
||||
const modelName = row.getValue('model_name') as string
|
||||
|
||||
return (
|
||||
<DescriptionCell modelName={modelName} description={description} />
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Tags column
|
||||
{
|
||||
accessorKey: 'tags',
|
||||
meta: { label: t('Tags'), mobileHidden: true },
|
||||
header: t('Tags'),
|
||||
cell: ({ row }) => {
|
||||
const tags = row.getValue('tags') as string
|
||||
const tagArray = parseModelTags(tags)
|
||||
|
||||
if (tagArray.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const tagBadges = tagArray.map((tag, idx) => (
|
||||
<StatusBadge key={idx} label={tag} autoColor={tag} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(tagBadges, 2)}</div>
|
||||
</TooltipTrigger>
|
||||
{tagArray.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{tagBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Endpoints column
|
||||
{
|
||||
accessorKey: 'endpoints',
|
||||
meta: { label: t('Endpoints'), mobileHidden: true },
|
||||
header: t('Endpoints'),
|
||||
cell: ({ row }) => {
|
||||
const endpoints = row.getValue('endpoints') as string
|
||||
const endpointArray = formatEndpointsDisplay(endpoints)
|
||||
|
||||
if (endpointArray.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const endpointBadges = endpointArray.map((ep, idx) => (
|
||||
<StatusBadge key={idx} label={ep} autoColor={ep} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(endpointBadges, 2)}</div>
|
||||
</TooltipTrigger>
|
||||
{endpointArray.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{endpointBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Bound Channels column
|
||||
{
|
||||
accessorKey: 'bound_channels',
|
||||
meta: { label: t('Bound Channels'), mobileHidden: true },
|
||||
header: t('Bound Channels'),
|
||||
cell: ({ row }) => {
|
||||
const channels = row.getValue('bound_channels') as Array<{
|
||||
id: number
|
||||
name: string
|
||||
type?: number
|
||||
status?: number
|
||||
}>
|
||||
|
||||
if (!channels || channels.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const channelBadges = channels.map((c, idx) => (
|
||||
<StatusBadge
|
||||
key={idx}
|
||||
label={`${c.name} (${c.type})`}
|
||||
autoColor={c.name}
|
||||
size='sm'
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(channelBadges, 2)}</div>
|
||||
</TooltipTrigger>
|
||||
{channels.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{channelBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Enable Groups column
|
||||
{
|
||||
accessorKey: 'enable_groups',
|
||||
meta: { label: t('Enable Groups'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Enable Groups')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const groups = row.getValue('enable_groups') as string[]
|
||||
|
||||
if (!groups || groups.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const groupBadges = groups.map((g, idx) => (
|
||||
<StatusBadge key={idx} label={g} autoColor={g} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(groupBadges, 2)}</div>
|
||||
</TooltipTrigger>
|
||||
{groups.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{groupBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Quota Types column
|
||||
{
|
||||
accessorKey: 'quota_types',
|
||||
meta: { label: t('Quota Types'), mobileHidden: true },
|
||||
header: t('Quota Types'),
|
||||
cell: ({ row }) => {
|
||||
const quotaTypes = row.getValue('quota_types') as number[]
|
||||
|
||||
if (!quotaTypes || quotaTypes.length === 0) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const quotaBadges = quotaTypes.map((qt, idx) => {
|
||||
const config = QUOTA_TYPE_CONFIG[qt]
|
||||
return (
|
||||
<StatusBadge
|
||||
key={idx}
|
||||
label={config?.label || String(qt)}
|
||||
variant={
|
||||
(config?.color === 'error' ? 'danger' : config?.color) as
|
||||
| 'neutral'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'info'
|
||||
}
|
||||
size='sm'
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{renderLimitedItems(quotaBadges, 2)}</div>
|
||||
</TooltipTrigger>
|
||||
{quotaTypes.length > 2 && (
|
||||
<TooltipContent
|
||||
side='top'
|
||||
className='border-border bg-popover max-h-48 max-w-[320px] overflow-y-auto p-2'
|
||||
>
|
||||
<div className='flex flex-wrap gap-1'>{quotaBadges}</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Sync Official column
|
||||
{
|
||||
accessorKey: 'sync_official',
|
||||
meta: { label: t('Official Sync'), mobileHidden: true },
|
||||
header: t('Official Sync'),
|
||||
cell: ({ row }) => {
|
||||
const syncOfficial = row.getValue('sync_official') as number
|
||||
return (
|
||||
<StatusBadge
|
||||
label={syncOfficial === 1 ? t('Official Sync') : t('No Sync')}
|
||||
variant={syncOfficial === 1 ? 'success' : 'warning'}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
if (!value || value.length === 0 || value.includes('all')) return true
|
||||
const syncOfficial = row.getValue(id) as number
|
||||
if (value.includes('yes')) return syncOfficial === 1
|
||||
if (value.includes('no')) return syncOfficial !== 1
|
||||
return false
|
||||
},
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
// Created Time column
|
||||
{
|
||||
accessorKey: 'created_time',
|
||||
meta: { label: t('Created'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Created')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const timestamp = row.getValue('created_time') as number
|
||||
return (
|
||||
<div className='min-w-[140px] font-mono text-sm'>
|
||||
{formatTimestampToDate(timestamp)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
},
|
||||
|
||||
// Updated Time column
|
||||
{
|
||||
accessorKey: 'updated_time',
|
||||
meta: { label: t('Updated'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Updated')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const timestamp = row.getValue('updated_time') as number
|
||||
return (
|
||||
<div className='min-w-[140px] font-mono text-sm'>
|
||||
{formatTimestampToDate(timestamp)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
},
|
||||
|
||||
// Actions column
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return <DataTableRowActions row={row} />
|
||||
},
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DescriptionDialog } from './dialogs/description-dialog'
|
||||
import { MissingModelsDialog } from './dialogs/missing-models-dialog'
|
||||
import { PrefillGroupManagement } from './dialogs/prefill-group-management'
|
||||
import { SyncWizardDialog } from './dialogs/sync-wizard-dialog'
|
||||
import { UpstreamConflictDialog } from './dialogs/upstream-conflict-dialog'
|
||||
import { VendorMutateDialog } from './dialogs/vendor-mutate-dialog'
|
||||
import { ModelMutateDrawer } from './drawers/model-mutate-drawer'
|
||||
import { useModels } from './models-provider'
|
||||
|
||||
export function ModelsDialogs() {
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
currentRow,
|
||||
currentVendor,
|
||||
descriptionData,
|
||||
setDescriptionData,
|
||||
} = useModels()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Model Create/Update Drawer */}
|
||||
<ModelMutateDrawer
|
||||
open={open === 'create-model' || open === 'update-model'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
|
||||
{/* Vendor Create/Update Dialog */}
|
||||
<VendorMutateDialog
|
||||
open={open === 'create-vendor' || open === 'update-vendor'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
currentVendor={open === 'update-vendor' ? currentVendor : null}
|
||||
/>
|
||||
|
||||
{/* Missing Models Dialog */}
|
||||
<MissingModelsDialog
|
||||
open={open === 'missing-models'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Sync Wizard Dialog */}
|
||||
<SyncWizardDialog
|
||||
open={open === 'sync-wizard'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Upstream Conflict Dialog */}
|
||||
<UpstreamConflictDialog
|
||||
open={open === 'upstream-conflict'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Prefill Groups Management */}
|
||||
<PrefillGroupManagement
|
||||
open={open === 'prefill-groups'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Description Dialog */}
|
||||
<DescriptionDialog
|
||||
open={open === 'description'}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) {
|
||||
setOpen(null)
|
||||
setDescriptionData(null)
|
||||
}
|
||||
}}
|
||||
modelName={descriptionData?.modelName || ''}
|
||||
description={descriptionData?.description || ''}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
List,
|
||||
Building2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useModels } from './models-provider'
|
||||
|
||||
export function ModelsPrimaryButtons() {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen, setCurrentRow } = useModels()
|
||||
|
||||
const handleCreateModel = () => {
|
||||
setCurrentRow(null)
|
||||
setOpen('create-model')
|
||||
}
|
||||
|
||||
const handleMissingModels = () => {
|
||||
setOpen('missing-models')
|
||||
}
|
||||
|
||||
const handleSync = () => {
|
||||
setOpen('sync-wizard')
|
||||
}
|
||||
|
||||
const handlePrefillGroups = () => {
|
||||
setOpen('prefill-groups')
|
||||
}
|
||||
|
||||
const handleManageVendors = () => {
|
||||
setOpen('create-vendor') // Will be a separate vendors management dialog
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Create Model */}
|
||||
<Button onClick={handleCreateModel} size='sm'>
|
||||
<Plus className='h-4 w-4' />
|
||||
{t('Add Model')}
|
||||
</Button>
|
||||
|
||||
{/* More Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-56'>
|
||||
<DropdownMenuItem onClick={handleMissingModels}>
|
||||
{t('Missing Models')}
|
||||
<DropdownMenuShortcut>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleSync}>
|
||||
{t('Sync Upstream')}
|
||||
<DropdownMenuShortcut>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handlePrefillGroups}>
|
||||
{t('Prefill Groups')}
|
||||
<DropdownMenuShortcut>
|
||||
<List className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleManageVendors}>
|
||||
{t('Manage Vendors')}
|
||||
<DropdownMenuShortcut>
|
||||
<Building2 className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
import type {
|
||||
Model,
|
||||
ModelTabCategory,
|
||||
Vendor,
|
||||
SyncDiffData,
|
||||
SyncLocale,
|
||||
SyncSource,
|
||||
} from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type DialogType =
|
||||
| 'create-model'
|
||||
| 'update-model'
|
||||
| 'create-vendor'
|
||||
| 'update-vendor'
|
||||
| 'missing-models'
|
||||
| 'sync-wizard'
|
||||
| 'upstream-conflict'
|
||||
| 'prefill-groups'
|
||||
| 'description'
|
||||
| null
|
||||
|
||||
type ModelsContextType = {
|
||||
open: DialogType
|
||||
setOpen: (open: DialogType) => void
|
||||
currentRow: Model | null
|
||||
setCurrentRow: (model: Model | null) => void
|
||||
currentVendor: Vendor | null
|
||||
setCurrentVendor: (vendor: Vendor | null) => void
|
||||
selectedVendor: string | null
|
||||
setSelectedVendor: (vendor: string | null) => void
|
||||
descriptionData: { modelName: string; description: string } | null
|
||||
setDescriptionData: (
|
||||
data: { modelName: string; description: string } | null
|
||||
) => void
|
||||
upstreamConflicts: SyncDiffData['conflicts']
|
||||
setUpstreamConflicts: (conflicts: SyncDiffData['conflicts']) => void
|
||||
syncWizardOptions: { locale: SyncLocale; source: SyncSource }
|
||||
setSyncWizardOptions: React.Dispatch<
|
||||
React.SetStateAction<{ locale: SyncLocale; source: SyncSource }>
|
||||
>
|
||||
tabCategory: ModelTabCategory
|
||||
setTabCategory: (category: ModelTabCategory) => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context
|
||||
// ============================================================================
|
||||
|
||||
const ModelsContext = createContext<ModelsContextType | undefined>(undefined)
|
||||
|
||||
// ============================================================================
|
||||
// Provider
|
||||
// ============================================================================
|
||||
|
||||
export function ModelsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState<DialogType>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Model | null>(null)
|
||||
const [currentVendor, setCurrentVendor] = useState<Vendor | null>(null)
|
||||
const [selectedVendor, setSelectedVendor] = useState<string | null>(null)
|
||||
const [descriptionData, setDescriptionData] = useState<{
|
||||
modelName: string
|
||||
description: string
|
||||
} | null>(null)
|
||||
const [upstreamConflicts, setUpstreamConflicts] = useState<
|
||||
SyncDiffData['conflicts']
|
||||
>([])
|
||||
const [syncWizardOptions, setSyncWizardOptions] = useState<{
|
||||
locale: SyncLocale
|
||||
source: SyncSource
|
||||
}>({
|
||||
locale: 'zh',
|
||||
source: 'official',
|
||||
})
|
||||
const [tabCategory, setTabCategory] = useState<ModelTabCategory>('metadata')
|
||||
|
||||
return (
|
||||
<ModelsContext.Provider
|
||||
value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentRow,
|
||||
setCurrentRow,
|
||||
currentVendor,
|
||||
setCurrentVendor,
|
||||
selectedVendor,
|
||||
setSelectedVendor,
|
||||
descriptionData,
|
||||
setDescriptionData,
|
||||
upstreamConflicts,
|
||||
setUpstreamConflicts,
|
||||
syncWizardOptions,
|
||||
setSyncWizardOptions,
|
||||
tabCategory,
|
||||
setTabCategory,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ModelsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useModels() {
|
||||
const context = useContext(ModelsContext)
|
||||
if (!context) {
|
||||
throw new Error('useModels must be used within ModelsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DataTableToolbar,
|
||||
TableSkeleton,
|
||||
TableEmpty,
|
||||
MobileCardList,
|
||||
} from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import { getModels, searchModels, getVendors } from '../api'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
getModelStatusOptions,
|
||||
getSyncStatusOptions,
|
||||
} from '../constants'
|
||||
import { modelsQueryKeys, vendorsQueryKeys } from '../lib'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
import { useModelsColumns } from './models-columns'
|
||||
import { useModels } from './models-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/models/$section')
|
||||
|
||||
export function ModelsTable() {
|
||||
const { t } = useTranslation()
|
||||
const { selectedVendor } = useModels()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
// Table state
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
description: false,
|
||||
bound_channels: false,
|
||||
quota_types: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
|
||||
// URL state management
|
||||
const {
|
||||
globalFilter,
|
||||
onGlobalFilterChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
ensurePageInRange,
|
||||
} = useTableUrlState({
|
||||
search: route.useSearch(),
|
||||
navigate: route.useNavigate(),
|
||||
pagination: { defaultPage: 1, defaultPageSize: DEFAULT_PAGE_SIZE },
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [
|
||||
{ columnId: 'status', searchKey: 'status', type: 'array' },
|
||||
{ columnId: 'vendor_id', searchKey: 'vendor', type: 'array' },
|
||||
{ columnId: 'sync_official', searchKey: 'sync', type: 'array' },
|
||||
],
|
||||
})
|
||||
|
||||
// Extract filters from column filters
|
||||
const statusFilter =
|
||||
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
|
||||
const vendorFilter =
|
||||
(columnFilters.find((f) => f.id === 'vendor_id')?.value as string[]) || []
|
||||
const syncFilter =
|
||||
(columnFilters.find((f) => f.id === 'sync_official')?.value as string[]) ||
|
||||
[]
|
||||
|
||||
// Fetch vendors for filter
|
||||
const { data: vendorsData } = useQuery({
|
||||
queryKey: vendorsQueryKeys.list(),
|
||||
queryFn: () => getVendors({ page_size: 1000 }),
|
||||
})
|
||||
|
||||
const vendors = useMemo(
|
||||
() => vendorsData?.data?.items || [],
|
||||
[vendorsData?.data?.items]
|
||||
)
|
||||
|
||||
const vendorOptions = useMemo(() => {
|
||||
return vendors.map((v) => ({
|
||||
label: v.name,
|
||||
value: String(v.id),
|
||||
}))
|
||||
}, [vendors])
|
||||
|
||||
// Determine whether to use search or regular list API
|
||||
const shouldSearch = Boolean(globalFilter?.trim())
|
||||
|
||||
// Apply selected vendor from context or filter
|
||||
const activeVendorFilter =
|
||||
selectedVendor ||
|
||||
(vendorFilter.length > 0 && !vendorFilter.includes('all')
|
||||
? vendorFilter[0]
|
||||
: undefined)
|
||||
|
||||
// Fetch models data
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: modelsQueryKeys.list({
|
||||
keyword: globalFilter,
|
||||
vendor: activeVendorFilter,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
sync_official:
|
||||
syncFilter.length > 0 && !syncFilter.includes('all')
|
||||
? syncFilter[0]
|
||||
: undefined,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (shouldSearch || activeVendorFilter) {
|
||||
return searchModels({
|
||||
keyword: globalFilter,
|
||||
vendor: activeVendorFilter,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
sync_official:
|
||||
syncFilter.length > 0 && !syncFilter.includes('all')
|
||||
? syncFilter[0]
|
||||
: undefined,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
} else {
|
||||
return getModels({
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
sync_official:
|
||||
syncFilter.length > 0 && !syncFilter.includes('all')
|
||||
? syncFilter[0]
|
||||
: undefined,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
}
|
||||
},
|
||||
placeholderData: (previousData) => previousData,
|
||||
})
|
||||
|
||||
const models = data?.data?.items || []
|
||||
const totalCount = data?.data?.total || 0
|
||||
const vendorCounts = data?.data?.vendor_counts
|
||||
|
||||
// Columns configuration
|
||||
const columns = useModelsColumns(vendors)
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
data: models,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalCount / pagination.pageSize),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
pagination,
|
||||
globalFilter,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
})
|
||||
|
||||
// Ensure page is in range when total count changes
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
// Prepare filter options
|
||||
const vendorFilterOptions = [
|
||||
{
|
||||
label: `${t('All Vendors')}${vendorCounts?.all ? ` (${vendorCounts.all})` : ''}`,
|
||||
value: 'all',
|
||||
},
|
||||
...vendorOptions.map((option) => ({
|
||||
label: `${option.label}${vendorCounts?.[option.value] ? ` (${vendorCounts[option.value]})` : ''}`,
|
||||
value: option.value,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder={t('Filter by model name...')}
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: [...getModelStatusOptions(t)],
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'vendor_id',
|
||||
title: t('Vendor'),
|
||||
options: vendorFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'sync_official',
|
||||
title: t('Official Sync'),
|
||||
options: [...getSyncStatusOptions(t)],
|
||||
singleSelect: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{isMobile ? (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyTitle={t('No Models Found')}
|
||||
emptyDescription={t(
|
||||
'No models available. Create your first model to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border transition-opacity duration-150',
|
||||
isFetching && !isLoading && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton table={table} keyPrefix='model-skeleton' />
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={columns.length}
|
||||
title={t('No Models Found')}
|
||||
description={t(
|
||||
'No models available. Create your first model to get started.'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTableBulkActions table={table} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<PageFooterPortal>
|
||||
<DataTablePagination
|
||||
table={table as ReturnType<typeof useReactTable>}
|
||||
/>
|
||||
</PageFooterPortal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { StatusBadgeProps } from '@/components/status-badge'
|
||||
import { type PrefillGroup, type PrefillGroupFormValues } from '../types'
|
||||
|
||||
export type PrefillGroupType = PrefillGroup['type']
|
||||
|
||||
export const PREFILL_GROUP_TYPES = [
|
||||
{
|
||||
value: 'model' as PrefillGroupType,
|
||||
label: 'Model Group',
|
||||
description: 'Reusable sets of models you can attach to channels.',
|
||||
badge: 'blue' as StatusBadgeProps['variant'],
|
||||
},
|
||||
{
|
||||
value: 'tag' as PrefillGroupType,
|
||||
label: 'Tag Group',
|
||||
description: 'Collections of metadata tags for bulk operations.',
|
||||
badge: 'purple' as StatusBadgeProps['variant'],
|
||||
},
|
||||
{
|
||||
value: 'endpoint' as PrefillGroupType,
|
||||
label: 'Endpoint Group',
|
||||
description: 'HTTP endpoint mappings shared across providers.',
|
||||
badge: 'cyan' as StatusBadgeProps['variant'],
|
||||
},
|
||||
] as const
|
||||
|
||||
export const PREFILL_GROUP_TYPE_META = PREFILL_GROUP_TYPES.reduce<
|
||||
Record<
|
||||
PrefillGroupType,
|
||||
{ label: string; badge: StatusBadgeProps['variant'] }
|
||||
>
|
||||
>(
|
||||
(acc, type) => {
|
||||
acc[type.value] = { label: type.label, badge: type.badge }
|
||||
return acc
|
||||
},
|
||||
{} as Record<
|
||||
PrefillGroupType,
|
||||
{ label: string; badge: StatusBadgeProps['variant'] }
|
||||
>
|
||||
)
|
||||
|
||||
export const DEFAULT_FORM_VALUES: PrefillGroupFormValues = {
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'model',
|
||||
items: [],
|
||||
}
|
||||
|
||||
export function parseStringItems(items: PrefillGroup['items']): string[] {
|
||||
if (!items) return []
|
||||
if (Array.isArray(items)) {
|
||||
return items
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (typeof items === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(items)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
} catch {
|
||||
return items
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function parseEndpointKeys(items: PrefillGroup['items']): string[] {
|
||||
if (!items) return []
|
||||
try {
|
||||
const parsed =
|
||||
typeof items === 'string' ? JSON.parse(items || '{}') : (items as unknown)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((item) =>
|
||||
typeof item === 'string'
|
||||
? item
|
||||
: typeof item?.name === 'string'
|
||||
? item.name
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return Object.keys(parsed)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function serializeEndpointItems(items: PrefillGroup['items']): string {
|
||||
if (!items) return ''
|
||||
if (typeof items === 'string') {
|
||||
return items
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(items, null, 2)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { type TFunction } from 'i18next'
|
||||
import type { NameRule, ModelStatus, SyncSource } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Pagination
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
// ============================================================================
|
||||
// Name Rule Options
|
||||
// ============================================================================
|
||||
|
||||
export function getNameRuleOptions(t: TFunction) {
|
||||
return [
|
||||
{ label: t('Exact Match'), value: 0 as NameRule },
|
||||
{ label: t('Prefix Match'), value: 1 as NameRule },
|
||||
{ label: t('Contains Match'), value: 2 as NameRule },
|
||||
{ label: t('Suffix Match'), value: 3 as NameRule },
|
||||
] as const
|
||||
}
|
||||
|
||||
export function getNameRuleConfig(
|
||||
t: TFunction
|
||||
): Record<NameRule, { label: string; color: string; description: string }> {
|
||||
return {
|
||||
0: {
|
||||
label: t('Exact'),
|
||||
color: 'green',
|
||||
description: t('Match model name exactly'),
|
||||
},
|
||||
1: {
|
||||
label: t('Prefix'),
|
||||
color: 'blue',
|
||||
description: t('Match models starting with this name'),
|
||||
},
|
||||
2: {
|
||||
label: t('Contains'),
|
||||
color: 'orange',
|
||||
description: t('Match models containing this name'),
|
||||
},
|
||||
3: {
|
||||
label: t('Suffix'),
|
||||
color: 'purple',
|
||||
description: t('Match models ending with this name'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model Status
|
||||
// ============================================================================
|
||||
|
||||
export function getModelStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ label: t('All Status'), value: 'all' },
|
||||
{ label: t('Enabled'), value: 'enabled' },
|
||||
{ label: t('Disabled'), value: 'disabled' },
|
||||
] as const
|
||||
}
|
||||
|
||||
export function getModelStatusConfig(
|
||||
t: TFunction
|
||||
): Record<
|
||||
ModelStatus,
|
||||
{ label: string; variant: 'success' | 'neutral'; showDot?: boolean }
|
||||
> {
|
||||
return {
|
||||
1: { label: t('Enabled'), variant: 'success', showDot: true },
|
||||
0: { label: t('Disabled'), variant: 'neutral' },
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sync Status Options
|
||||
// ============================================================================
|
||||
|
||||
export function getSyncStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ label: t('All Sync Status'), value: 'all' },
|
||||
{ label: t('Official Sync'), value: 'yes' },
|
||||
{ label: t('No Sync'), value: 'no' },
|
||||
] as const
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deployment Status
|
||||
// ============================================================================
|
||||
|
||||
export function getDeploymentStatusOptions(t: TFunction) {
|
||||
return [
|
||||
{ label: t('All Status'), value: 'all' },
|
||||
{ label: t('Running'), value: 'running' },
|
||||
{ label: t('Completed'), value: 'completed' },
|
||||
{ label: t('Failed'), value: 'failed' },
|
||||
{ label: t('Deployment requested'), value: 'deployment requested' },
|
||||
{ label: t('Termination requested'), value: 'termination requested' },
|
||||
{ label: t('Destroyed'), value: 'destroyed' },
|
||||
] as const
|
||||
}
|
||||
|
||||
export function getDeploymentStatusConfig(t: TFunction): Record<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
variant: 'success' | 'neutral' | 'warning' | 'danger'
|
||||
showDot?: boolean
|
||||
}
|
||||
> {
|
||||
return {
|
||||
running: { label: t('Running'), variant: 'success', showDot: true },
|
||||
completed: { label: t('Completed'), variant: 'success' },
|
||||
failed: { label: t('Failed'), variant: 'danger' },
|
||||
error: { label: t('Failed'), variant: 'danger' },
|
||||
destroyed: { label: t('Destroyed'), variant: 'danger' },
|
||||
'deployment requested': {
|
||||
label: t('Deployment requested'),
|
||||
variant: 'warning',
|
||||
showDot: true,
|
||||
},
|
||||
'termination requested': {
|
||||
label: t('Termination requested'),
|
||||
variant: 'warning',
|
||||
showDot: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quota Type
|
||||
// ============================================================================
|
||||
|
||||
export function getQuotaTypeConfig(
|
||||
t: TFunction
|
||||
): Record<number, { label: string; color: string }> {
|
||||
return {
|
||||
0: { label: t('Usage-based'), color: 'violet' },
|
||||
1: { label: t('Per-call'), color: 'teal' },
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Endpoint Templates
|
||||
// ============================================================================
|
||||
|
||||
export const ENDPOINT_TEMPLATES: Record<
|
||||
string,
|
||||
{ path: string; method: string }
|
||||
> = {
|
||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||
'openai-response': { path: '/v1/responses', method: 'POST' },
|
||||
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
|
||||
'jina-rerank': { path: '/rerank', method: 'POST' },
|
||||
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||
embeddings: { path: '/v1/embeddings', method: 'POST' },
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sync Locale Options
|
||||
// ============================================================================
|
||||
|
||||
export function getSyncLocaleOptions(t: TFunction) {
|
||||
return [
|
||||
{ label: t('Chinese'), value: 'zh' },
|
||||
{ label: t('English'), value: 'en' },
|
||||
{ label: t('Japanese'), value: 'ja' },
|
||||
] as const
|
||||
}
|
||||
|
||||
export function getSyncSourceOptions(t: TFunction) {
|
||||
return [
|
||||
{
|
||||
label: t('Official Repository'),
|
||||
value: 'official' as SyncSource,
|
||||
description: t('Sync from the public upstream metadata repository.'),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: t('Configuration File'),
|
||||
value: 'config' as SyncSource,
|
||||
description: t('Upload or reference a local configuration file.'),
|
||||
disabled: true,
|
||||
},
|
||||
] as const
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getDeploymentSettings, testDeploymentConnection } from '../api'
|
||||
|
||||
interface ConnectionState {
|
||||
loading: boolean
|
||||
ok: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// Connection cache (5 minutes TTL)
|
||||
const CONNECTION_CACHE_TTL = 5 * 60 * 1000
|
||||
let connectionCache: {
|
||||
ok: boolean
|
||||
timestamp: number
|
||||
} | null = null
|
||||
|
||||
function getCachedConnection(): boolean | null {
|
||||
if (!connectionCache) return null
|
||||
if (Date.now() - connectionCache.timestamp > CONNECTION_CACHE_TTL) {
|
||||
connectionCache = null
|
||||
return null
|
||||
}
|
||||
return connectionCache.ok
|
||||
}
|
||||
|
||||
function setCachedConnection(ok: boolean) {
|
||||
connectionCache = { ok, timestamp: Date.now() }
|
||||
}
|
||||
|
||||
export function clearConnectionCache() {
|
||||
connectionCache = null
|
||||
}
|
||||
|
||||
type LoadingPhase = 'idle' | 'settings' | 'connection' | 'done'
|
||||
|
||||
export function useModelDeploymentSettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingPhase, setLoadingPhase] = useState<LoadingPhase>('settings')
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({
|
||||
'model_deployment.ionet.enabled': false,
|
||||
})
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>({
|
||||
loading: false,
|
||||
ok: null,
|
||||
error: null,
|
||||
})
|
||||
const initialLoadRef = useRef(true)
|
||||
|
||||
// Parallel fetch: settings + connection test (when enabled)
|
||||
const fetchAll = useCallback(async (useCache = true) => {
|
||||
setLoading(true)
|
||||
setLoadingPhase('settings')
|
||||
|
||||
try {
|
||||
// Step 1: Fetch settings first (usually fast)
|
||||
const response = await getDeploymentSettings()
|
||||
const isEnabled = response?.success && response?.data?.enabled === true
|
||||
|
||||
setSettings({
|
||||
'model_deployment.ionet.enabled': isEnabled,
|
||||
})
|
||||
|
||||
if (!isEnabled) {
|
||||
// Not enabled, done
|
||||
setConnectionState({ loading: false, ok: null, error: null })
|
||||
setLoadingPhase('done')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Check connection (check cache first)
|
||||
if (useCache) {
|
||||
const cached = getCachedConnection()
|
||||
if (cached !== null) {
|
||||
setConnectionState({ loading: false, ok: cached, error: null })
|
||||
setLoadingPhase('done')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection
|
||||
setLoadingPhase('connection')
|
||||
setConnectionState({ loading: true, ok: null, error: null })
|
||||
|
||||
try {
|
||||
const connResponse = await testDeploymentConnection()
|
||||
if (connResponse?.success) {
|
||||
setCachedConnection(true)
|
||||
setConnectionState({ loading: false, ok: true, error: null })
|
||||
} else {
|
||||
const message = connResponse?.message || 'Connection failed'
|
||||
setCachedConnection(false)
|
||||
setConnectionState({ loading: false, ok: false, error: message })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Connection failed'
|
||||
setCachedConnection(false)
|
||||
setConnectionState({ loading: false, ok: false, error: errMsg })
|
||||
}
|
||||
} catch {
|
||||
// Settings fetch failed, use defaults
|
||||
setConnectionState({ loading: false, ok: null, error: null })
|
||||
} finally {
|
||||
setLoadingPhase('done')
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current) {
|
||||
initialLoadRef.current = false
|
||||
fetchAll(true)
|
||||
}
|
||||
}, [fetchAll])
|
||||
|
||||
const isIoNetEnabled = Boolean(settings['model_deployment.ionet.enabled'])
|
||||
|
||||
// Manual retry (skip cache)
|
||||
const testConnection = useCallback(async () => {
|
||||
clearConnectionCache()
|
||||
setConnectionState({ loading: true, ok: null, error: null })
|
||||
setLoadingPhase('connection')
|
||||
|
||||
try {
|
||||
const response = await testDeploymentConnection()
|
||||
if (response?.success) {
|
||||
setCachedConnection(true)
|
||||
setConnectionState({ loading: false, ok: true, error: null })
|
||||
return
|
||||
}
|
||||
const message = response?.message || 'Connection failed'
|
||||
setCachedConnection(false)
|
||||
setConnectionState({ loading: false, ok: false, error: message })
|
||||
} catch (error: unknown) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Connection failed'
|
||||
setCachedConnection(false)
|
||||
setConnectionState({ loading: false, ok: false, error: errMsg })
|
||||
} finally {
|
||||
setLoadingPhase('done')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Refresh all (skip cache)
|
||||
const refresh = useCallback(() => {
|
||||
clearConnectionCache()
|
||||
return fetchAll(false)
|
||||
}, [fetchAll])
|
||||
|
||||
// Refresh on window focus (useful after saving settings in another page)
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
// Use cache on focus to avoid unnecessary requests
|
||||
fetchAll(true)
|
||||
}
|
||||
window.addEventListener('focus', handler)
|
||||
return () => window.removeEventListener('focus', handler)
|
||||
}, [fetchAll])
|
||||
|
||||
return {
|
||||
loading,
|
||||
loadingPhase,
|
||||
settings,
|
||||
isIoNetEnabled,
|
||||
refresh,
|
||||
connectionLoading: connectionState.loading,
|
||||
connectionOk: connectionState.ok,
|
||||
connectionError: connectionState.error,
|
||||
testConnection,
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { listDeployments } from './api'
|
||||
import { DeploymentAccessGuard } from './components/deployment-access-guard'
|
||||
import { DeploymentsTable } from './components/deployments-table'
|
||||
import { CreateDeploymentDrawer } from './components/dialogs/create-deployment-drawer'
|
||||
import { ModelsDialogs } from './components/models-dialogs'
|
||||
import { ModelsPrimaryButtons } from './components/models-primary-buttons'
|
||||
import { ModelsProvider, useModels } from './components/models-provider'
|
||||
import { ModelsTable } from './components/models-table'
|
||||
import { useModelDeploymentSettings } from './hooks/use-model-deployment-settings'
|
||||
import { deploymentsQueryKeys } from './lib'
|
||||
import {
|
||||
type ModelsSectionId,
|
||||
MODELS_DEFAULT_SECTION,
|
||||
} from './section-registry'
|
||||
|
||||
const route = getRouteApi('/_authenticated/models/$section')
|
||||
|
||||
function ModelsContent() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { tabCategory, setTabCategory } = useModels()
|
||||
const params = route.useParams()
|
||||
const activeSection = (params.section ??
|
||||
MODELS_DEFAULT_SECTION) as ModelsSectionId
|
||||
|
||||
// Deployment create dialog state
|
||||
const [createDeploymentOpen, setCreateDeploymentOpen] = useState(false)
|
||||
|
||||
// keep context state in sync (for components that rely on it)
|
||||
useEffect(() => {
|
||||
if (tabCategory !== activeSection) {
|
||||
setTabCategory(activeSection)
|
||||
}
|
||||
}, [activeSection, setTabCategory, tabCategory])
|
||||
|
||||
const {
|
||||
loading: deploymentLoading,
|
||||
loadingPhase,
|
||||
isIoNetEnabled,
|
||||
connectionLoading,
|
||||
connectionOk,
|
||||
connectionError,
|
||||
testConnection,
|
||||
refresh: refreshDeploymentSettings,
|
||||
} = useModelDeploymentSettings()
|
||||
|
||||
// Ensure settings are fresh when switching to deployments section
|
||||
useEffect(() => {
|
||||
if (activeSection === 'deployments') {
|
||||
refreshDeploymentSettings()
|
||||
}
|
||||
}, [activeSection, refreshDeploymentSettings])
|
||||
|
||||
// Prefetch deployments list while connection check is in progress
|
||||
// This allows the data to be ready as soon as the guard passes
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeSection === 'deployments' &&
|
||||
isIoNetEnabled &&
|
||||
loadingPhase === 'connection'
|
||||
) {
|
||||
const defaultParams = { p: 1, page_size: 10 }
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: deploymentsQueryKeys.list(defaultParams),
|
||||
queryFn: () => listDeployments(defaultParams),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
})
|
||||
}
|
||||
}, [activeSection, isIoNetEnabled, loadingPhase, queryClient])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>
|
||||
{activeSection === 'metadata' ? t('Metadata') : t('Deployments')}
|
||||
</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Description>
|
||||
{activeSection === 'metadata'
|
||||
? t('Manage model metadata and configuration')
|
||||
: t('Manage model deployments')}
|
||||
</SectionPageLayout.Description>
|
||||
<SectionPageLayout.Actions>
|
||||
{activeSection === 'metadata' ? (
|
||||
<ModelsPrimaryButtons />
|
||||
) : (
|
||||
<Button onClick={() => setCreateDeploymentOpen(true)} size='sm'>
|
||||
<Plus className='h-4 w-4' />
|
||||
{t('Create deployment')}
|
||||
</Button>
|
||||
)}
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
{activeSection === 'metadata' ? (
|
||||
<ModelsTable />
|
||||
) : (
|
||||
<DeploymentAccessGuard
|
||||
loading={deploymentLoading}
|
||||
loadingPhase={loadingPhase}
|
||||
isEnabled={isIoNetEnabled}
|
||||
connectionLoading={connectionLoading}
|
||||
connectionOk={connectionOk}
|
||||
connectionError={connectionError}
|
||||
onRetry={testConnection}
|
||||
>
|
||||
<DeploymentsTable />
|
||||
</DeploymentAccessGuard>
|
||||
)}
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
<ModelsDialogs />
|
||||
<CreateDeploymentDrawer
|
||||
open={createDeploymentOpen}
|
||||
onOpenChange={setCreateDeploymentOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Models() {
|
||||
return (
|
||||
<ModelsProvider>
|
||||
<ModelsContent />
|
||||
</ModelsProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function normalizeDeploymentStatus(status: unknown) {
|
||||
return typeof status === 'string' ? status.trim().toLowerCase() : ''
|
||||
}
|
||||
|
||||
export function formatRemainingMinutes(mins: unknown) {
|
||||
const n =
|
||||
typeof mins === 'string'
|
||||
? Number(mins)
|
||||
: typeof mins === 'number'
|
||||
? mins
|
||||
: NaN
|
||||
if (!Number.isFinite(n)) return null
|
||||
|
||||
const total = Math.max(0, Math.round(n))
|
||||
const days = Math.floor(total / 1440)
|
||||
const hours = Math.floor((total % 1440) / 60)
|
||||
const minutes = total % 60
|
||||
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (parts.length === 0 || minutes > 0) parts.push(`${minutes}m`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Query keys
|
||||
export * from './query-keys'
|
||||
|
||||
// Utilities
|
||||
export * from './model-utils'
|
||||
|
||||
// Form schemas and transformers
|
||||
export * from './model-form'
|
||||
|
||||
// Actions
|
||||
export * from './model-actions'
|
||||
export * from './vendor-actions'
|
||||
@@ -0,0 +1,253 @@
|
||||
import { type QueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { updateModelStatus, deleteModel as deleteModelAPI } from '../api'
|
||||
import { modelsQueryKeys } from './query-keys'
|
||||
|
||||
// ============================================================================
|
||||
// Model Status Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Enable a model
|
||||
*/
|
||||
export async function handleEnableModel(
|
||||
id: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await updateModelStatus(id, 1)
|
||||
if (response.success) {
|
||||
toast.success(i18next.t('Model enabled successfully'))
|
||||
queryClient?.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to enable model'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
(error as Error)?.message || i18next.t('Failed to enable model')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a model
|
||||
*/
|
||||
export async function handleDisableModel(
|
||||
id: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await updateModelStatus(id, 0)
|
||||
if (response.success) {
|
||||
toast.success(i18next.t('Model disabled successfully'))
|
||||
queryClient?.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to disable model'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
(error as Error)?.message || i18next.t('Failed to disable model')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle model status
|
||||
*/
|
||||
export async function handleToggleModelStatus(
|
||||
id: number,
|
||||
currentStatus: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
if (currentStatus === 1) {
|
||||
await handleDisableModel(id, queryClient, onSuccess)
|
||||
} else {
|
||||
await handleEnableModel(id, queryClient, onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model Delete Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Delete a single model
|
||||
*/
|
||||
export async function handleDeleteModel(
|
||||
id: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await deleteModelAPI(id)
|
||||
if (response.success) {
|
||||
toast.success(i18next.t('Model deleted successfully'))
|
||||
queryClient?.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to delete model'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
(error as Error)?.message || i18next.t('Failed to delete model')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete models
|
||||
*/
|
||||
export async function handleBatchDeleteModels(
|
||||
ids: number[],
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: (deletedCount: number) => void
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
toast.error(i18next.t('Please select at least one model'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const deletePromises = ids.map((id) => deleteModelAPI(id))
|
||||
const results = await Promise.all(deletePromises)
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
results.forEach((res, index) => {
|
||||
if (res.success) {
|
||||
successCount++
|
||||
} else {
|
||||
failedCount++
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to delete model ${ids[index]}:`, res.message)
|
||||
}
|
||||
})
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
i18next.t('Successfully deleted {{count}} model(s)', {
|
||||
count: successCount,
|
||||
})
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
onSuccess?.(successCount)
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.error(
|
||||
i18next.t('Failed to delete {{count}} model(s)', { count: failedCount })
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || i18next.t('Batch delete failed'))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Batch Status Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Batch enable models
|
||||
*/
|
||||
export async function handleBatchEnableModels(
|
||||
ids: number[],
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
toast.error(i18next.t('Please select at least one model'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const enablePromises = ids.map((id) => updateModelStatus(id, 1))
|
||||
const results = await Promise.all(enablePromises)
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
results.forEach((res) => {
|
||||
if (res.success) {
|
||||
successCount++
|
||||
} else {
|
||||
failedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
i18next.t('Successfully enabled {{count}} model(s)', {
|
||||
count: successCount,
|
||||
})
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.error(
|
||||
i18next.t('Failed to enable {{count}} model(s)', { count: failedCount })
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || i18next.t('Batch enable failed'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch disable models
|
||||
*/
|
||||
export async function handleBatchDisableModels(
|
||||
ids: number[],
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
toast.error(i18next.t('Please select at least one model'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const disablePromises = ids.map((id) => updateModelStatus(id, 0))
|
||||
const results = await Promise.all(disablePromises)
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
results.forEach((res) => {
|
||||
if (res.success) {
|
||||
successCount++
|
||||
} else {
|
||||
failedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
i18next.t('Successfully disabled {{count}} model(s)', {
|
||||
count: successCount,
|
||||
})
|
||||
)
|
||||
queryClient?.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.error(
|
||||
i18next.t('Failed to disable {{count}} model(s)', {
|
||||
count: failedCount,
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || i18next.t('Batch disable failed'))
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { z } from 'zod'
|
||||
import type { Model } from '../types'
|
||||
import { parseModelTags as parseTagsFromUtils } from './model-utils'
|
||||
|
||||
// ============================================================================
|
||||
// Model Form Schema
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Model form validation schema
|
||||
*/
|
||||
export const modelFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
model_name: z.string().min(1, 'Model name is required'),
|
||||
description: z.string().default(''),
|
||||
icon: z.string().default(''),
|
||||
tags: z.array(z.string()).default([]),
|
||||
vendor_id: z.number().optional(),
|
||||
endpoints: z.string().default(''),
|
||||
name_rule: z.number().min(0).max(3).default(0),
|
||||
status: z.boolean().default(true),
|
||||
sync_official: z.boolean().default(true),
|
||||
enable_groups: z.array(z.string()).default([]),
|
||||
quota_types: z.array(z.number()).default([]),
|
||||
})
|
||||
|
||||
export type ModelFormValues = z.infer<typeof modelFormSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Vendor Form Schema
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Vendor form validation schema
|
||||
*/
|
||||
export const vendorFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().min(1, 'Vendor name is required'),
|
||||
description: z.string().default(''),
|
||||
icon: z.string().default(''),
|
||||
status: z.number().default(1),
|
||||
})
|
||||
|
||||
export type VendorFormValues = z.infer<typeof vendorFormSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Form Data Transformation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Transform model to form default values
|
||||
*/
|
||||
export function transformModelToFormDefaults(model: Model): ModelFormValues {
|
||||
return {
|
||||
id: model.id,
|
||||
model_name: model.model_name,
|
||||
description: model.description || '',
|
||||
icon: model.icon || '',
|
||||
tags: parseTagsFromUtils(model.tags),
|
||||
vendor_id: model.vendor_id,
|
||||
endpoints: model.endpoints || '',
|
||||
name_rule: model.name_rule || 0,
|
||||
status: model.status === 1,
|
||||
sync_official: model.sync_official === 1,
|
||||
enable_groups: model.enable_groups || [],
|
||||
quota_types: model.quota_types || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform form data to model create/update payload
|
||||
*/
|
||||
export function transformFormDataToModelPayload(
|
||||
formData: ModelFormValues
|
||||
): Partial<Model> {
|
||||
return {
|
||||
id: formData.id,
|
||||
model_name: formData.model_name,
|
||||
description: formData.description || '',
|
||||
icon: formData.icon || '',
|
||||
tags: formatTagsArray(formData.tags),
|
||||
vendor_id: formData.vendor_id,
|
||||
endpoints: formData.endpoints || '',
|
||||
name_rule: formData.name_rule,
|
||||
status: formData.status ? 1 : 0,
|
||||
sync_official: formData.sync_official ? 1 : 0,
|
||||
enable_groups: formData.enable_groups,
|
||||
quota_types: formData.quota_types,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parsing and Formatting Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format tags array to string
|
||||
*/
|
||||
export function formatTagsArray(tags: string[]): string {
|
||||
return tags.filter(Boolean).join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON string
|
||||
*/
|
||||
export function validateJSON(value: string): boolean {
|
||||
if (!value || value.trim() === '') return true
|
||||
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate endpoints JSON
|
||||
*/
|
||||
export function validateEndpoints(endpoints: string): boolean {
|
||||
return validateJSON(endpoints)
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import { type TFunction } from 'i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { getNameRuleConfig, getQuotaTypeConfig } from '../constants'
|
||||
import type { NameRule, Model } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Time Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format timestamp to standard date string (YYYY-MM-DD HH:mm:ss)
|
||||
*/
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
if (!timestamp || timestamp === 0) return '-'
|
||||
return formatTimestampToDate(timestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
if (!timestamp || timestamp === 0) return 'Never'
|
||||
|
||||
const now = Date.now()
|
||||
const time = timestamp * 1000
|
||||
const diff = now - time
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
|
||||
return `${seconds} second${seconds !== 1 ? 's' : ''} ago`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tags Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse tags string to array
|
||||
*/
|
||||
export function parseModelTags(tags: string | undefined): string[] {
|
||||
if (!tags) return []
|
||||
return tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tags array to string
|
||||
*/
|
||||
export function formatTagsString(tags: string[]): string {
|
||||
return tags.join(',')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Endpoints Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse endpoints JSON string
|
||||
*/
|
||||
export function parseEndpoints(
|
||||
endpoints: string | undefined
|
||||
): Record<string, unknown> | unknown[] | null {
|
||||
if (!endpoints || endpoints.trim() === '') return null
|
||||
|
||||
try {
|
||||
return JSON.parse(endpoints)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format endpoints to display
|
||||
*/
|
||||
export function formatEndpointsDisplay(
|
||||
endpoints: string | undefined
|
||||
): string[] {
|
||||
const parsed = parseEndpoints(endpoints)
|
||||
if (!parsed) return []
|
||||
|
||||
if (typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return Object.keys(parsed)
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map(String)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Name Rule Utils
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get name rule label
|
||||
*/
|
||||
export function getNameRuleLabelByRule(rule: NameRule, t: TFunction): string {
|
||||
const config = getNameRuleConfig(t)
|
||||
return config[rule]?.label || '-'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name rule config by rule
|
||||
*/
|
||||
export function getNameRuleConfigByRule(rule: NameRule, t: TFunction) {
|
||||
const config = getNameRuleConfig(t)
|
||||
return config[rule] || config[0]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quota Type Utils
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format quota types array
|
||||
*/
|
||||
export function formatQuotaTypes(
|
||||
quotaTypes: number[] | undefined,
|
||||
t: TFunction
|
||||
): string {
|
||||
if (!quotaTypes || quotaTypes.length === 0) return '-'
|
||||
const config = getQuotaTypeConfig(t)
|
||||
return quotaTypes.map((qt) => config[qt]?.label || String(qt)).join(', ')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate model name
|
||||
*/
|
||||
export function validateModelName(name: string): boolean {
|
||||
return name.trim().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate endpoints JSON
|
||||
*/
|
||||
export function validateEndpointsJSON(endpoints: string): boolean {
|
||||
if (!endpoints || endpoints.trim() === '') return true
|
||||
|
||||
try {
|
||||
JSON.parse(endpoints)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model Status Utils
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if model is enabled
|
||||
*/
|
||||
export function isModelEnabled(model: Model): boolean {
|
||||
return model.status === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model syncs with official
|
||||
*/
|
||||
export function isModelSyncOfficial(model: Model): boolean {
|
||||
return model.sync_official === 1
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { GetModelsParams, SearchModelsParams } from '../types'
|
||||
|
||||
/**
|
||||
* React Query cache keys for models
|
||||
*/
|
||||
export const modelsQueryKeys = {
|
||||
all: ['models'] as const,
|
||||
lists: () => [...modelsQueryKeys.all, 'list'] as const,
|
||||
list: (filters: GetModelsParams | SearchModelsParams) =>
|
||||
[...modelsQueryKeys.lists(), filters] as const,
|
||||
detail: (id: number) => [...modelsQueryKeys.all, 'detail', id] as const,
|
||||
missing: () => [...modelsQueryKeys.all, 'missing'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query cache keys for vendors
|
||||
*/
|
||||
export const vendorsQueryKeys = {
|
||||
all: ['vendors'] as const,
|
||||
lists: () => [...vendorsQueryKeys.all, 'list'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...vendorsQueryKeys.lists(), filters] as const,
|
||||
detail: (id: number) => [...vendorsQueryKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query cache keys for prefill groups
|
||||
*/
|
||||
export const prefillGroupsQueryKeys = {
|
||||
all: ['prefill-groups'] as const,
|
||||
lists: () => [...prefillGroupsQueryKeys.all, 'list'] as const,
|
||||
list: (type?: string) => [...prefillGroupsQueryKeys.lists(), type] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query cache keys for deployments
|
||||
*/
|
||||
export const deploymentsQueryKeys = {
|
||||
all: ['deployments'] as const,
|
||||
lists: () => [...deploymentsQueryKeys.all, 'list'] as const,
|
||||
list: (filters: {
|
||||
keyword?: string
|
||||
status?: string
|
||||
p?: number
|
||||
page_size?: number
|
||||
}) => [...deploymentsQueryKeys.lists(), filters] as const,
|
||||
detail: (id: string | number) =>
|
||||
[...deploymentsQueryKeys.all, 'detail', id] as const,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type QueryClient } from '@tanstack/react-query'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { deleteVendor as deleteVendorAPI } from '../api'
|
||||
import { vendorsQueryKeys, modelsQueryKeys } from './query-keys'
|
||||
|
||||
// ============================================================================
|
||||
// Vendor Actions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Delete a vendor
|
||||
*/
|
||||
export async function handleDeleteVendor(
|
||||
id: number,
|
||||
queryClient?: QueryClient,
|
||||
onSuccess?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await deleteVendorAPI(id)
|
||||
if (response.success) {
|
||||
toast.success(i18next.t('Vendor deleted successfully'))
|
||||
queryClient?.invalidateQueries({ queryKey: vendorsQueryKeys.lists() })
|
||||
queryClient?.invalidateQueries({ queryKey: modelsQueryKeys.lists() })
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to delete vendor'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
(error as Error)?.message || i18next.t('Failed to delete vendor')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createSectionRegistry } from '@/features/system-settings/utils/section-registry'
|
||||
|
||||
/**
|
||||
* Models page section definitions
|
||||
*/
|
||||
const MODELS_SECTIONS = [
|
||||
{
|
||||
id: 'metadata',
|
||||
titleKey: 'Metadata',
|
||||
descriptionKey: 'Manage model metadata and configuration',
|
||||
build: () => null, // Content is rendered directly in the page component
|
||||
},
|
||||
{
|
||||
id: 'deployments',
|
||||
titleKey: 'Deployments',
|
||||
descriptionKey: 'Manage model deployments',
|
||||
build: () => null, // Content is rendered directly in the page component
|
||||
},
|
||||
] as const
|
||||
|
||||
export type ModelsSectionId = (typeof MODELS_SECTIONS)[number]['id']
|
||||
|
||||
const modelsRegistry = createSectionRegistry<
|
||||
ModelsSectionId,
|
||||
Record<string, never>,
|
||||
[]
|
||||
>({
|
||||
sections: MODELS_SECTIONS,
|
||||
defaultSection: 'metadata',
|
||||
basePath: '/models',
|
||||
urlStyle: 'path',
|
||||
})
|
||||
|
||||
export const MODELS_SECTION_IDS = modelsRegistry.sectionIds
|
||||
export const MODELS_DEFAULT_SECTION = modelsRegistry.defaultSection
|
||||
export const getModelsSectionNavItems = modelsRegistry.getSectionNavItems
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// ============================================================================
|
||||
// Model Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Bound channel information
|
||||
*/
|
||||
export interface BoundChannel {
|
||||
name: string
|
||||
type: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Model entity from API
|
||||
*/
|
||||
export interface Model {
|
||||
id: number
|
||||
model_name: string
|
||||
description?: string
|
||||
icon?: string
|
||||
tags?: string
|
||||
vendor_id?: number
|
||||
endpoints?: string
|
||||
status: number
|
||||
sync_official: number
|
||||
created_time: number
|
||||
updated_time: number
|
||||
name_rule: number
|
||||
// Runtime fields
|
||||
bound_channels?: BoundChannel[]
|
||||
enable_groups?: string[]
|
||||
quota_types?: number[]
|
||||
matched_models?: string[]
|
||||
matched_count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Vendor entity from API
|
||||
*/
|
||||
export interface Vendor {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
icon?: string
|
||||
status: number
|
||||
created_time: number
|
||||
updated_time: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefill group entity
|
||||
*/
|
||||
export interface PrefillGroup {
|
||||
id: number
|
||||
name: string
|
||||
type: 'model' | 'tag' | 'endpoint'
|
||||
items: string | string[]
|
||||
description?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get models list parameters
|
||||
*/
|
||||
export interface GetModelsParams {
|
||||
p?: number
|
||||
page_size?: number
|
||||
vendor?: string // vendor ID to filter by
|
||||
status?: string // filter by status
|
||||
sync_official?: string // filter by sync_official status
|
||||
}
|
||||
|
||||
/**
|
||||
* Search models parameters
|
||||
*/
|
||||
export interface SearchModelsParams {
|
||||
keyword?: string
|
||||
vendor?: string // vendor ID to filter by
|
||||
status?: string // filter by status
|
||||
sync_official?: string // filter by sync_official status
|
||||
p?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models response
|
||||
*/
|
||||
export interface GetModelsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
items: Model[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
vendor_counts?: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model detail response
|
||||
*/
|
||||
export interface GetModelResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vendors response
|
||||
*/
|
||||
export interface GetVendorsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
items: Vendor[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vendor response
|
||||
*/
|
||||
export interface GetVendorResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Vendor
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync diff data
|
||||
*/
|
||||
export interface SyncDiffData {
|
||||
missing?: Array<{
|
||||
model_name: string
|
||||
vendor?: string
|
||||
[key: string]: unknown
|
||||
}>
|
||||
conflicts?: Array<{
|
||||
model_name: string
|
||||
local?: Partial<Model>
|
||||
upstream?: Partial<Model>
|
||||
fields?: Array<{
|
||||
field: string
|
||||
local?: unknown
|
||||
upstream?: unknown
|
||||
}>
|
||||
[key: string]: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
export interface SyncOverwritePayload {
|
||||
model_name: string
|
||||
fields: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync upstream response
|
||||
*/
|
||||
export interface SyncUpstreamResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
created_models?: number
|
||||
updated_models?: number
|
||||
created_vendors?: number
|
||||
skipped_models?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview upstream diff response
|
||||
*/
|
||||
export interface PreviewUpstreamDiffResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: SyncDiffData
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing models response
|
||||
*/
|
||||
export interface MissingModelsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefill groups response
|
||||
*/
|
||||
export interface PrefillGroupsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: PrefillGroup[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Data Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Model form schema
|
||||
*/
|
||||
export const modelFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
model_name: z.string().min(1, 'Model name is required'),
|
||||
description: z.string().default(''),
|
||||
icon: z.string().default(''),
|
||||
tags: z.array(z.string()).default([]),
|
||||
vendor_id: z.number().optional(),
|
||||
endpoints: z.string().default(''),
|
||||
name_rule: z.number().min(0).max(3).default(0),
|
||||
status: z.boolean().default(true),
|
||||
sync_official: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export type ModelFormValues = z.infer<typeof modelFormSchema>
|
||||
|
||||
/**
|
||||
* Vendor form schema
|
||||
*/
|
||||
export const vendorFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().min(1, 'Vendor name is required'),
|
||||
description: z.string().default(''),
|
||||
icon: z.string().default(''),
|
||||
status: z.number().default(1),
|
||||
})
|
||||
|
||||
export type VendorFormValues = z.infer<typeof vendorFormSchema>
|
||||
|
||||
/**
|
||||
* Prefill group form schema
|
||||
*/
|
||||
export const prefillGroupFormSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().min(1, 'Group name is required'),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['model', 'tag', 'endpoint']),
|
||||
items: z.union([z.string(), z.array(z.string())]),
|
||||
})
|
||||
|
||||
export type PrefillGroupFormValues = z.infer<typeof prefillGroupFormSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Utility Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Name rule type
|
||||
*/
|
||||
export type NameRule = 0 | 1 | 2 | 3 // exact, prefix, contains, suffix
|
||||
|
||||
/**
|
||||
* Model status type
|
||||
*/
|
||||
export type ModelStatus = 0 | 1 // disabled, enabled
|
||||
|
||||
/**
|
||||
* Quota type
|
||||
*/
|
||||
export type QuotaType = 0 | 1 // usage-based, per-call
|
||||
|
||||
/**
|
||||
* Sync locale
|
||||
*/
|
||||
export type SyncLocale = 'zh' | 'en' | 'ja'
|
||||
|
||||
/**
|
||||
* Sync upstream source
|
||||
*/
|
||||
export type SyncSource = 'official' | 'config'
|
||||
|
||||
// ============================================================================
|
||||
// Model Deployments Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Model tab type
|
||||
*/
|
||||
export type ModelTabCategory = 'metadata' | 'deployments'
|
||||
|
||||
/**
|
||||
* Deployment entity from API
|
||||
*/
|
||||
export interface Deployment {
|
||||
id: string | number
|
||||
container_name?: string
|
||||
deployment_name?: string
|
||||
name?: string
|
||||
status?: string
|
||||
provider?: string
|
||||
/**
|
||||
* Human readable string returned by backend, e.g. "2 hour 15 minutes"
|
||||
* or "completed".
|
||||
*/
|
||||
time_remaining?: string
|
||||
/**
|
||||
* Remaining minutes (numeric) returned by backend.
|
||||
*/
|
||||
compute_minutes_remaining?: number
|
||||
/**
|
||||
* Served minutes (numeric) returned by backend.
|
||||
*/
|
||||
compute_minutes_served?: number
|
||||
/**
|
||||
* Completed percent (0-100) returned by backend.
|
||||
*/
|
||||
completed_percent?: number
|
||||
hardware_info?: string | Record<string, unknown>
|
||||
hardware_name?: string
|
||||
brand_name?: string
|
||||
hardware_quantity?: number
|
||||
created_at?: string | number
|
||||
updated_at?: string | number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Deployment settings response
|
||||
*/
|
||||
export interface DeploymentSettingsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List deployments response
|
||||
*/
|
||||
export interface ListDeploymentsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
items?: Deployment[]
|
||||
total?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
status_counts?: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deployment logs response
|
||||
*/
|
||||
export interface DeploymentLogsResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
logs?: Array<{
|
||||
timestamp?: string
|
||||
level?: string
|
||||
message?: string
|
||||
source?: string
|
||||
}>
|
||||
cursor?: string
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user