🚀 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 of 9f8a4ec05)
- 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 of bee339d27)
- 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 fix 4e93148d9
- 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:
同語
2026-04-28 14:19:19 +08:00
committed by GitHub
parent 9f8a4ec050
commit a42b397607
1290 changed files with 158786 additions and 53 deletions
+93
View File
@@ -0,0 +1,93 @@
import { api } from '@/lib/api'
import { buildQueryParams } from './lib/utils'
import type {
GetLogsParams,
GetLogsResponse,
GetLogStatsParams,
GetLogStatsResponse,
GetMidjourneyLogsParams,
GetTaskLogsParams,
UserInfo,
} from './types'
// ============================================================================
// Generic API Helpers
// ============================================================================
function buildApiPath(endpoint: string, isAdmin: boolean): string {
return isAdmin ? endpoint : `${endpoint}/self`
}
async function fetchLogs<T>(
endpoint: string,
params: T,
isAdmin: boolean
): Promise<GetLogsResponse> {
const paramRecord = params as unknown as Record<string, unknown>
const queryParams = buildQueryParams({
p: paramRecord.p || 1,
page_size: paramRecord.page_size || 20,
...params,
})
const path = buildApiPath(endpoint, isAdmin)
const res = await api.get(`${path}?${queryParams}`)
return res.data
}
async function fetchLogStats<T>(
endpoint: string,
params: T,
isAdmin: boolean
): Promise<GetLogStatsResponse> {
const queryParams = buildQueryParams(
params as unknown as Record<string, unknown>
)
const path = buildApiPath(endpoint, isAdmin)
const res = await api.get(`${path}/stat?${queryParams}`)
return res.data
}
// ============================================================================
// Common Log APIs
// ============================================================================
export const getAllLogs = (params: GetLogsParams = {}) =>
fetchLogs('/api/log', params, true)
export const getUserLogs = (
params: Omit<GetLogsParams, 'username' | 'channel'> = {}
) => fetchLogs('/api/log', params, false)
export const getLogStats = (params: GetLogStatsParams = {}) =>
fetchLogStats('/api/log', params, true)
export const getUserLogStats = (
params: Omit<GetLogStatsParams, 'username' | 'channel'> = {}
) => fetchLogStats('/api/log', params, false)
export async function getUserInfo(
userId: number
): Promise<{ success: boolean; message?: string; data?: UserInfo }> {
const res = await api.get(`/api/user/${userId}`)
return res.data
}
// ============================================================================
// Midjourney (Drawing) Logs API
// ============================================================================
export const getAllMidjourneyLogs = (params: GetMidjourneyLogsParams) =>
fetchLogs('/api/mj', params, true)
export const getUserMidjourneyLogs = (params: GetMidjourneyLogsParams) =>
fetchLogs('/api/mj', params, false)
// ============================================================================
// Task Logs API
// ============================================================================
export const getAllTaskLogs = (params: GetTaskLogsParams) =>
fetchLogs('/api/task', params, true)
export const getUserTaskLogs = (params: GetTaskLogsParams) =>
fetchLogs('/api/task', params, false)
@@ -0,0 +1,222 @@
/* eslint-disable react-refresh/only-export-components */
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { Clock, Zap } from 'lucide-react'
import { formatTimestampToDate, formatTokens } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { formatDuration } from '../../lib/format'
import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
/**
* Cache tooltip component for token display
*/
export function CacheTooltip({
tokens,
label,
color,
}: {
tokens: number
label: string
color: string
}) {
if (tokens <= 0) return null
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Zap className={`size-3 flex-shrink-0 ${color}`} />
</TooltipTrigger>
<TooltipContent side='top'>
<p className='text-xs'>
{label}: {formatTokens(tokens)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
// ============================================================================
// Column Definition Factories
// ============================================================================
/**
* Create a timestamp column
*/
export function createTimestampColumn<T>(config: {
accessorKey: string
title: string
unit?: 'seconds' | 'milliseconds'
}): ColumnDef<T> {
const { accessorKey, title, unit = 'milliseconds' } = config
return {
accessorKey,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={title} />
),
cell: ({ row }) => {
const timestamp = row.getValue(accessorKey) as number
return (
<div className='min-w-[140px] font-mono text-sm'>
{formatTimestampToDate(timestamp, unit)}
</div>
)
},
meta: { label: title },
}
}
/**
* Create a duration column
*/
export function createDurationColumn<T>(config: {
submitTimeKey: string
finishTimeKey: string
unit?: 'seconds' | 'milliseconds'
headerLabel: string
}): ColumnDef<T> {
const {
submitTimeKey,
finishTimeKey,
unit = 'milliseconds',
headerLabel,
} = config
return {
id: 'duration',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={headerLabel} />
),
cell: ({ row }) => {
const log = row.original as Record<string, unknown>
const duration = formatDuration(
log[submitTimeKey] as number | undefined,
log[finishTimeKey] as number | undefined,
unit
)
if (!duration) {
return <div className='text-muted-foreground text-sm'>-</div>
}
return (
<StatusBadge
label={`${duration.durationSec.toFixed(1)}s`}
variant={duration.variant}
icon={Clock}
size='sm'
copyable={false}
/>
)
},
meta: { label: headerLabel },
}
}
/**
* Create a channel column (admin only)
*/
export function createChannelColumn<T>(config: {
accessorKey?: string
headerLabel: string
}): ColumnDef<T> {
const { accessorKey = 'channel_id', headerLabel } = config
return {
accessorKey,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={headerLabel} />
),
cell: ({ row }) => {
const channelId = row.getValue(accessorKey) as number
return (
<StatusBadge
label={`${channelId}`}
autoColor={`channel-${channelId}`}
size='sm'
/>
)
},
meta: { label: headerLabel },
}
}
/**
* Create a fail reason column
*/
export function createFailReasonColumn<T>(config: {
accessorKey?: string
headerLabel: string
cellTitle: string
}): ColumnDef<T> {
const { accessorKey = 'fail_reason', headerLabel, cellTitle } = config
return {
accessorKey,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={headerLabel} />
),
cell: function FailReasonCell({ row }) {
const failReason = row.getValue(accessorKey) as string
const [dialogOpen, setDialogOpen] = useState(false)
if (!failReason) {
return <span className='text-muted-foreground text-sm'>-</span>
}
return (
<>
<Button
variant='ghost'
className='h-auto max-w-[200px] justify-start overflow-hidden p-0 text-left text-sm font-normal text-red-600 hover:underline'
onClick={() => setDialogOpen(true)}
title={cellTitle}
>
<span className='truncate'>{failReason}</span>
</Button>
<FailReasonDialog
failReason={failReason}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
},
meta: { label: headerLabel },
}
}
/**
* Create a progress column
*/
export function createProgressColumn<T>(config: {
accessorKey?: string
headerLabel: string
}): ColumnDef<T> {
const { accessorKey = 'progress', headerLabel } = config
return {
accessorKey,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={headerLabel} />
),
cell: ({ row }) => {
const progress = row.getValue(accessorKey) as string
if (!progress) {
return <span className='text-muted-foreground text-sm'>-</span>
}
return <div className='font-mono text-sm'>{progress}</div>
},
meta: { label: headerLabel },
}
}
@@ -0,0 +1,706 @@
import { useState } from 'react'
import { type ColumnDef } from '@tanstack/react-table'
import { Route, CircleAlert, Sparkles } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import {
formatUseTime,
formatLogQuota,
formatTimestampToDate,
} from '@/lib/format'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import {
StatusBadge,
type StatusBadgeProps,
dotColorMap,
textColorMap,
} from '@/components/status-badge'
import type { UsageLog } from '../../data/schema'
import {
getTimeColor,
formatModelName,
getTieredBillingSummary,
parseLogOther,
isViolationFeeLog,
} from '../../lib/format'
import {
isDisplayableLogType,
isTimingLogType,
getLogTypeConfig,
isPerCallBilling,
} from '../../lib/utils'
import type { LogOtherData } from '../../types'
import { DetailsDialog } from '../dialogs/details-dialog'
import { useUsageLogsContext } from '../usage-logs-provider'
import { CacheTooltip } from './column-helpers'
interface DetailSegment {
text: string
muted?: boolean
danger?: boolean
}
function formatRatioCompact(ratio: number | undefined): string {
if (ratio == null || !Number.isFinite(ratio)) return '-'
return ratio % 1 === 0 ? String(ratio) : ratio.toFixed(4)
}
function buildDetailSegments(
log: UsageLog,
other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string
): DetailSegment[] {
if (log.type === 6) {
return [{ text: t('Async task refund') }]
}
if (log.type !== 2) return []
const isViolation = isViolationFeeLog(other)
if (isViolation) {
const segments: DetailSegment[] = []
segments.push({ text: t('Violation Fee'), danger: true })
if (other?.violation_fee_code) {
segments.push({
text: other.violation_fee_code,
muted: true,
})
}
segments.push({
text: `${t('Fee')}: ${formatLogQuota(other?.fee_quota ?? log.quota)}`,
muted: true,
})
return segments
}
if (!other) return []
const segments: DetailSegment[] = []
const userGroupRatio = other.user_group_ratio
const groupRatio = other.group_ratio
const isUserGroup =
userGroupRatio != null &&
Number.isFinite(userGroupRatio) &&
userGroupRatio !== -1
const effectiveRatio = isUserGroup ? userGroupRatio : groupRatio
const ratioLabel = isUserGroup ? t('User Exclusive Ratio') : t('Group Ratio')
if (effectiveRatio != null && Number.isFinite(effectiveRatio)) {
segments.push({
text: `${ratioLabel} ${formatRatioCompact(effectiveRatio)}x`,
})
}
const priceOpts = { digitsLarge: 4, digitsSmall: 6, abbreviate: false }
const tieredSummary = getTieredBillingSummary(other)
if (tieredSummary) {
if (tieredSummary.tier.label) {
segments.push({
text: `${t('Tier')} ${tieredSummary.tier.label}`,
muted: true,
})
}
for (const entry of tieredSummary.priceEntries) {
segments.push({
text: `${t(entry.shortLabel)} ${formatBillingCurrencyFromUSD(entry.price, priceOpts)}/M`,
muted: true,
})
}
} else {
const isPerCall = isPerCallBilling(other.model_price)
if (isPerCall) {
segments.push({
text: `${t('Model Price')} ${formatBillingCurrencyFromUSD(other.model_price!, priceOpts)}`,
muted: true,
})
} else if (other.model_ratio != null) {
const inputPriceUSD = other.model_ratio * 2.0
segments.push({
text: `${t('Input')} ${formatBillingCurrencyFromUSD(inputPriceUSD, priceOpts)}/M`,
muted: true,
})
}
}
if (other.is_system_prompt_overwritten) {
segments.push({
text: t('System Prompt Override'),
danger: true,
})
}
return segments
}
export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const { t } = useTranslation()
const columns: ColumnDef<UsageLog>[] = [
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Time')} />
),
cell: ({ row }) => {
const log = row.original
const timestamp = row.getValue('created_at') as number
const config = getLogTypeConfig(log.type)
return (
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs tabular-nums'>
{formatTimestampToDate(timestamp)}
</span>
<StatusBadge
label={t(config.label)}
variant={config.color as StatusBadgeProps['variant']}
size='sm'
copyable={false}
/>
{log.request_id && (
<StatusBadge
label={
log.request_id.length > 18
? `${log.request_id.slice(0, 18)}`
: log.request_id
}
variant='neutral'
size='sm'
copyText={log.request_id}
className='max-w-[140px] truncate font-mono'
/>
)}
</div>
)
},
filterFn: (row, _id, value) => {
if (!value || value.length === 0) return true
return value.includes(String(row.original.type))
},
enableHiding: false,
meta: { label: t('Time') },
},
]
if (isAdmin) {
columns.push({
id: 'source',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Source')} />
),
cell: function SourceCell({ row }) {
const {
setAffinityTarget,
setAffinityDialogOpen,
setSelectedUserId,
setUserInfoDialogOpen,
} = useUsageLogsContext()
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other)
const affinity = other?.admin_info?.channel_affinity
const useChannel = other?.admin_info?.use_channel
const channelChain =
useChannel && useChannel.length > 0
? useChannel.join(' → ')
: undefined
const channelDisplay = log.channel_name
? `${log.channel_name} #${log.channel}`
: `#${log.channel}`
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-1'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='relative'>
<StatusBadge
label={channelDisplay}
autoColor={log.channel_name || String(log.channel)}
copyText={String(log.channel)}
size='sm'
/>
{affinity && (
<button
type='button'
className='absolute -top-1 -right-1 leading-none text-amber-500'
onClick={(e) => {
e.stopPropagation()
setAffinityTarget({
rule_name: affinity.rule_name || '',
using_group:
affinity.using_group ||
affinity.selected_group ||
'',
key_hint: affinity.key_hint || '',
key_fp: affinity.key_fp || '',
})
setAffinityDialogOpen(true)
}}
>
<Sparkles className='size-3 fill-current' />
</button>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1'>
<p>{channelDisplay}</p>
{channelChain && (
<p className='text-muted-foreground text-xs'>
{t('Chain')}: {channelChain}
</p>
)}
{affinity && (
<div className='border-t pt-1 text-xs'>
<p className='font-medium'>{t('Channel Affinity')}</p>
<p>
{t('Rule')}: {affinity.rule_name || '-'}
</p>
<p>
{t('Group')}:{' '}
{affinity.using_group ||
affinity.selected_group ||
'-'}
</p>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{log.username && (
<button
type='button'
className='flex items-center gap-1 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<span className='bg-primary/10 text-primary flex size-4 items-center justify-center rounded-full text-[10px] font-bold'>
{log.username.charAt(0).toUpperCase()}
</span>
<span className='text-muted-foreground truncate text-xs hover:underline'>
{log.username}
</span>
</button>
)}
</div>
)
},
meta: { label: t('Source'), mobileHidden: true },
})
}
columns.push(
{
accessorKey: 'model_name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Model')} />
),
cell: function ModelCell({ row }) {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const modelInfo = formatModelName(log)
const tokenName = log.token_name
const other = parseLogOther(log.other)
let group = log.group
if (!group) group = other?.group || ''
const modelBadge = modelInfo.isMapped ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-auto p-0 hover:bg-transparent'
>
<span className='flex items-center gap-1'>
<StatusBadge
label={modelInfo.name}
autoColor={modelInfo.name}
copyText={modelInfo.name}
size='sm'
className='truncate font-mono'
/>
<Route className='text-muted-foreground size-3 shrink-0' />
</span>
</Button>
</PopoverTrigger>
<PopoverContent className='w-72'>
<div className='space-y-2'>
<div className='flex items-start justify-between gap-3'>
<span className='text-muted-foreground text-xs'>
{t('Request Model:')}
</span>
<span className='truncate font-mono text-xs font-medium'>
{modelInfo.name}
</span>
</div>
<div className='flex items-start justify-between gap-3'>
<span className='text-muted-foreground text-xs'>
{t('Actual Model:')}
</span>
<span className='truncate font-mono text-xs font-medium'>
{modelInfo.actualModel}
</span>
</div>
</div>
</PopoverContent>
</Popover>
) : (
<StatusBadge
label={modelInfo.name}
autoColor={modelInfo.name}
copyText={modelInfo.name}
size='sm'
className='truncate font-mono'
/>
)
const metaParts: string[] = []
if (tokenName) metaParts.push(tokenName)
if (group) metaParts.push(group)
return (
<div className='flex max-w-[220px] flex-col gap-0.5'>
{modelBadge}
{metaParts.length > 0 && (
<span className='text-muted-foreground/60 truncate text-[11px]'>
{metaParts.join(' · ')}
</span>
)}
</div>
)
},
meta: { label: t('Model'), mobileTitle: true },
},
{
accessorKey: 'use_time',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Timing')} />
),
cell: ({ row }) => {
const log = row.original
if (!isTimingLogType(log.type)) return null
const useTime = row.getValue('use_time') as number
const other = parseLogOther(log.other)
const frt = other?.frt
const timeVariant = getTimeColor(useTime)
const frtVariant = frt ? getTimeColor(frt / 1000) : null
return (
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-1 text-xs'>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
dotColorMap[timeVariant]
)}
aria-hidden='true'
/>
<span
className={cn(
'font-mono font-medium',
textColorMap[timeVariant]
)}
>
{formatUseTime(useTime)}
</span>
{log.is_stream && frt != null && frt > 0 && (
<>
<span className='text-muted-foreground/30'>·</span>
<span
className={cn(
'font-mono font-medium',
textColorMap[frtVariant!]
)}
>
{formatUseTime(frt / 1000)}
</span>
</>
)}
</div>
<div className='flex items-center gap-1 text-[11px]'>
<span className='text-muted-foreground/60'>
{log.is_stream ? t('Stream') : t('Non-stream')}
</span>
{log.is_stream &&
other?.stream_status &&
other.stream_status.status !== 'ok' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<CircleAlert className='size-3 text-red-500' />
</TooltipTrigger>
<TooltipContent>
<div className='space-y-0.5 text-xs'>
<p>
{t('Stream Status')}: {t('Error')}
</p>
<p>{other.stream_status.end_reason || 'unknown'}</p>
{(other.stream_status.error_count ?? 0) > 0 && (
<p>
{t('Soft Errors')}:{' '}
{other.stream_status.error_count}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
)
},
meta: { label: t('Timing'), mobileHidden: true },
},
{
accessorKey: 'prompt_tokens',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Input')} />
),
cell: ({ row }) => {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other)
if (isPerCallBilling(other?.model_price)) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const promptTokens = log.prompt_tokens || 0
if (promptTokens === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const cacheReadTokens = other?.cache_tokens || 0
return (
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs font-medium'>
{promptTokens.toLocaleString()}
</span>
{cacheReadTokens > 0 && (
<span className='flex items-center gap-1 text-[11px]'>
<CacheTooltip
tokens={cacheReadTokens}
label={t('Cache Read')}
color='fill-amber-500 text-amber-500'
/>
<span className='text-muted-foreground/60'>
{t('Cache Read')} {cacheReadTokens.toLocaleString()}
</span>
</span>
)}
</div>
)
},
meta: { label: t('Input'), mobileHidden: true },
},
{
accessorKey: 'completion_tokens',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Output')} />
),
cell: ({ row }) => {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other)
if (isPerCallBilling(other?.model_price)) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const completionTokens = log.completion_tokens || 0
if (completionTokens === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const cacheWrite5m = other?.cache_creation_tokens_5m || 0
const cacheWrite1h = other?.cache_creation_tokens_1h || 0
const hasSplitCache = cacheWrite5m > 0 || cacheWrite1h > 0
const cacheWriteTokens = hasSplitCache
? cacheWrite5m + cacheWrite1h
: other?.cache_creation_tokens || 0
return (
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs font-medium'>
{completionTokens.toLocaleString()}
</span>
{cacheWriteTokens > 0 && (
<span className='flex items-center gap-1 text-[11px]'>
<CacheTooltip
tokens={cacheWriteTokens}
label={t('Cache Write')}
color='fill-blue-500 text-blue-500'
/>
<span className='text-muted-foreground/60'>
{hasSplitCache
? `${t('Cache Write')} ${cacheWrite5m.toLocaleString()}/${cacheWrite1h.toLocaleString()}`
: `${t('Cache Write')} ${cacheWriteTokens.toLocaleString()}`}
</span>
</span>
)}
</div>
)
},
meta: { label: t('Output'), mobileHidden: true },
},
{
accessorKey: 'quota',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Cost')} />
),
cell: ({ row }) => {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const quota = row.getValue('quota') as number
const other = parseLogOther(log.other)
const isSubscription = other?.billing_source === 'subscription'
if (isSubscription) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<StatusBadge
label={t('Subscription')}
variant='green'
size='sm'
copyable={false}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<span>
{t('Deducted by subscription')}: {formatLogQuota(quota)}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return (
<div className='flex flex-col'>
<span className='font-mono text-xs font-medium tabular-nums'>
{formatLogQuota(quota)}
</span>
{(() => {
const userGroupRatio = other?.user_group_ratio
if (
userGroupRatio != null &&
userGroupRatio !== -1 &&
Number.isFinite(userGroupRatio)
) {
return (
<span className='text-muted-foreground/60 text-[11px]'>
{t('User Group: {{ratio}}x', { ratio: userGroupRatio })}
</span>
)
}
const groupRatio = other?.group_ratio
if (groupRatio != null && groupRatio !== 1) {
return (
<span className='text-muted-foreground/60 text-[11px]'>
{t('Group: {{ratio}}x', { ratio: groupRatio })}
</span>
)
}
return null
})()}
</div>
)
},
meta: { label: t('Cost') },
},
{
accessorKey: 'content',
header: t('Details'),
cell: function DetailsCell({ row }) {
const [dialogOpen, setDialogOpen] = useState(false)
const log = row.original
const other = parseLogOther(log.other)
const segments = buildDetailSegments(log, other, t)
return (
<>
<Button
variant='ghost'
className='h-auto max-w-[220px] justify-start p-0 text-left text-xs font-normal hover:underline'
onClick={() => setDialogOpen(true)}
title={t('Click to view full details')}
>
{segments.length > 0 ? (
<div className='flex flex-col gap-px'>
{segments.map((seg, i) => (
<span
key={i}
className={cn(
'leading-snug',
seg.muted
? 'text-muted-foreground/60'
: seg.danger
? 'text-red-600 dark:text-red-400'
: 'text-foreground'
)}
>
{seg.text}
</span>
))}
</div>
) : log.content ? (
<span className='line-clamp-2'>{log.content}</span>
) : (
<span className='text-muted-foreground'>-</span>
)}
</Button>
<DetailsDialog
log={log}
isAdmin={isAdmin}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
},
meta: { label: t('Details'), mobileHidden: true },
size: 200,
maxSize: 220,
}
)
return columns
}
@@ -0,0 +1,208 @@
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { StatusBadge } from '@/components/status-badge'
import {
mjTaskTypeMapper,
mjStatusMapper,
mjSubmitResultMapper,
} from '../../lib/mappers'
import type { MidjourneyLog } from '../../types'
import { ImageDialog } from '../dialogs/image-dialog'
import { PromptDialog } from '../dialogs/prompt-dialog'
import {
createTimestampColumn,
createDurationColumn,
createChannelColumn,
createProgressColumn,
createFailReasonColumn,
} from './column-helpers'
export function useDrawingLogsColumns(
isAdmin: boolean
): ColumnDef<MidjourneyLog>[] {
const { t } = useTranslation()
const columns: ColumnDef<MidjourneyLog>[] = [
createTimestampColumn<MidjourneyLog>({
accessorKey: 'submit_time',
title: t('Submit Time'),
}),
createDurationColumn<MidjourneyLog>({
submitTimeKey: 'submit_time',
finishTimeKey: 'finish_time',
headerLabel: t('Duration'),
}),
]
// Channel (admin only)
if (isAdmin) {
columns.push(
createChannelColumn<MidjourneyLog>({ headerLabel: t('Channel') })
)
}
columns.push(
// Type (using 'action' field from backend)
{
accessorKey: 'action',
header: t('Type'),
cell: ({ row }) => {
const action = row.getValue('action') as string
return (
<StatusBadge
label={t(mjTaskTypeMapper.getLabel(action))}
variant={mjTaskTypeMapper.getVariant(action)}
size='sm'
copyable={false}
/>
)
},
meta: { label: t('Type') },
},
// Task ID
{
accessorKey: 'mj_id',
header: t('Task ID'),
cell: ({ row }) => {
const mjId = row.getValue('mj_id') as string
if (!mjId) {
return <span className='text-muted-foreground text-sm'>-</span>
}
return (
<StatusBadge
label={mjId}
autoColor={mjId}
size='sm'
className='font-mono'
/>
)
},
meta: { label: t('Task ID'), mobileHidden: true },
}
)
// Submit Result (admin only)
if (isAdmin) {
columns.push({
accessorKey: 'code',
header: t('Submit Result'),
cell: ({ row }) => {
const code = row.getValue('code') as number
return (
<StatusBadge
label={t(mjSubmitResultMapper.getLabel(String(code)))}
variant={mjSubmitResultMapper.getVariant(String(code))}
size='sm'
copyable={false}
showDot
/>
)
},
meta: { label: t('Submit Result') },
})
}
columns.push(
// Status
{
accessorKey: 'status',
header: t('Status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
return (
<StatusBadge
label={t(mjStatusMapper.getLabel(status))}
variant={mjStatusMapper.getVariant(status)}
size='sm'
copyable={false}
showDot
/>
)
},
meta: { label: t('Status') },
},
createProgressColumn<MidjourneyLog>({ headerLabel: t('Progress') }),
// Image
{
accessorKey: 'image_url',
header: t('Image'),
cell: function ImageCell({ row }) {
const log = row.original
const imageUrl = row.getValue('image_url') as string
const [dialogOpen, setDialogOpen] = useState(false)
if (!imageUrl) {
return <span className='text-muted-foreground text-sm'>-</span>
}
return (
<>
<Button
variant='ghost'
className='text-primary h-auto p-0 text-sm font-normal hover:underline'
onClick={() => setDialogOpen(true)}
>
{t('View')}
</Button>
<ImageDialog
imageUrl={imageUrl}
taskId={log.mj_id}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
},
meta: { label: t('Image'), mobileHidden: true },
},
// Prompt (clickable)
{
accessorKey: 'prompt',
header: t('Prompt'),
cell: function PromptCell({ row }) {
const log = row.original
const prompt = row.getValue('prompt') as string
const [dialogOpen, setDialogOpen] = useState(false)
if (!prompt) {
return <span className='text-muted-foreground text-sm'>-</span>
}
return (
<>
<Button
variant='ghost'
className='h-auto max-w-[300px] justify-start overflow-hidden p-0 text-left text-sm font-normal hover:underline'
onClick={() => setDialogOpen(true)}
title={t('Click to view full prompt')}
>
<span className='truncate'>{prompt}</span>
</Button>
<PromptDialog
prompt={prompt}
promptEn={log.prompt_en}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
},
meta: { label: t('Prompt'), mobileHidden: true },
},
createFailReasonColumn<MidjourneyLog>({
headerLabel: t('Fail Reason'),
cellTitle: t('Click to view full error message'),
})
)
return columns
}
@@ -0,0 +1,256 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { Music } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { StatusBadge } from '@/components/status-badge'
import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
import {
taskActionMapper,
taskStatusMapper,
taskPlatformMapper,
} from '../../lib/mappers'
import type { TaskLog } from '../../types'
import {
AudioPreviewDialog,
type AudioClip,
} from '../dialogs/audio-preview-dialog'
import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
import {
createTimestampColumn,
createDurationColumn,
createChannelColumn,
createProgressColumn,
} from './column-helpers'
function parseTaskData(data: unknown): unknown[] {
if (Array.isArray(data)) return data
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
return []
}
function AudioPreviewCell({ log }: { log: TaskLog }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const clips = useMemo(() => {
const data = parseTaskData(log.data)
return data.filter(
(c) =>
c && typeof c === 'object' && (c as Record<string, unknown>).audio_url
)
}, [log.data])
if (clips.length === 0) return null
return (
<>
<Button
variant='link'
className='h-auto p-0 text-sm'
onClick={() => setOpen(true)}
>
<Music className='mr-1 h-3 w-3' />
{t('Click to preview audio')}
</Button>
<AudioPreviewDialog
open={open}
onOpenChange={setOpen}
clips={clips as AudioClip[]}
/>
</>
)
}
export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
const { t } = useTranslation()
const columns: ColumnDef<TaskLog>[] = [
createTimestampColumn<TaskLog>({
accessorKey: 'submit_time',
title: t('Submit Time'),
unit: 'seconds',
}),
createTimestampColumn<TaskLog>({
accessorKey: 'finish_time',
title: t('Finish Time'),
unit: 'seconds',
}),
createDurationColumn<TaskLog>({
submitTimeKey: 'submit_time',
finishTimeKey: 'finish_time',
unit: 'seconds',
headerLabel: t('Duration'),
}),
]
// Channel (admin only)
if (isAdmin) {
columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }))
}
columns.push(
// Platform
{
accessorKey: 'platform',
header: t('Platform'),
cell: ({ row }) => {
const platform = row.getValue('platform') as string
return (
<StatusBadge
label={t(platform)}
variant={taskPlatformMapper.getVariant(platform)}
size='sm'
copyable={false}
/>
)
},
meta: { label: t('Platform') },
},
// Type/Action
{
accessorKey: 'action',
header: t('Type'),
cell: ({ row }) => {
const action = row.getValue('action') as string
return (
<StatusBadge
label={t(taskActionMapper.getLabel(action))}
variant={taskActionMapper.getVariant(action)}
size='sm'
copyable={false}
/>
)
},
meta: { label: t('Type') },
},
// Task ID
{
accessorKey: 'task_id',
header: t('Task ID'),
cell: ({ row }) => {
const taskId = row.getValue('task_id') as string
return (
<StatusBadge
label={taskId}
autoColor={taskId}
size='sm'
className='font-mono'
/>
)
},
meta: { label: t('Task ID'), mobileHidden: true },
},
// Status
{
accessorKey: 'status',
header: t('Status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
return (
<StatusBadge
label={t(taskStatusMapper.getLabel(status, status || 'Submitting'))}
variant={taskStatusMapper.getVariant(status)}
size='sm'
copyable={false}
showDot
/>
)
},
meta: { label: t('Status') },
},
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
// Result/Fail Reason - Combined column
{
accessorKey: 'fail_reason',
header: t('Details'),
cell: function DetailsCell({ row }) {
const log = row.original
const failReason = row.getValue('fail_reason') as string
const status = log.status
const [dialogOpen, setDialogOpen] = useState(false)
// Suno audio preview
const isSunoSuccess =
log.platform === 'suno' && status === TASK_STATUS.SUCCESS
if (isSunoSuccess) {
const data = parseTaskData(log.data)
if (
data.some(
(c) =>
c &&
typeof c === 'object' &&
(c as Record<string, unknown>).audio_url
)
) {
return <AudioPreviewCell log={log} />
}
}
// For video generation tasks that succeeded, fail_reason contains the result URL
const isVideoTask =
log.action === TASK_ACTIONS.GENERATE ||
log.action === TASK_ACTIONS.TEXT_GENERATE ||
log.action === TASK_ACTIONS.FIRST_TAIL_GENERATE ||
log.action === TASK_ACTIONS.REFERENCE_GENERATE ||
log.action === TASK_ACTIONS.REMIX_GENERATE
const isSuccess = status === TASK_STATUS.SUCCESS
const isUrl = failReason?.startsWith('http')
// If success and is a URL, show as result link
if (isSuccess && isVideoTask && isUrl) {
const videoUrl = `/v1/videos/${log.task_id}/content`
return (
<a
href={videoUrl}
target='_blank'
rel='noopener noreferrer'
className='text-primary text-sm hover:underline'
>
{t('Click to preview video')}
</a>
)
}
// Otherwise, show fail reason (if any) using the existing dialog
if (!failReason) {
return <span className='text-muted-foreground text-sm'>-</span>
}
return (
<>
<Button
variant='ghost'
className='h-auto max-w-[200px] justify-start overflow-hidden p-0 text-left text-sm font-normal text-red-600 hover:underline'
onClick={() => setDialogOpen(true)}
title={t('Click to view full error message')}
>
<span className='truncate'>{failReason}</span>
</Button>
<FailReasonDialog
failReason={failReason}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
},
meta: { label: t('Details') },
size: 200,
maxSize: 220,
}
)
return columns
}
@@ -0,0 +1,71 @@
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { formatLogQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { useIsAdmin } from '@/hooks/use-admin'
import { Skeleton } from '@/components/ui/skeleton'
import { dotColorMap, textColorMap } from '@/components/status-badge'
import { getLogStats, getUserLogStats } from '../api'
import { DEFAULT_LOG_STATS } from '../constants'
import { buildApiParams } from '../lib/utils'
const route = getRouteApi('/_authenticated/usage-logs/$section')
export function CommonLogsStats() {
const { t } = useTranslation()
const isAdmin = useIsAdmin()
const searchParams = route.useSearch()
const { data: stats, isLoading } = useQuery({
queryKey: ['usage-logs-stats', isAdmin, searchParams],
queryFn: async () => {
const params = buildApiParams({
page: 1,
pageSize: 1,
searchParams,
columnFilters: [],
isAdmin,
})
const result = isAdmin
? await getLogStats(params)
: await getUserLogStats(params)
return result.success
? result.data || DEFAULT_LOG_STATS
: DEFAULT_LOG_STATS
},
placeholderData: (previousData) => previousData,
})
if (isLoading) {
return (
<div className='flex items-center gap-2'>
<Skeleton className='h-6 w-[126px] rounded-md' />
<Skeleton className='h-6 w-[58px] rounded-md' />
<Skeleton className='h-6 w-[58px] rounded-md' />
</div>
)
}
return (
<div className='flex items-center gap-1.5 text-xs font-medium'>
<span
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap.blue)}
aria-hidden='true'
/>
<span className={cn(textColorMap.blue)}>
{t('Usage')}: {formatLogQuota(stats?.quota || 0)}
</span>
<span className='text-muted-foreground/30'>·</span>
<span className={cn(textColorMap.pink)}>
{t('RPM')}: {stats?.rpm || 0}
</span>
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground'>
{t('TPM')}: {stats?.tpm || 0}
</span>
</div>
)
}
@@ -0,0 +1,165 @@
import { useState, useRef, useEffect } from 'react'
import { ExternalLink, Copy, Music } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { StatusBadge } from '@/components/status-badge'
export interface AudioClip {
clip_id?: string
id?: string
title?: string
tags?: string
duration?: number
audio_url?: string
image_url?: string
image_large_url?: string
metadata?: {
tags?: string
duration?: number
}
}
interface AudioPreviewDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
clips: AudioClip[]
}
function formatDuration(seconds?: number): string {
if (!seconds || seconds <= 0) return '--:--'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function AudioClipCard({ clip }: { clip: AudioClip }) {
const { t } = useTranslation()
const [hasError, setHasError] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setHasError(false)
}, [clip.audio_url])
const title = clip.title || t('Untitled')
const tags = clip.tags || clip.metadata?.tags || ''
const duration = clip.duration || clip.metadata?.duration
const imageUrl = clip.image_url || clip.image_large_url
const audioUrl = clip.audio_url
if (!audioUrl) return null
return (
<div className='bg-card flex gap-4 rounded-lg border p-4'>
{imageUrl && (
<img
src={imageUrl}
alt={title}
className='h-20 w-20 shrink-0 rounded-lg object-cover'
onError={(e) => {
;(e.target as HTMLElement).style.display = 'none'
}}
/>
)}
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center gap-2'>
<span className='truncate text-sm font-medium'>{title}</span>
{duration != null && duration > 0 && (
<StatusBadge
label={formatDuration(duration)}
variant='neutral'
className='shrink-0'
copyable={false}
/>
)}
</div>
{tags && (
<p className='text-muted-foreground mb-2 truncate text-xs'>{tags}</p>
)}
{hasError ? (
<div className='flex flex-wrap items-center gap-2'>
<span className='text-destructive text-xs'>
{t('Audio playback failed')}
</span>
<Button
variant='outline'
size='sm'
className='h-7 gap-1 text-xs'
onClick={() => window.open(audioUrl, '_blank')}
>
<ExternalLink className='h-3 w-3' />
{t('Open in new tab')}
</Button>
<Button
variant='outline'
size='sm'
className='h-7 gap-1 text-xs'
onClick={() => {
navigator.clipboard.writeText(audioUrl)
toast.success(t('Copied'))
}}
>
<Copy className='h-3 w-3' />
{t('Copy Link')}
</Button>
</div>
) : (
<audio
ref={audioRef}
src={audioUrl}
controls
preload='none'
onError={() => setHasError(true)}
className='h-9 w-full'
/>
)}
</div>
</div>
)
}
export function AudioPreviewDialog(props: AudioPreviewDialogProps) {
const { t } = useTranslation()
const clips = Array.isArray(props.clips) ? props.clips : []
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Music className='h-5 w-5' />
{t('Audio Preview')}
</DialogTitle>
</DialogHeader>
{clips.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{t('None')}
</p>
) : (
<ScrollArea className='max-h-[60vh]'>
<div className='space-y-3 pr-2'>
{clips.map((clip, idx) => (
<AudioClipCard
key={clip.clip_id || clip.id || idx}
clip={clip}
/>
))}
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,994 @@
import {
Copy,
Check,
Route,
Settings2,
AlertTriangle,
Headphones,
Monitor,
Cloud,
Globe,
ShieldCheck,
UserCog,
Info,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { formatLogQuota, formatTokens, formatUseTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown'
import type { UsageLog } from '../../data/schema'
import {
parseLogOther,
getParamOverrideActionLabel,
parseAuditLine,
decodeBillingExprB64,
getTieredBillingSummary,
hasAnyCacheTokens,
isViolationFeeLog,
getTimeColor,
} from '../../lib/format'
import {
getLogTypeConfig,
isPerCallBilling,
isTimingLogType,
} from '../../lib/utils'
import type { LogOtherData } from '../../types'
function DetailRow(props: {
label: React.ReactNode
value: React.ReactNode
mono?: boolean
muted?: boolean
}) {
return (
<div className='flex items-start gap-3 text-sm'>
<span className='text-muted-foreground w-28 shrink-0 text-xs'>
{props.label}
</span>
<span
className={cn(
'min-w-0 text-xs break-words',
props.mono && 'font-mono',
props.muted && 'text-muted-foreground'
)}
>
{props.value}
</span>
</div>
)
}
function DetailSection(props: {
icon?: React.ReactNode
label: string
variant?: 'default' | 'danger'
children: React.ReactNode
}) {
const isDanger = props.variant === 'danger'
return (
<div className='space-y-1.5'>
<Label
className={cn(
'flex items-center gap-1.5 text-xs font-semibold',
isDanger && 'text-red-500'
)}
>
{props.icon}
{props.label}
</Label>
<div
className={cn(
'space-y-1.5 rounded-md border p-2.5',
isDanger
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/20'
: 'bg-muted/30'
)}
>
{props.children}
</div>
</div>
)
}
function formatRatio(ratio: number | undefined): string {
if (ratio == null) return '-'
return ratio.toFixed(4)
}
function BillingBreakdown(props: {
log: UsageLog
other: LogOtherData
isAdmin: boolean
}) {
const { t } = useTranslation()
const { log, other, isAdmin } = props
const isPerCall = isPerCallBilling(other.model_price)
const isClaude = other.claude === true
const tieredSummary = getTieredBillingSummary(other)
const rows: Array<{ label: string; value: string }> = []
const priceOpts = { digitsLarge: 4, digitsSmall: 6, abbreviate: false }
const fmtPrice = (usd: number) => formatBillingCurrencyFromUSD(usd, priceOpts)
const baseInputUSD = other.model_ratio != null ? other.model_ratio * 2.0 : 0
if (tieredSummary) {
rows.push({
label: t('Billing Mode'),
value: t('Dynamic Pricing'),
})
if (tieredSummary.tier.label) {
rows.push({
label: t('Matched Tier'),
value: tieredSummary.tier.label,
})
}
for (const entry of tieredSummary.priceEntries) {
rows.push({
label: t(entry.shortLabel),
value: `${fmtPrice(entry.price)}/M`,
})
}
} else if (isPerCall) {
rows.push({ label: t('Billing Mode'), value: t('Per-call') })
if (other.model_price != null) {
rows.push({
label: t('Model Price'),
value: fmtPrice(other.model_price),
})
}
} else {
rows.push({ label: t('Billing Mode'), value: t('Per-token') })
if (other.model_ratio != null) {
rows.push({
label: t('Input'),
value: `${fmtPrice(baseInputUSD)}/M`,
})
}
if (other.completion_ratio != null && other.model_ratio != null) {
rows.push({
label: t('Output'),
value: `${fmtPrice(baseInputUSD * other.completion_ratio)}/M`,
})
}
}
const userGR = other.user_group_ratio
const isUserGR = userGR != null && Number.isFinite(userGR) && userGR !== -1
const effectiveGR = isUserGR ? userGR : other.group_ratio
if (effectiveGR != null && Number.isFinite(effectiveGR)) {
rows.push({
label: isUserGR ? t('User Exclusive Ratio') : t('Group Ratio'),
value: `${formatRatio(effectiveGR)}x`,
})
}
if (!tieredSummary && isClaude) {
if (other.cache_ratio != null && other.cache_ratio !== 1) {
rows.push({
label: t('Cache Read'),
value: `${fmtPrice(baseInputUSD * other.cache_ratio)}/M`,
})
}
if (
other.cache_creation_ratio != null &&
other.cache_creation_ratio !== 1
) {
rows.push({
label: t('Cache Creation'),
value: `${fmtPrice(baseInputUSD * other.cache_creation_ratio)}/M`,
})
}
if (
other.cache_creation_ratio_5m != null &&
other.cache_creation_ratio_5m !== 0
) {
rows.push({
label: t('Cache Creation (5m)'),
value: `${fmtPrice(baseInputUSD * other.cache_creation_ratio_5m)}/M`,
})
}
if (
other.cache_creation_ratio_1h != null &&
other.cache_creation_ratio_1h !== 0
) {
rows.push({
label: t('Cache Creation (1h)'),
value: `${fmtPrice(baseInputUSD * other.cache_creation_ratio_1h)}/M`,
})
}
}
if (!tieredSummary) {
if (other.audio_ratio != null && other.audio_ratio !== 1) {
rows.push({
label: t('Audio input'),
value: `${fmtPrice(baseInputUSD * other.audio_ratio)}/M`,
})
}
if (
other.audio_completion_ratio != null &&
other.audio_completion_ratio !== 1
) {
rows.push({
label: t('Audio output'),
value: `${fmtPrice(baseInputUSD * other.audio_completion_ratio)}/M`,
})
}
if (other.image_ratio != null && other.image_ratio !== 1) {
rows.push({
label: t('Image input'),
value: `${fmtPrice(baseInputUSD * other.image_ratio)}/M`,
})
}
}
if (other.web_search && other.web_search_call_count) {
rows.push({
label: t('Web Search'),
value: `${other.web_search_call_count}x${other.web_search_price ? ` (${fmtPrice(other.web_search_price)})` : ''}`,
})
}
if (other.file_search && other.file_search_call_count) {
rows.push({
label: t('File Search'),
value: `${other.file_search_call_count}x${other.file_search_price ? ` (${fmtPrice(other.file_search_price)})` : ''}`,
})
}
if (other.image_generation_call && other.image_generation_call_price) {
rows.push({
label: t('Image Generation'),
value: fmtPrice(other.image_generation_call_price),
})
}
if (other.audio_input_seperate_price && other.audio_input_price) {
rows.push({
label: t('Audio Input Price'),
value: fmtPrice(other.audio_input_price),
})
}
if (isAdmin && other.admin_info) {
rows.push({
label: t('Billing Source'),
value: other.admin_info.local_count_tokens
? t('Local Billing')
: t('Upstream Response'),
})
}
rows.push({
label: t('Total Cost'),
value: formatLogQuota(log.quota),
})
if (rows.length === 0) return null
return (
<DetailSection label={t('Billing Details')}>
{rows.map((row, idx) => (
<DetailRow key={idx} label={row.label} value={row.value} mono />
))}
</DetailSection>
)
}
function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) {
const { t } = useTranslation()
const { log, other } = props
const promptTokens = log.prompt_tokens || 0
const completionTokens = log.completion_tokens || 0
const cacheRead = other.cache_tokens || 0
const cacheWrite = other.cache_creation_tokens || 0
const cacheWrite5m = other.cache_creation_tokens_5m || 0
const cacheWrite1h = other.cache_creation_tokens_1h || 0
const hasTokens = promptTokens > 0 || completionTokens > 0
if (!hasTokens) return null
const rows: Array<{ label: string; value: string }> = []
rows.push({ label: t('Input Tokens'), value: promptTokens.toLocaleString() })
rows.push({
label: t('Output Tokens'),
value: completionTokens.toLocaleString(),
})
if (cacheRead > 0) {
rows.push({
label: t('Cache Read'),
value: cacheRead.toLocaleString(),
})
}
if (cacheWrite > 0 && cacheWrite5m === 0 && cacheWrite1h === 0) {
rows.push({
label: t('Cache Write'),
value: cacheWrite.toLocaleString(),
})
}
if (cacheWrite5m > 0) {
rows.push({
label: t('Cache Write (5m)'),
value: cacheWrite5m.toLocaleString(),
})
}
if (cacheWrite1h > 0) {
rows.push({
label: t('Cache Write (1h)'),
value: cacheWrite1h.toLocaleString(),
})
}
if (other.image && other.image_output) {
rows.push({
label: t('Image Tokens'),
value: other.image_output.toLocaleString(),
})
}
return (
<DetailSection label={t('Token Breakdown')}>
{rows.map((row, idx) => (
<DetailRow key={idx} label={row.label} value={row.value} mono />
))}
</DetailSection>
)
}
interface DetailsDialogProps {
log: UsageLog
isAdmin: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
export function DetailsDialog(props: DetailsDialogProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
const details = props.log.content ?? ''
const other = parseLogOther(props.log.other)
const typeConfig = getLogTypeConfig(props.log.type)
const isViolation = isViolationFeeLog(other)
const isRefund = props.log.type === 6
const isConsume = props.log.type === 2
const isTopup = props.log.type === 1
const isManage = props.log.type === 3
const isSubscription = other?.billing_source === 'subscription'
const hasAudioTokens = other?.ws || other?.audio
const showTiming = isTimingLogType(props.log.type)
const showAdminIp =
!!props.log.ip && (showTiming || (props.isAdmin && isTopup))
const adminInfo = other?.admin_info
const topupAuditFields =
isTopup && props.isAdmin && adminInfo
? ([
adminInfo.payment_method && {
label: t('Order Payment Method'),
value: adminInfo.payment_method,
},
adminInfo.callback_payment_method && {
label: t('Callback Payment Method'),
value: adminInfo.callback_payment_method,
},
adminInfo.caller_ip && {
label: t('Callback Caller IP'),
value: adminInfo.caller_ip,
},
adminInfo.server_ip && {
label: t('Server IP'),
value: adminInfo.server_ip,
},
adminInfo.node_name && {
label: t('Node Name'),
value: adminInfo.node_name,
},
adminInfo.version && {
label: t('System Version'),
value: adminInfo.version,
},
].filter(Boolean) as Array<{ label: string; value: string }>)
: []
const showLegacyTopupWarning = isTopup && props.isAdmin && !adminInfo
const showTopupAuditSection =
isTopup &&
props.isAdmin &&
(topupAuditFields.length > 0 || showLegacyTopupWarning)
const manageOperator = (() => {
if (!isManage || !props.isAdmin || !adminInfo) return null
const username = adminInfo.admin_username
const id = adminInfo.admin_id
const hasUsername = username != null && String(username).trim() !== ''
const hasId = id != null && String(id).trim() !== ''
if (!hasUsername && !hasId) return null
if (hasUsername && hasId) return `${username} (ID: ${id})`
if (hasUsername) return String(username)
return `ID: ${id}`
})()
const conversionChain =
other && Array.isArray(other.request_conversion)
? other.request_conversion.filter(Boolean)
: []
const conversionLabel =
conversionChain.length <= 1
? t('Native format')
: conversionChain.join(' -> ')
const showConversion =
props.isAdmin &&
props.log.type !== 6 &&
(other?.request_path || conversionChain.length > 0)
const useChannel = other?.admin_info?.use_channel
const channelChain =
useChannel && useChannel.length > 0 ? useChannel.join(' → ') : undefined
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2 text-base'>
{t('Log Details')}
<StatusBadge
label={t(typeConfig.label)}
variant={typeConfig.color as StatusBadgeProps['variant']}
size='sm'
copyable={false}
/>
</DialogTitle>
<DialogDescription className='sr-only'>
{t('View the complete details for this log entry')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[70vh] pr-4'>
<div className='space-y-3 py-1'>
{/* Overview section - key identifiers */}
<div className='space-y-1.5'>
{props.log.request_id && (
<DetailRow
label={t('Request ID')}
value={props.log.request_id}
mono
/>
)}
{props.isAdmin && props.log.channel > 0 && (
<DetailRow
label={t('Channel')}
value={
<span>
{props.log.channel}
{props.log.channel_name && (
<span className='text-muted-foreground'>
{' '}
({props.log.channel_name})
</span>
)}
</span>
}
mono
/>
)}
{channelChain && props.isAdmin && (
<DetailRow label={t('Retry Chain')} value={channelChain} mono />
)}
{props.log.token_name && (
<DetailRow
label={t('Token')}
value={props.log.token_name}
mono
/>
)}
{(props.log.group || other?.group) && (
<DetailRow
label={t('Group')}
value={props.log.group || other?.group || ''}
mono
/>
)}
{showAdminIp && (
<DetailRow
label={t('IP Address')}
value={
<span className='flex items-center gap-1'>
<Globe
className='size-3 text-amber-500'
aria-hidden='true'
/>
{props.log.ip}
</span>
}
mono
/>
)}
{showTiming && props.log.use_time > 0 && (
<DetailRow
label={t('Response Time')}
value={
<span
className={cn(
'font-medium',
getTimeColor(props.log.use_time) === 'success'
? 'text-emerald-600'
: getTimeColor(props.log.use_time) === 'info'
? 'text-sky-600'
: 'text-amber-600'
)}
>
{formatUseTime(props.log.use_time)}
{props.log.is_stream &&
other?.frt != null &&
other.frt > 0 && (
<span className='text-muted-foreground font-normal'>
{' '}
(FRT: {formatUseTime(other.frt / 1000)})
</span>
)}
</span>
}
/>
)}
</div>
{/* Request conversion (admin only, not for refund) */}
{showConversion && (
<DetailSection label={t('Request Conversion')}>
<div className='relative'>
<Button
variant='ghost'
size='sm'
className='absolute top-0 right-0 h-5 w-5 p-0'
onClick={() => copyToClipboard(conversionLabel)}
title={t('Copy to clipboard')}
aria-label={t('Copy to clipboard')}
>
{copiedText === conversionLabel ? (
<Check className='size-3 text-green-600' />
) : (
<Copy className='size-3' />
)}
</Button>
<div className='space-y-1 pr-6'>
{other?.request_path && (
<DetailRow
label={t('Path')}
value={other.request_path}
mono
/>
)}
<div className='flex items-center gap-1.5 text-xs'>
<Route
className='text-muted-foreground size-3'
aria-hidden='true'
/>
<span className='break-words'>{conversionLabel}</span>
</div>
</div>
</div>
</DetailSection>
)}
{/* Reject reason (admin only) */}
{props.isAdmin && other?.reject_reason && (
<DetailSection
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
label={t('Reject Reason')}
variant='danger'
>
<p className='text-xs break-words'>{other.reject_reason}</p>
</DetailSection>
)}
{/* Violation fee info */}
{isViolation && other && (
<DetailSection
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
label={t('Violation Fee')}
variant='danger'
>
{other.violation_fee_code && (
<DetailRow
label={t('Violation Code')}
value={other.violation_fee_code}
mono
/>
)}
{other.violation_fee_marker && (
<DetailRow
label={t('Violation Marker')}
value={other.violation_fee_marker}
/>
)}
<DetailRow
label={t('Fee Amount')}
value={formatLogQuota(other.fee_quota ?? props.log.quota)}
mono
/>
</DetailSection>
)}
{/* Refund details (type=6) */}
{isRefund && other && (other.task_id || other.reason) && (
<DetailSection label={t('Refund Details')}>
{other.task_id && (
<DetailRow label={t('Task ID')} value={other.task_id} mono />
)}
{other.reason && (
<DetailRow label={t('Reason')} value={other.reason} />
)}
</DetailSection>
)}
{/* Top-up audit info (type=1, admin only) */}
{showTopupAuditSection && (
<DetailSection
icon={<ShieldCheck className='size-3.5' aria-hidden='true' />}
label={t('Top-up Audit Info')}
>
{topupAuditFields.map((field, idx) => (
<DetailRow
key={idx}
label={field.label}
value={field.value}
mono
/>
))}
{showLegacyTopupWarning && (
<div className='flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400'>
<Info
className='mt-0.5 size-3.5 shrink-0'
aria-hidden='true'
/>
<span>
{t(
'This record was written by a pre-upgrade instance and lacks audit info. Upgrade the instance to record server IP, callback IP, payment method and system version.'
)}
</span>
</div>
)}
</DetailSection>
)}
{/* Manage operator (type=3, admin only) */}
{manageOperator && (
<DetailRow
label={
<span className='flex items-center gap-1.5'>
<UserCog
className='text-muted-foreground size-3.5'
aria-hidden='true'
/>
{t('Operator Admin')}
</span>
}
value={manageOperator}
mono
/>
)}
{/* Audio/WebSocket token breakdown */}
{hasAudioTokens && other && (
<DetailSection
icon={<Headphones className='size-3.5' aria-hidden='true' />}
label={t('Audio Tokens')}
>
{other.audio_input != null && other.audio_input > 0 && (
<DetailRow
label={t('Audio Input')}
value={formatTokens(other.audio_input)}
mono
/>
)}
{other.audio_output != null && other.audio_output > 0 && (
<DetailRow
label={t('Audio Output')}
value={formatTokens(other.audio_output)}
mono
/>
)}
{other.text_input != null && other.text_input > 0 && (
<DetailRow
label={t('Text Input')}
value={formatTokens(other.text_input)}
mono
/>
)}
{other.text_output != null && other.text_output > 0 && (
<DetailRow
label={t('Text Output')}
value={formatTokens(other.text_output)}
mono
/>
)}
</DetailSection>
)}
{/* Reasoning effort */}
{other?.reasoning_effort && (
<DetailRow
label={t('Reasoning Effort')}
value={
<StatusBadge
label={other.reasoning_effort}
variant={
other.reasoning_effort === 'high'
? 'orange'
: other.reasoning_effort === 'medium'
? 'yellow'
: 'green'
}
size='sm'
copyable={false}
/>
}
/>
)}
{/* System prompt override */}
{other?.is_system_prompt_overwritten && (
<DetailRow
label={t('System Prompt')}
value={
<StatusBadge
label={t('Overwritten')}
variant='orange'
size='sm'
copyable={false}
/>
}
/>
)}
{/* Model mapping */}
{other?.is_model_mapped && other?.upstream_model_name && (
<DetailSection label={t('Model Mapping')}>
<DetailRow
label={t('Request Model')}
value={props.log.model_name}
mono
/>
<DetailRow
label={t('Actual Model')}
value={other.upstream_model_name}
mono
/>
</DetailSection>
)}
{/* Token breakdown (for consume/error types with token data) */}
{isDisplayableType(props.log.type) && other && (
<TokenBreakdown log={props.log} other={other} />
)}
{/* Billing breakdown (consume type) */}
{isConsume && other && !isViolation && (
<BillingBreakdown
log={props.log}
other={other}
isAdmin={props.isAdmin}
/>
)}
{/* Tiered pricing breakdown (when billing_mode is tiered_expr) */}
{isConsume &&
!isViolation &&
other?.billing_mode === 'tiered_expr' &&
other?.expr_b64 && (
<div className='bg-muted/30 rounded-md border px-3'>
<DynamicPricingBreakdown
billingExpr={decodeBillingExprB64(other.expr_b64)}
matchedTierLabel={other.matched_tier}
hideCacheColumns={!hasAnyCacheTokens(other)}
/>
</div>
)}
{/* Admin billing mode indicator for non-consume */}
{props.isAdmin &&
!isConsume &&
props.log.type !== 6 &&
other?.admin_info && (
<DetailRow
label={t('Billing Source')}
value={
<span className='flex items-center gap-1'>
{other.admin_info.local_count_tokens ? (
<Monitor className='size-3 text-blue-500' />
) : (
<Cloud className='size-3 text-emerald-500' />
)}
<span className='text-xs'>
{other.admin_info.local_count_tokens
? t('Local Billing')
: t('Upstream Response')}
</span>
</span>
}
/>
)}
{/* Stream status details (admin only) */}
{props.isAdmin &&
other?.stream_status &&
other.stream_status.status !== 'ok' && (
<DetailSection label={t('Stream Status')}>
<DetailRow
label={t('Status')}
value={
<StatusBadge
label={other.stream_status.status || t('Error')}
variant='red'
size='sm'
copyable={false}
/>
}
/>
{other.stream_status.end_reason && (
<DetailRow
label={t('End Reason')}
value={other.stream_status.end_reason}
/>
)}
{(other.stream_status.error_count ?? 0) > 0 && (
<DetailRow
label={t('Soft Errors')}
value={String(other.stream_status.error_count)}
/>
)}
{other.stream_status.end_error && (
<DetailRow
label={t('End Error')}
value={other.stream_status.end_error}
/>
)}
{Array.isArray(other.stream_status.errors) &&
other.stream_status.errors.length > 0 && (
<pre className='bg-background/60 mt-1 max-h-32 overflow-y-auto rounded border p-2 font-mono text-[11px] leading-relaxed break-words whitespace-pre-wrap'>
{other.stream_status.errors.join('\n')}
</pre>
)}
</DetailSection>
)}
{/* Subscription billing details */}
{isSubscription && other && (
<DetailSection label={t('Subscription Billing')}>
{other.subscription_plan_id && (
<DetailRow
label={t('Plan')}
value={`#${other.subscription_plan_id} ${other.subscription_plan_title || ''}`.trim()}
/>
)}
{other.subscription_id && (
<DetailRow
label={t('Instance')}
value={`#${other.subscription_id}`}
mono
/>
)}
{other.subscription_pre_consumed != null && (
<DetailRow
label={t('Pre-consumed')}
value={formatLogQuota(other.subscription_pre_consumed)}
mono
/>
)}
{other.subscription_post_delta != null &&
other.subscription_post_delta !== 0 && (
<DetailRow
label={t('Post Delta')}
value={formatLogQuota(other.subscription_post_delta)}
mono
/>
)}
{other.subscription_consumed != null && (
<DetailRow
label={t('Final Consumed')}
value={formatLogQuota(other.subscription_consumed)}
mono
/>
)}
{other.subscription_remain != null && (
<DetailRow
label={t('Remaining')}
value={`${formatLogQuota(other.subscription_remain)}${other.subscription_total != null ? ` / ${formatLogQuota(other.subscription_total)}` : ''}`}
mono
/>
)}
</DetailSection>
)}
{/* Param override (admin only) */}
{props.isAdmin &&
other?.po &&
Array.isArray(other.po) &&
other.po.length > 0 && (
<DetailSection
icon={<Settings2 className='size-3.5' aria-hidden='true' />}
label={`${t('Param Override')} (${other.po.length})`}
>
{other.po.filter(Boolean).map((line, idx) => {
const parsed = parseAuditLine(line)
if (!parsed) return null
return (
<div
key={idx}
className='bg-background/60 flex items-start gap-2 rounded border p-2'
>
<StatusBadge
variant='neutral'
label={getParamOverrideActionLabel(parsed.action, t)}
className='shrink-0 font-medium'
copyable={false}
/>
<span className='min-w-0 font-mono text-[11px] leading-relaxed break-words'>
{parsed.content}
</span>
</div>
)
})}
</DetailSection>
)}
{/* Content */}
{details && (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>{t('Content')}</Label>
<div className='bg-muted/30 relative rounded-md border p-2.5'>
<Button
variant='ghost'
size='sm'
className='absolute top-1.5 right-1.5 h-5 w-5 p-0'
onClick={() => copyToClipboard(details)}
title={t('Copy to clipboard')}
aria-label={t('Copy to clipboard')}
>
{copiedText === details ? (
<Check className='size-3 text-green-600' />
) : (
<Copy className='size-3' />
)}
</Button>
<p className='pr-6 text-xs leading-relaxed break-words whitespace-pre-wrap'>
{details}
</p>
</div>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
function isDisplayableType(type: number): boolean {
return [0, 2, 5, 6].includes(type)
}
@@ -0,0 +1,69 @@
import { Copy, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
interface FailReasonDialogProps {
failReason: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function FailReasonDialog({
failReason,
open,
onOpenChange,
}: FailReasonDialogProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Fail Reason Details')}</DialogTitle>
<DialogDescription>
{t('View the complete error message and details')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Error Message')}
</Label>
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(failReason)}
title={t('Copy to clipboard')}
>
{copiedText === failReason ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
{failReason || '-'}
</p>
</div>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,60 @@
/**
* Reusable components for filter dialogs
*/
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
// ============================================================================
// Filter Input Component
// ============================================================================
interface FilterInputProps {
id: string
label: string
placeholder: string
value: string
onChange: (value: string) => void
}
export function FilterInput({
id,
label,
placeholder,
value,
onChange,
}: FilterInputProps) {
return (
<div className='grid gap-2'>
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)
}
// ============================================================================
// Section Divider Component
// ============================================================================
interface SectionDividerProps {
label: string
}
export function SectionDivider({ label }: SectionDividerProps) {
return (
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background text-muted-foreground px-2'>
{label}
</span>
</div>
</div>
)
}
@@ -0,0 +1,102 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
interface ImageDialogProps {
imageUrl: string
taskId?: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function ImageDialog({
imageUrl,
taskId,
open,
onOpenChange,
}: ImageDialogProps) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
// Reset loading state when dialog opens or image URL changes
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setIsLoading(true)
setHasError(false)
}
onOpenChange(newOpen)
}
const handleImageLoad = () => {
setIsLoading(false)
setHasError(false)
}
const handleImageError = () => {
setIsLoading(false)
setHasError(true)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Image Preview')}</DialogTitle>
<DialogDescription>
{taskId
? `${t('Task ID:')} ${taskId}`
: t('View the generated image')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[600px]'>
<div className='py-4'>
<div className='bg-muted/50 relative flex min-h-[300px] items-center justify-center rounded-lg border'>
{/* Skeleton - show when loading or error */}
{(isLoading || hasError) && (
<Skeleton className='absolute inset-0 h-full w-full rounded-lg' />
)}
{/* Actual Image */}
<img
src={imageUrl}
alt={t('Generated image')}
className={`max-h-[550px] w-full rounded-lg object-contain ${
isLoading || hasError ? 'opacity-0' : 'opacity-100'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
loading='lazy'
/>
{/* Error text overlay (shown on skeleton) */}
{hasError && (
<div className='absolute inset-0 flex items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Failed to load image')}
</p>
</div>
)}
</div>
{/* Image URL */}
<div className='bg-muted mt-4 rounded-md p-3'>
<p className='text-muted-foreground font-mono text-xs break-all'>
{imageUrl}
</p>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,97 @@
import { Copy, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
interface PromptDialogProps {
prompt: string
promptEn?: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function PromptDialog({
prompt,
promptEn,
open,
onOpenChange,
}: PromptDialogProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Prompt Details')}</DialogTitle>
<DialogDescription>
{t('View the complete prompt and its English translation')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
{/* Original Prompt */}
<div className='space-y-2'>
<Label className='text-sm font-semibold'>{t('Prompt')}</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(prompt)}
title={t('Copy to clipboard')}
>
{copiedText === prompt ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{prompt || '-'}
</p>
</div>
</div>
{/* English Prompt */}
{promptEn && (
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Prompt (EN)')}
</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(promptEn)}
title={t('Copy to clipboard')}
>
{copiedText === promptEn ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{promptEn}
</p>
</div>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,371 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { Search, RotateCcw, Calendar } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getNormalizedDateRange } from '@/lib/time'
import { cn } from '@/lib/utils'
import { useIsAdmin } from '@/hooks/use-admin'
import { Button } from '@/components/ui/button'
import { ComboboxInput } from '@/components/ui/combobox-input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { DateTimePicker } from '@/components/datetime-picker'
import { getApiKeys } from '@/features/keys/api'
import { TIME_RANGE_PRESETS } from '../../constants'
import { buildSearchParams, getLogCategoryLabel } from '../../lib/filter'
import { getDefaultTimeRange } from '../../lib/utils'
import type {
LogCategory,
LogFilters,
CommonLogFilters,
DrawingLogFilters,
TaskLogFilters,
} from '../../types'
import { FilterInput, SectionDivider } from './filter-components'
const route = getRouteApi('/_authenticated/usage-logs/$section')
interface UsageLogsFilterDialogProps {
open?: boolean
onOpenChange?: (open: boolean) => void
onFilterChange?: (filters: LogFilters) => void
logCategory: LogCategory
}
export function UsageLogsFilterDialog({
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
onFilterChange,
logCategory,
}: UsageLogsFilterDialogProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const searchParams = route.useSearch()
const isAdmin = useIsAdmin()
const [internalOpen, setInternalOpen] = useState(false)
const open = controlledOpen ?? internalOpen
const setOpen = controlledOnOpenChange ?? setInternalOpen
const [filters, setFilters] = useState<LogFilters>(() => {
const { start, end } = getDefaultTimeRange()
return { startTime: start, endTime: end }
})
const [selectedRange, setSelectedRange] = useState<number | null>(null)
const { data: tokensData } = useQuery({
queryKey: ['api-keys', 'filter', open, logCategory],
queryFn: () => getApiKeys({ p: 1, size: 200 }),
enabled: open && logCategory === 'common',
})
const tokenNameOptions = useMemo(() => {
const items = tokensData?.data?.items ?? []
const seen = new Set<string>()
return items
.filter((item) => {
if (seen.has(item.name)) return false
seen.add(item.name)
return true
})
.map((item) => ({ value: item.name, label: item.name }))
}, [tokensData?.data?.items])
// Sync filters from URL
useEffect(() => {
const urlFilters: Partial<LogFilters> = {}
if (searchParams.startTime)
urlFilters.startTime = new Date(searchParams.startTime)
if (searchParams.endTime)
urlFilters.endTime = new Date(searchParams.endTime)
if (searchParams.channel) urlFilters.channel = String(searchParams.channel)
if (logCategory === 'common') {
if (searchParams.model)
(urlFilters as CommonLogFilters).model = searchParams.model
if (searchParams.token)
(urlFilters as CommonLogFilters).token = searchParams.token
if (searchParams.group)
(urlFilters as CommonLogFilters).group = searchParams.group
if (searchParams.username)
(urlFilters as CommonLogFilters).username = searchParams.username
if (searchParams.requestId)
(urlFilters as CommonLogFilters).requestId = searchParams.requestId
} else if (logCategory === 'drawing') {
if (searchParams.filter)
(urlFilters as DrawingLogFilters).mjId = searchParams.filter
} else if (logCategory === 'task') {
if (searchParams.filter)
(urlFilters as TaskLogFilters).taskId = searchParams.filter
}
if (Object.keys(urlFilters).length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFilters((prev: LogFilters) => ({ ...prev, ...urlFilters }))
setSelectedRange(null)
}
}, [
logCategory,
searchParams.startTime,
searchParams.endTime,
searchParams.channel,
searchParams.model,
searchParams.token,
searchParams.group,
searchParams.username,
searchParams.requestId,
searchParams.filter,
])
const handleChange = useCallback(
(field: string, value: Date | string | undefined) => {
setFilters((prev: LogFilters) => ({ ...prev, [field]: value }))
if (field === 'startTime' || field === 'endTime') {
setSelectedRange(null)
}
},
[]
)
const handleQuickRange = useCallback((days: number) => {
const { start, end } = getNormalizedDateRange(days)
setFilters((prev: LogFilters) => ({
...prev,
startTime: start,
endTime: end,
}))
setSelectedRange(days)
}, [])
const navigateWithFilters = useCallback(
(searchUpdate: Record<string, unknown>) => {
navigate({
to: '/usage-logs/$section',
params: { section: logCategory },
search: (prev: Record<string, unknown>) => ({
...prev,
...searchUpdate,
}),
})
},
[navigate, logCategory]
)
const handleApply = useCallback(() => {
const filterParams = buildSearchParams(filters, logCategory)
navigateWithFilters(filterParams)
onFilterChange?.(filters)
setOpen(false)
}, [filters, logCategory, navigateWithFilters, onFilterChange, setOpen])
const handleReset = useCallback(() => {
const { start, end } = getDefaultTimeRange()
const resetFilters: LogFilters = { startTime: start, endTime: end }
setFilters(resetFilters)
setSelectedRange(null)
navigate({
to: '/usage-logs/$section',
params: { section: logCategory },
search: {
page: 1,
startTime: start.getTime(),
endTime: end.getTime(),
},
})
onFilterChange?.(resetFilters)
setOpen(false)
}, [navigate, logCategory, onFilterChange, setOpen])
// Render category-specific filters
const renderCategoryFilters = () => {
switch (logCategory) {
case 'common': {
const commonFilters = filters as CommonLogFilters
return (
<>
<FilterInput
id='model'
label={t('Model Name')}
placeholder={t('e.g., gpt-4, claude-3')}
value={commonFilters.model || ''}
onChange={(value) => handleChange('model', value)}
/>
<div className='grid gap-2'>
<Label htmlFor='token'>{t('Token Name')}</Label>
<ComboboxInput
id='token'
options={tokenNameOptions}
value={commonFilters.token || ''}
onValueChange={(v) => handleChange('token', v)}
placeholder={t('Filter by token name')}
emptyText={t('No token found.')}
/>
</div>
<FilterInput
id='group'
label={t('Group')}
placeholder={t('Filter by group')}
value={commonFilters.group || ''}
onChange={(value) => handleChange('group', value)}
/>
{isAdmin && (
<FilterInput
id='username'
label={t('Username')}
placeholder={t('Filter by username')}
value={commonFilters.username || ''}
onChange={(value) => handleChange('username', value)}
/>
)}
<FilterInput
id='requestId'
label={t('Request ID')}
placeholder={t('Filter by request ID')}
value={commonFilters.requestId || ''}
onChange={(value) => handleChange('requestId', value)}
/>
</>
)
}
case 'drawing': {
const drawingFilters = filters as DrawingLogFilters
return (
<FilterInput
id='mjId'
label={t('Task ID')}
placeholder={t('Filter by Midjourney task ID')}
value={drawingFilters.mjId || ''}
onChange={(value) => handleChange('mjId', value)}
/>
)
}
case 'task': {
const taskFilters = filters as TaskLogFilters
return (
<FilterInput
id='taskId'
label={t('Task ID')}
placeholder={t('Filter by task ID')}
value={taskFilters.taskId || ''}
onChange={(value) => handleChange('taskId', value)}
/>
)
}
default:
return null
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>
{t('Filter')} {t(getLogCategoryLabel(logCategory))} {t('Logs')}
</DialogTitle>
<DialogDescription>
{t('Set filters to narrow down your log search results.')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='grid gap-4 py-4'>
{/* Quick time range selection */}
<div className='grid gap-2'>
<Label className='flex items-center gap-2'>
<Calendar className='h-4 w-4' />
{t('Quick Range')}
</Label>
<div className='flex gap-2'>
{TIME_RANGE_PRESETS.map((range) => (
<Button
key={range.days}
type='button'
size='sm'
variant={
selectedRange === range.days ? 'default' : 'outline'
}
onClick={() => handleQuickRange(range.days)}
className={cn(
'flex-1',
selectedRange === range.days &&
'ring-ring ring-2 ring-offset-2'
)}
>
{t(range.label)}
</Button>
))}
</div>
</div>
<SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */}
<div className='grid gap-4'>
<div className='grid gap-2'>
<Label htmlFor='start_time'>{t('Start Time')}</Label>
<DateTimePicker
value={filters.startTime}
onChange={(date) =>
handleChange('startTime', date || undefined)
}
placeholder={t('Select start time')}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end_time'>{t('End Time')}</Label>
<DateTimePicker
value={filters.endTime}
onChange={(date) =>
handleChange('endTime', date || undefined)
}
placeholder={t('Select end time')}
/>
</div>
</div>
<SectionDivider label={t('Filters')} />
{renderCategoryFilters()}
{/* Channel filter (admin only, all log types) */}
{isAdmin && (
<FilterInput
id='channel'
label={t('Channel ID')}
placeholder={t('Filter by channel ID')}
value={filters.channel || ''}
onChange={(value) => handleChange('channel', value)}
/>
)}
</div>
</ScrollArea>
<DialogFooter>
<Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
</Button>
<Button onClick={handleApply} type='submit'>
<Search className='mr-2 h-4 w-4' />
{t('Apply Filters')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatQuota, formatCompactNumber } from '@/lib/format'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { getUserInfo } from '../../api'
import type { UserInfo } from '../../types'
interface UserInfoDialogProps {
userId: number | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function UserInfoDialog({
userId,
open,
onOpenChange,
}: UserInfoDialogProps) {
const { t } = useTranslation()
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const fetchUserInfo = useCallback(
async (id: number) => {
setIsLoading(true)
try {
const result = await getUserInfo(id)
if (result.success) {
setUserInfo(result.data || null)
} else {
toast.error(result.message || t('Failed to fetch user information'))
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch user info:', error)
toast.error(t('Failed to fetch user information'))
} finally {
setIsLoading(false)
}
},
[t]
)
useEffect(() => {
if (open && userId) {
fetchUserInfo(userId)
}
}, [open, userId, fetchUserInfo])
const InfoItem = ({
label,
value,
}: {
label: string
value: string | number
}) => (
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>{label}</Label>
<div className='text-sm font-semibold'>{value}</div>
</div>
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('User Information')}</DialogTitle>
<DialogDescription>
{t(
'View detailed information about this user including balance, usage statistics, and invitation details.'
)}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground size-6 animate-spin' />
</div>
) : userInfo ? (
<div className='space-y-4 py-4'>
{/* Basic Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem label={t('Username')} value={userInfo.username} />
{userInfo.display_name && (
<InfoItem
label={t('Display Name')}
value={userInfo.display_name}
/>
)}
</div>
{/* Balance Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Balance')}
value={formatQuota(userInfo.quota)}
/>
<InfoItem
label={t('Used Quota')}
value={formatQuota(userInfo.used_quota)}
/>
</div>
{/* Statistics */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Request Count')}
value={formatCompactNumber(userInfo.request_count)}
/>
{userInfo.group && (
<InfoItem label={t('User Group')} value={userInfo.group} />
)}
</div>
{/* Invitation Info */}
{(userInfo.aff_code ||
userInfo.aff_count !== undefined ||
(userInfo.aff_quota !== undefined && userInfo.aff_quota > 0)) && (
<>
<div className='grid grid-cols-2 gap-4'>
{userInfo.aff_code && (
<InfoItem
label={t('Invitation Code')}
value={userInfo.aff_code}
/>
)}
{userInfo.aff_count !== undefined && (
<InfoItem
label={t('Invited Users')}
value={formatCompactNumber(userInfo.aff_count)}
/>
)}
</div>
{userInfo.aff_quota !== undefined && userInfo.aff_quota > 0 && (
<InfoItem
label={t('Invitation Quota')}
value={formatQuota(userInfo.aff_quota)}
/>
)}
</>
)}
{/* Remark */}
{userInfo.remark && (
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>
{t('Remark')}
</Label>
<div className='text-sm leading-relaxed font-semibold break-words'>
{userInfo.remark}
</div>
</div>
)}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No user information available')}
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,32 @@
import { useState } from 'react'
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import type { LogCategory } from '../types'
import { UsageLogsFilterDialog } from './dialogs/usage-logs-filter-dialog'
interface UsageLogsPrimaryButtonsProps {
logCategory: LogCategory
}
export function UsageLogsPrimaryButtons({
logCategory,
}: UsageLogsPrimaryButtonsProps) {
const { t } = useTranslation()
const [filterDialogOpen, setFilterDialogOpen] = useState(false)
return (
<div className='flex gap-2'>
<Button size='sm' onClick={() => setFilterDialogOpen(true)}>
<Search className='h-4 w-4' />
{t('Search')}
</Button>
<UsageLogsFilterDialog
open={filterDialogOpen}
onOpenChange={setFilterDialogOpen}
logCategory={logCategory}
/>
</div>
)
}
@@ -0,0 +1,51 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, type ReactNode } from 'react'
import type { ChannelAffinityInfo } from '../types'
interface UsageLogsContextValue {
selectedUserId: number | null
setSelectedUserId: (userId: number | null) => void
userInfoDialogOpen: boolean
setUserInfoDialogOpen: (open: boolean) => void
affinityTarget: ChannelAffinityInfo | null
setAffinityTarget: (target: ChannelAffinityInfo | null) => void
affinityDialogOpen: boolean
setAffinityDialogOpen: (open: boolean) => void
}
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
undefined
)
export function UsageLogsProvider({ children }: { children: ReactNode }) {
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const [userInfoDialogOpen, setUserInfoDialogOpen] = useState(false)
const [affinityTarget, setAffinityTarget] =
useState<ChannelAffinityInfo | null>(null)
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
return (
<UsageLogsContext.Provider
value={{
selectedUserId,
setSelectedUserId,
userInfoDialogOpen,
setUserInfoDialogOpen,
affinityTarget,
setAffinityTarget,
affinityDialogOpen,
setAffinityDialogOpen,
}}
>
{children}
</UsageLogsContext.Provider>
)
}
export function useUsageLogsContext() {
const context = useContext(UsageLogsContext)
if (!context) {
throw new Error('useUsageLogsContext must be used within UsageLogsProvider')
}
return context
}
@@ -0,0 +1,237 @@
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useIsAdmin } from '@/hooks/use-admin'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DataTablePagination,
DataTableToolbar,
TableSkeleton,
TableEmpty,
MobileCardList,
} from '@/components/data-table'
import { PageFooterPortal } from '@/components/layout'
import { LOG_TYPE_FILTERS, DEFAULT_LOGS_DATA } from '../constants'
import { useColumnsByCategory } from '../lib/columns'
import { fetchLogsByCategory } from '../lib/utils'
import type { LogCategory } from '../types'
const route = getRouteApi('/_authenticated/usage-logs/$section')
interface UsageLogsTableProps {
logCategory: LogCategory
}
export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const { t } = useTranslation()
const isAdmin = useIsAdmin()
const isMobile = useMediaQuery('(max-width: 640px)')
const searchParams = route.useSearch()
const {
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 },
globalFilter: { enabled: false },
columnFilters: [
{ columnId: 'created_at', searchKey: 'type', type: 'array' as const },
{ columnId: 'model_name', searchKey: 'model', type: 'string' as const },
{ columnId: 'token_name', searchKey: 'token', type: 'string' as const },
{ columnId: 'group', searchKey: 'group', type: 'string' as const },
...(isAdmin
? [
{
columnId: 'channel',
searchKey: 'channel',
type: 'string' as const,
},
{
columnId: 'username',
searchKey: 'username',
type: 'string' as const,
},
]
: []),
],
})
const { data, isLoading, isFetching } = useQuery({
queryKey: [
'logs',
logCategory,
isAdmin,
pagination.pageIndex + 1,
pagination.pageSize,
columnFilters,
searchParams,
t,
],
queryFn: async () => {
const result = await fetchLogsByCategory({
logCategory,
isAdmin,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
searchParams,
columnFilters,
})
if (!result?.success) {
toast.error(result?.message || t('Failed to load logs'))
return DEFAULT_LOGS_DATA
}
return result.data || DEFAULT_LOGS_DATA
},
placeholderData: (previousData, previousQuery) => {
if (previousQuery?.queryKey[1] === logCategory) {
return previousData
}
return undefined
},
})
const logs = data?.items || []
const columns = useColumnsByCategory(logCategory, isAdmin)
const isLoadingData = isLoading || (isFetching && !data)
const table = useReactTable({
data: logs as Record<string, unknown>[],
columns: columns as ColumnDef<Record<string, unknown>>[],
state: {
columnFilters,
pagination,
},
enableRowSelection: false,
onPaginationChange,
onColumnFiltersChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
manualPagination: true,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
})
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
const filters =
logCategory === 'common'
? [
{
columnId: 'created_at',
title: t('Log Type'),
options: LOG_TYPE_FILTERS.map((opt) => ({
value: opt.value,
label: t(opt.label),
})),
singleSelect: true,
},
]
: []
return (
<>
<div className='space-y-4'>
<DataTableToolbar table={table} filters={filters} customSearch={null} />
{isMobile ? (
<MobileCardList
table={table}
isLoading={isLoadingData}
emptyTitle={t('No Logs Found')}
emptyDescription={t(
'No usage logs available. Logs will appear here once API calls are made.'
)}
/>
) : (
<div
className={cn(
'overflow-hidden rounded-md border transition-opacity duration-150',
isFetching && !isLoadingData && 'pointer-events-none opacity-50'
)}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoadingData ? (
<TableSkeleton table={table} keyPrefix='usage-log-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Logs Found')}
description={t(
'No usage logs available. Logs will appear here once API calls are made.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className='py-2'>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
<PageFooterPortal>
<DataTablePagination table={table} />
</PageFooterPortal>
</>
)
}
+320
View File
@@ -0,0 +1,320 @@
/**
* Shared constants for usage logs feature
*/
import type { StatusBadgeProps } from '@/components/status-badge'
import type { LogStatistics, LogCategory } from './types'
// ============================================================================
// Default Values
// ============================================================================
/**
* Default log statistics when no data is available
*/
export const DEFAULT_LOG_STATS: LogStatistics = {
quota: 0,
rpm: 0,
tpm: 0,
}
/**
* Default empty logs data
*/
export const DEFAULT_LOGS_DATA = {
items: [],
total: 0,
}
// ============================================================================
// Log Type Enum
// ============================================================================
/**
* Log type enum values
*/
export const LOG_TYPE_ENUM = {
UNKNOWN: 0,
TOPUP: 1,
CONSUME: 2,
MANAGE: 3,
SYSTEM: 4,
ERROR: 5,
REFUND: 6,
} as const
// ============================================================================
// Time Range Presets
// ============================================================================
/**
* Quick time range presets for filter dialog
*/
export const TIME_RANGE_PRESETS = [
{ days: 1, label: '24 Hours' },
{ days: 7, label: '7 Days' },
{ days: 14, label: '14 Days' },
{ days: 30, label: '30 Days' },
] as const
// ============================================================================
// Common Logs Configuration
// ============================================================================
/**
* Log types configuration for filtering and display
*/
export const LOG_TYPES = [
{ value: 0, label: 'Unknown', color: 'default' },
{ value: 1, label: 'Top-up', color: 'cyan' },
{ value: 2, label: 'Consume', color: 'green' },
{ value: 3, label: 'Manage', color: 'orange' },
{ value: 4, label: 'System', color: 'purple' },
{ value: 5, label: 'Error', color: 'red' },
{ value: 6, label: 'Refund', color: 'blue' },
] as const
/**
* Log types for DataTableToolbar filters (single select mode)
*/
export const LOG_TYPE_FILTERS = LOG_TYPES.map((type) => ({
label: type.label,
value: String(type.value),
}))
// ============================================================================
// Drawing Logs (Midjourney) Constants
// ============================================================================
/**
* Midjourney task types
* Must match backend constants in constant/midjourney.go
*/
export const MJ_TASK_TYPES = {
IMAGINE: 'IMAGINE', // 绘图
UPSCALE: 'UPSCALE', // 放大
VIDEO: 'VIDEO', // 视频
EDITS: 'EDITS', // 编辑
VARIATION: 'VARIATION', // 变换
HIGH_VARIATION: 'HIGH_VARIATION', // 强变换
LOW_VARIATION: 'LOW_VARIATION', // 弱变换
PAN: 'PAN', // 平移
DESCRIBE: 'DESCRIBE', // 图生文
BLEND: 'BLEND', // 图混合
UPLOAD: 'UPLOAD', // 上传文件
SHORTEN: 'SHORTEN', // 缩词
REROLL: 'REROLL', // 重绘
INPAINT: 'INPAINT', // 局部重绘
SWAP_FACE: 'SWAP_FACE', // 换脸
ZOOM: 'ZOOM', // 缩放
CUSTOM_ZOOM: 'CUSTOM_ZOOM', // 自定义缩放
MODAL: 'MODAL', // 窗口
} as const
/**
* Midjourney task status
*/
export const MJ_TASK_STATUS = {
NOT_START: 'NOT_START', // 未启动
SUBMITTED: 'SUBMITTED', // 队列中
IN_PROGRESS: 'IN_PROGRESS', // 执行中
SUCCESS: 'SUCCESS', // 成功
FAILURE: 'FAILURE', // 失败
MODAL: 'MODAL', // 窗口等待
} as const
/**
* Midjourney submit result codes
*/
export const MJ_SUBMIT_RESULT_CODES = {
NOT_SUBMITTED: 0, // 未提交
SUBMITTED: 1, // 已提交
WAITING: 21, // 等待中
DUPLICATE: 22, // 重复任务
} as const
// ============================================================================
// Task Logs Constants
// ============================================================================
/**
* Task action types
* Must match backend constants in constant/task.go
*/
export const TASK_ACTIONS = {
// Suno (uppercase)
MUSIC: 'MUSIC', // 生成音乐
LYRICS: 'LYRICS', // 生成歌词
// Video generation (camelCase)
GENERATE: 'generate', // 图生视频
TEXT_GENERATE: 'textGenerate', // 文生视频
FIRST_TAIL_GENERATE: 'firstTailGenerate', // 首尾生视频
REFERENCE_GENERATE: 'referenceGenerate', // 参照生视频
REMIX_GENERATE: 'remixGenerate', // 视频 Remix
} as const
/**
* Task status
*/
export const TASK_STATUS = {
NOT_START: 'NOT_START', // 未启动
SUBMITTED: 'SUBMITTED', // 队列中
IN_PROGRESS: 'IN_PROGRESS', // 执行中
SUCCESS: 'SUCCESS', // 成功
FAILURE: 'FAILURE', // 失败
QUEUED: 'QUEUED', // 排队中
UNKNOWN: 'UNKNOWN', // 未知
} as const
/**
* Task platforms
*/
export const TASK_PLATFORMS = {
SUNO: 'suno',
KLING: 'kling',
RUNWAY: 'runway',
LUMA: 'luma',
VIGGLE: 'viggle',
} as const
// ============================================================================
// Status Mappings
// ============================================================================
/**
* Status mapping configuration type
*/
export interface StatusMapping {
label: string
variant: StatusBadgeProps['variant']
}
/**
* Midjourney task type mappings
*/
export const MJ_TASK_TYPE_MAPPINGS: Record<string, StatusMapping> = {
[MJ_TASK_TYPES.IMAGINE]: { label: 'Draw', variant: 'blue' },
[MJ_TASK_TYPES.UPSCALE]: { label: 'Upscale', variant: 'orange' },
[MJ_TASK_TYPES.VIDEO]: { label: 'Video', variant: 'orange' },
[MJ_TASK_TYPES.EDITS]: { label: 'Edit', variant: 'orange' },
[MJ_TASK_TYPES.VARIATION]: { label: 'Vary', variant: 'violet' },
[MJ_TASK_TYPES.HIGH_VARIATION]: { label: 'Vary (Strong)', variant: 'violet' },
[MJ_TASK_TYPES.LOW_VARIATION]: { label: 'Vary (Subtle)', variant: 'violet' },
[MJ_TASK_TYPES.PAN]: { label: 'Pan', variant: 'cyan' },
[MJ_TASK_TYPES.DESCRIBE]: { label: 'Describe', variant: 'yellow' },
[MJ_TASK_TYPES.BLEND]: { label: 'Blend', variant: 'lime' },
[MJ_TASK_TYPES.UPLOAD]: { label: 'Upload', variant: 'blue' },
[MJ_TASK_TYPES.SHORTEN]: { label: 'Shorten', variant: 'pink' },
[MJ_TASK_TYPES.REROLL]: { label: 'Reroll', variant: 'indigo' },
[MJ_TASK_TYPES.INPAINT]: { label: 'Inpaint', variant: 'teal' },
[MJ_TASK_TYPES.SWAP_FACE]: { label: 'Swap Face', variant: 'purple' },
[MJ_TASK_TYPES.ZOOM]: { label: 'Zoom', variant: 'green' },
[MJ_TASK_TYPES.CUSTOM_ZOOM]: { label: 'Custom Zoom', variant: 'green' },
}
/**
* Midjourney task status mappings
*/
export const MJ_STATUS_MAPPINGS: Record<string, StatusMapping> = {
[MJ_TASK_STATUS.SUCCESS]: { label: 'Success', variant: 'green' },
[MJ_TASK_STATUS.NOT_START]: { label: 'Not Started', variant: 'neutral' },
[MJ_TASK_STATUS.SUBMITTED]: { label: 'Queued', variant: 'yellow' },
[MJ_TASK_STATUS.IN_PROGRESS]: { label: 'In Progress', variant: 'blue' },
[MJ_TASK_STATUS.FAILURE]: { label: 'Failed', variant: 'red' },
[MJ_TASK_STATUS.MODAL]: { label: 'Waiting', variant: 'amber' },
}
/**
* Midjourney submit result mappings
*/
export const MJ_SUBMIT_RESULT_MAPPINGS: Record<string, StatusMapping> = {
[String(MJ_SUBMIT_RESULT_CODES.SUBMITTED)]: {
label: 'Submitted',
variant: 'green',
},
[String(MJ_SUBMIT_RESULT_CODES.WAITING)]: {
label: 'Waiting',
variant: 'lime',
},
[String(MJ_SUBMIT_RESULT_CODES.DUPLICATE)]: {
label: 'Duplicate',
variant: 'orange',
},
[String(MJ_SUBMIT_RESULT_CODES.NOT_SUBMITTED)]: {
label: 'Not Submitted',
variant: 'yellow',
},
}
/**
* Task action type mappings
*/
export const TASK_ACTION_MAPPINGS: Record<string, StatusMapping> = {
[TASK_ACTIONS.MUSIC]: { label: 'Generate Music', variant: 'neutral' },
[TASK_ACTIONS.LYRICS]: { label: 'Generate Lyrics', variant: 'pink' },
[TASK_ACTIONS.GENERATE]: { label: 'Image to Video', variant: 'blue' },
[TASK_ACTIONS.TEXT_GENERATE]: { label: 'Text to Video', variant: 'blue' },
[TASK_ACTIONS.FIRST_TAIL_GENERATE]: {
label: 'First/Last Frame to Video',
variant: 'blue',
},
[TASK_ACTIONS.REFERENCE_GENERATE]: {
label: 'Reference Video',
variant: 'blue',
},
[TASK_ACTIONS.REMIX_GENERATE]: {
label: 'Video Remix',
variant: 'blue',
},
}
/**
* Task status mappings
*/
export const TASK_STATUS_MAPPINGS: Record<string, StatusMapping> = {
[TASK_STATUS.SUCCESS]: { label: 'Success', variant: 'green' },
[TASK_STATUS.NOT_START]: { label: 'Not Started', variant: 'neutral' },
[TASK_STATUS.SUBMITTED]: { label: 'Queued', variant: 'yellow' },
[TASK_STATUS.IN_PROGRESS]: { label: 'In Progress', variant: 'blue' },
[TASK_STATUS.FAILURE]: { label: 'Failed', variant: 'red' },
[TASK_STATUS.QUEUED]: { label: 'Queued', variant: 'orange' },
[TASK_STATUS.UNKNOWN]: { label: 'Unknown', variant: 'neutral' },
}
/**
* Task platform mappings
*/
export const TASK_PLATFORM_MAPPINGS: Record<string, StatusMapping> = {
[TASK_PLATFORMS.SUNO]: { label: 'suno', variant: 'green' },
[TASK_PLATFORMS.KLING]: { label: 'kling', variant: 'blue' },
[TASK_PLATFORMS.RUNWAY]: { label: 'runway', variant: 'violet' },
[TASK_PLATFORMS.LUMA]: { label: 'luma', variant: 'orange' },
[TASK_PLATFORMS.VIGGLE]: { label: 'viggle', variant: 'pink' },
}
// ============================================================================
// Log Category Labels
// ============================================================================
/**
* Log category display labels
*/
export const LOG_CATEGORY_LABELS: Record<LogCategory, string> = {
common: 'Common',
drawing: 'Drawing',
task: 'Task',
}
// ============================================================================
// Log Type Checkers (Constants)
// ============================================================================
/**
* Log types that are displayable (have detailed info)
*/
export const DISPLAYABLE_LOG_TYPES = [0, 2, 5, 6] as const
/**
* Log types that show timing info
*/
export const TIMING_LOG_TYPES = [2, 5] as const
+31
View File
@@ -0,0 +1,31 @@
/**
* Zod schemas for common logs
* This file should only contain Zod schemas and types inferred from them
*/
import { z } from 'zod'
// Usage log schema
export const usageLogSchema = z.object({
id: z.number(),
user_id: z.number(),
created_at: z.number(),
type: z.number(),
content: z.string(),
username: z.string().default(''),
token_name: z.string().default(''),
model_name: z.string().default(''),
quota: z.number().default(0),
prompt_tokens: z.number().default(0),
completion_tokens: z.number().default(0),
use_time: z.number().default(0),
is_stream: z.boolean().default(false),
channel: z.number().default(0),
channel_name: z.string().nullish().default(''),
token_id: z.number().default(0),
group: z.string().default(''),
ip: z.string().default(''),
other: z.string().default(''),
request_id: z.string().default(''),
})
export type UsageLog = z.infer<typeof usageLogSchema>
+104
View File
@@ -0,0 +1,104 @@
import { getRouteApi } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
import { CommonLogsStats } from './components/common-logs-stats'
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
import { UsageLogsPrimaryButtons } from './components/usage-logs-primary-buttons'
import {
UsageLogsProvider,
useUsageLogsContext,
} from './components/usage-logs-provider'
import { UsageLogsTable } from './components/usage-logs-table'
import {
isUsageLogsSectionId,
USAGE_LOGS_DEFAULT_SECTION,
type UsageLogsSectionId,
} from './section-registry'
const route = getRouteApi('/_authenticated/usage-logs/$section')
function UsageLogsContent() {
const { t } = useTranslation()
const params = route.useParams()
const activeCategory: UsageLogsSectionId =
params.section && isUsageLogsSectionId(params.section)
? params.section
: USAGE_LOGS_DEFAULT_SECTION
const {
selectedUserId,
userInfoDialogOpen,
setUserInfoDialogOpen,
affinityTarget,
affinityDialogOpen,
setAffinityDialogOpen,
} = useUsageLogsContext()
const title =
activeCategory === 'common'
? t('Common Logs')
: activeCategory === 'drawing'
? t('Drawing Logs')
: activeCategory === 'task'
? t('Task Logs')
: t('Usage Logs')
const description =
activeCategory === 'common'
? t('View and manage your API usage logs')
: activeCategory === 'drawing'
? t('View and manage your drawing logs')
: activeCategory === 'task'
? t('View and manage your task logs')
: t('View and manage your API usage logs')
return (
<>
<SectionPageLayout>
<SectionPageLayout.Title>{title}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{description}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
{activeCategory === 'common' && <CommonLogsStats />}
<UsageLogsPrimaryButtons logCategory={activeCategory} />
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<UsageLogsTable logCategory={activeCategory} />
</SectionPageLayout.Content>
</SectionPageLayout>
<UserInfoDialog
userId={selectedUserId}
open={userInfoDialogOpen}
onOpenChange={setUserInfoDialogOpen}
/>
<CacheStatsDialog
open={affinityDialogOpen}
onOpenChange={setAffinityDialogOpen}
target={
affinityTarget
? {
rule_name: affinityTarget.rule_name || '',
using_group:
affinityTarget.using_group ||
affinityTarget.selected_group ||
'',
key_hint: affinityTarget.key_hint || '',
key_fp: affinityTarget.key_fp || '',
}
: null
}
/>
</>
)
}
export function UsageLogs() {
return (
<UsageLogsProvider>
<UsageLogsContent />
</UsageLogsProvider>
)
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Column definitions factory
*/
import type { ColumnDef } from '@tanstack/react-table'
import { useCommonLogsColumns } from '../components/columns/common-logs-columns'
import { useDrawingLogsColumns } from '../components/columns/drawing-logs-columns'
import { useTaskLogsColumns } from '../components/columns/task-logs-columns'
import type { LogCategory } from '../types'
/**
* Get column definitions based on log category
* Returns any[] due to different log types (UsageLog, MidjourneyLog, TaskLog)
*/
export function useColumnsByCategory(
logCategory: LogCategory,
isAdmin: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ColumnDef<any>[] {
const commonColumns = useCommonLogsColumns(isAdmin)
const drawingColumns = useDrawingLogsColumns(isAdmin)
const taskColumns = useTaskLogsColumns(isAdmin)
switch (logCategory) {
case 'common':
return commonColumns
case 'drawing':
return drawingColumns
case 'task':
return taskColumns
default:
return commonColumns
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Utility functions for usage logs filters
*/
import { LOG_CATEGORY_LABELS } from '../constants'
import type {
LogCategory,
LogFilters,
CommonLogFilters,
DrawingLogFilters,
TaskLogFilters,
} from '../types'
// ============================================================================
// Filter Building Functions
// ============================================================================
/**
* Build search params from filters based on log category
*/
export function buildSearchParams(
filters: LogFilters,
logCategory: LogCategory
): Record<string, unknown> {
const baseParams: Record<string, unknown> = {
...(filters.startTime && { startTime: filters.startTime.getTime() }),
...(filters.endTime && { endTime: filters.endTime.getTime() }),
...(filters.channel && { channel: filters.channel }),
}
switch (logCategory) {
case 'common': {
const commonFilters = filters as CommonLogFilters
return {
...baseParams,
...(commonFilters.model && { model: commonFilters.model }),
...(commonFilters.token && { token: commonFilters.token }),
...(commonFilters.group && { group: commonFilters.group }),
...(commonFilters.username && { username: commonFilters.username }),
...(commonFilters.requestId && { requestId: commonFilters.requestId }),
}
}
case 'drawing': {
const drawingFilters = filters as DrawingLogFilters
return {
...baseParams,
...(drawingFilters.mjId && { filter: drawingFilters.mjId }),
}
}
case 'task': {
const taskFilters = filters as TaskLogFilters
return {
...baseParams,
...(taskFilters.taskId && { filter: taskFilters.taskId }),
}
}
default:
return baseParams
}
}
/**
* Get log category display name
*/
export function getLogCategoryLabel(category: LogCategory): string {
return LOG_CATEGORY_LABELS[category]
}
+224
View File
@@ -0,0 +1,224 @@
import type { StatusBadgeProps } from '@/components/status-badge'
import {
BILLING_PRICING_VARS,
parseTiersFromExpr,
type ParsedTier,
} from '@/features/pricing/lib/billing-expr'
import type { UsageLog } from '../data/schema'
import type { LogOtherData } from '../types'
const PARAM_OVERRIDE_ACTION_MAP: Record<string, string> = {
set: 'Set',
delete: 'Delete',
copy: 'Copy',
move: 'Move',
append: 'Append',
prepend: 'Prepend',
trim_prefix: 'Trim Prefix',
trim_suffix: 'Trim Suffix',
ensure_prefix: 'Ensure Prefix',
ensure_suffix: 'Ensure Suffix',
trim_space: 'Trim Space',
to_lower: 'To Lower',
to_upper: 'To Upper',
replace: 'Replace',
regex_replace: 'Regex Replace',
set_header: 'Set Header',
delete_header: 'Delete Header',
copy_header: 'Copy Header',
move_header: 'Move Header',
pass_headers: 'Pass Headers',
sync_fields: 'Sync Fields',
return_error: 'Return Error',
}
/**
* Get localized label for a param override action
*/
export function getParamOverrideActionLabel(
action: string,
t: (key: string) => string
): string {
const key = PARAM_OVERRIDE_ACTION_MAP[action.toLowerCase()]
return key ? t(key) : action
}
/**
* Parse a param override audit line into action and content
*/
export function parseAuditLine(
line: string
): { action: string; content: string } | null {
if (typeof line !== 'string') return null
const firstSpace = line.indexOf(' ')
if (firstSpace <= 0) return { action: line, content: line }
return {
action: line.slice(0, firstSpace),
content: line.slice(firstSpace + 1),
}
}
/**
* Check if the log is a violation fee log
*/
export function isViolationFeeLog(other: LogOtherData | null): boolean {
if (!other) return false
return (
other.violation_fee === true ||
Boolean(other.violation_fee_code) ||
Boolean(other.violation_fee_marker)
)
}
/**
* Parse the 'other' field from JSON string to object
*/
export function parseLogOther(other: string): LogOtherData | null {
if (!other) return null
try {
return JSON.parse(other) as LogOtherData
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse log other field:', error)
return null
}
}
/**
* Get time color based on duration (in seconds)
*/
export function getTimeColor(seconds: number): 'success' | 'info' | 'warning' {
if (seconds < 3) return 'success'
if (seconds < 10) return 'info'
return 'warning'
}
/**
* Format model name with mapping indicator
*/
export function formatModelName(log: UsageLog): {
name: string
isMapped: boolean
actualModel?: string
} {
const other = parseLogOther(log.other)
const isMapped = !!(
other?.is_model_mapped &&
other?.upstream_model_name &&
other.upstream_model_name !== ''
)
return {
name: log.model_name,
isMapped,
actualModel: isMapped ? other.upstream_model_name : undefined,
}
}
/**
* Decode a base64-encoded billing expression. Safely returns an empty string
* when the input is missing or malformed (e.g. legacy logs without expr_b64).
*/
export function decodeBillingExprB64(exprB64: string | undefined): string {
if (!exprB64) return ''
try {
return atob(exprB64)
} catch {
return ''
}
}
/**
* Resolve which parsed tier corresponds to the matched_tier label in a log
* entry. Falls back to the first tier when the label is missing or unknown,
* which mirrors the behaviour of the classic frontend renderer.
*/
export function resolveMatchedTier(
tiers: ParsedTier[],
matchedLabel: string | undefined
): ParsedTier | null {
if (tiers.length === 0) return null
if (matchedLabel) {
const found = tiers.find((tier) => tier.label === matchedLabel)
if (found) return found
}
return tiers[0]
}
/**
* Tiered pricing summary derived from an `other` log payload using the
* billing-expression library. Returns null when the entry is not a tiered
* billing log or the expression failed to parse.
*/
export interface TieredBillingSummary {
tiers: ParsedTier[]
tier: ParsedTier
priceEntries: Array<{ field: string; shortLabel: string; price: number }>
}
/**
* Whether the request payload reports any cache-related token usage. Used to
* suppress cache pricing rows from the tiered breakdown when the request did
* not exercise the cache path (mirrors the classic frontend behaviour).
*/
export function hasAnyCacheTokens(
other: LogOtherData | null | undefined
): boolean {
if (!other) return false
return (
(other.cache_tokens || 0) > 0 ||
(other.cache_creation_tokens || 0) > 0 ||
(other.cache_creation_tokens_5m || 0) > 0 ||
(other.cache_creation_tokens_1h || 0) > 0
)
}
export function getTieredBillingSummary(
other: LogOtherData | null
): TieredBillingSummary | null {
if (!other || other.billing_mode !== 'tiered_expr') return null
const exprStr = decodeBillingExprB64(other.expr_b64)
if (!exprStr) return null
const tiers = parseTiersFromExpr(exprStr)
const tier = resolveMatchedTier(tiers, other.matched_tier)
if (!tier) return null
const cacheTokensPresent = hasAnyCacheTokens(other)
const priceEntries: TieredBillingSummary['priceEntries'] = []
for (const v of BILLING_PRICING_VARS) {
if (!v.field) continue
if (v.group === 'cache' && !cacheTokensPresent) continue
const raw = tier[v.field as keyof ParsedTier]
const price = Number(raw)
if (Number.isFinite(price) && price > 0) {
priceEntries.push({
field: v.field,
shortLabel: v.shortLabel,
price,
})
}
}
return { tiers, tier, priceEntries }
}
/**
* Calculate duration and return formatted result with color variant
* @param submitTime - Submit timestamp
* @param finishTime - Finish timestamp
* @param unit - Unit of the timestamps ('seconds' or 'milliseconds')
*/
export function formatDuration(
submitTime?: number,
finishTime?: number,
unit: 'seconds' | 'milliseconds' = 'milliseconds'
): { durationSec: number; variant: StatusBadgeProps['variant'] } | null {
if (!submitTime || !finishTime) return null
const durationSec =
unit === 'milliseconds'
? (finishTime - submitTime) / 1000
: finishTime - submitTime
return { durationSec, variant: durationSec > 60 ? 'red' : 'green' }
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Central export point for all lib utilities
*/
// Format utilities (usage-logs specific)
export {
parseLogOther,
getTimeColor,
formatModelName,
formatDuration,
getParamOverrideActionLabel,
parseAuditLine,
isViolationFeeLog,
} from './format'
// Filter utilities
export { buildSearchParams, getLogCategoryLabel } from './filter'
// General utilities
export {
isDisplayableLogType,
isTimingLogType,
getLogTypeConfig,
isPerCallBilling,
getDefaultTimeRange,
buildQueryParams,
buildBaseParams,
buildApiParams,
fetchLogsByCategory,
} from './utils'
// Status mapper utilities
export { createStatusMapper } from './status'
// Mappers
export {
mjTaskTypeMapper,
mjStatusMapper,
taskActionMapper,
taskStatusMapper,
taskPlatformMapper,
} from './mappers'
// Column utilities
export { useColumnsByCategory } from './columns'
+53
View File
@@ -0,0 +1,53 @@
/**
* Status mappers for different log types
* Centralized mapper instances for consistent usage across components
*/
import {
MJ_TASK_TYPE_MAPPINGS,
MJ_STATUS_MAPPINGS,
MJ_SUBMIT_RESULT_MAPPINGS,
TASK_ACTION_MAPPINGS,
TASK_STATUS_MAPPINGS,
TASK_PLATFORM_MAPPINGS,
} from '../constants'
import { createStatusMapper } from './status'
// ============================================================================
// Midjourney (Drawing) Logs Mappers
// ============================================================================
/**
* Midjourney task type mapper
*/
export const mjTaskTypeMapper = createStatusMapper(MJ_TASK_TYPE_MAPPINGS)
/**
* Midjourney task status mapper
*/
export const mjStatusMapper = createStatusMapper(MJ_STATUS_MAPPINGS)
/**
* Midjourney submit result mapper
*/
export const mjSubmitResultMapper = createStatusMapper(
MJ_SUBMIT_RESULT_MAPPINGS
)
// ============================================================================
// Task Logs Mappers
// ============================================================================
/**
* Task action type mapper
*/
export const taskActionMapper = createStatusMapper(TASK_ACTION_MAPPINGS)
/**
* Task status mapper
*/
export const taskStatusMapper = createStatusMapper(TASK_STATUS_MAPPINGS)
/**
* Task platform mapper
*/
export const taskPlatformMapper = createStatusMapper(TASK_PLATFORM_MAPPINGS)
+21
View File
@@ -0,0 +1,21 @@
import type { StatusBadgeProps } from '@/components/status-badge'
/**
* Generic status mapping utility
* Creates a function to map status values to labels and variants
*/
export function createStatusMapper<T extends string>(mapping: {
[key in T]?: { label: string; variant: StatusBadgeProps['variant'] }
}) {
return {
getLabel: (status: string, defaultLabel = 'Unknown'): string => {
return mapping[status as T]?.label ?? defaultLabel
},
getVariant: (
status: string,
defaultVariant: StatusBadgeProps['variant'] = 'neutral'
): StatusBadgeProps['variant'] => {
return mapping[status as T]?.variant ?? defaultVariant
},
}
}
+275
View File
@@ -0,0 +1,275 @@
/**
* Utility functions for usage logs feature
*/
import {
getAllLogs,
getUserLogs,
getAllMidjourneyLogs,
getUserMidjourneyLogs,
getAllTaskLogs,
getUserTaskLogs,
} from '../api'
import {
LOG_TYPES,
DISPLAYABLE_LOG_TYPES,
TIMING_LOG_TYPES,
} from '../constants'
import type {
GetLogsParams,
GetLogsResponse,
FetchLogsConfig,
GetMidjourneyLogsParams,
GetTaskLogsParams,
} from '../types'
// ============================================================================
// Type Checkers & Utilities
// ============================================================================
/**
* Check if log type is displayable (has detailed info)
*/
export function isDisplayableLogType(type: number): boolean {
return (DISPLAYABLE_LOG_TYPES as readonly number[]).includes(type)
}
/**
* Check if log type shows timing info
*/
export function isTimingLogType(type: number): boolean {
return (TIMING_LOG_TYPES as readonly number[]).includes(type)
}
/**
* Get log type configuration by type number
*/
export function getLogTypeConfig(type: number) {
return LOG_TYPES.find((t) => t.value === type) || LOG_TYPES[0]
}
/**
* Check if log uses per-call billing
*/
export function isPerCallBilling(modelPrice?: number): boolean {
return (modelPrice ?? 0) > 0
}
/**
* Get default time range (today 00:00:00 to now + 1 hour)
*/
export function getDefaultTimeRange(): { start: Date; end: Date } {
const now = new Date()
const start = new Date(now)
start.setHours(0, 0, 0, 0)
const end = new Date(now.getTime() + 3600 * 1000) // +1 hour
return { start, end }
}
/**
* Convert milliseconds timestamp to seconds for API
*/
function timestampToSeconds(ms: number): number {
return Math.floor(ms / 1000)
}
/**
* Build query parameters from filters
*/
export function buildQueryParams(
params: Record<string, unknown>
): URLSearchParams {
const queryParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
// Keep 0 as a valid value, only filter out undefined, null, and empty string
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value))
}
})
return queryParams
}
/**
* Build time range parameters with default values
* Shared logic for all log types
*/
function buildTimeRangeParams(
searchParams: Record<string, unknown>,
useMilliseconds: boolean
): { start_timestamp?: number; end_timestamp?: number } {
const hasTimeParams = searchParams.startTime ?? searchParams.endTime
const defaultTimeRange = !hasTimeParams ? getDefaultTimeRange() : null
const convertTimestamp = (timestamp: number) =>
useMilliseconds ? timestamp : timestampToSeconds(timestamp)
const getTimestamp = (paramTime?: unknown, defaultTime?: Date) => {
const time = (paramTime as number) || defaultTime?.getTime()
return time ? convertTimestamp(time) : undefined
}
return {
start_timestamp: getTimestamp(
searchParams.startTime,
defaultTimeRange?.start
),
end_timestamp: getTimestamp(searchParams.endTime, defaultTimeRange?.end),
}
}
/**
* Build base parameters with time range (for drawing and task logs)
* @param useMilliseconds - Whether to use millisecond timestamps (true for drawing logs, false for task logs)
*/
export function buildBaseParams(config: {
page: number
pageSize: number
searchParams: Record<string, unknown>
useMilliseconds?: boolean
}): {
p: number
page_size: number
channel_id?: string
start_timestamp?: number
end_timestamp?: number
} {
const { page, pageSize, searchParams, useMilliseconds = false } = config
return {
p: page,
page_size: pageSize,
...(searchParams.channel
? {
channel_id: String(searchParams.channel),
}
: {}),
...buildTimeRangeParams(searchParams, useMilliseconds),
}
}
/**
* Build API params from search params and column filters (for common logs)
*/
export function buildApiParams(config: {
page: number
pageSize: number
searchParams: Record<string, unknown>
columnFilters?: Array<{ id: string; value: unknown }>
isAdmin: boolean
}): GetLogsParams {
const { page, pageSize, searchParams, columnFilters = [], isAdmin } = config
// Helper to process type parameter (single value from array)
const processType = (value: unknown) => {
if (Array.isArray(value) && value.length === 1) {
return Number(value[0])
}
return undefined
}
// Build base params from search params
const params: GetLogsParams = {
p: page,
page_size: pageSize,
...(searchParams.type ? { type: processType(searchParams.type) } : {}),
...(searchParams.model ? { model_name: String(searchParams.model) } : {}),
...(searchParams.token ? { token_name: String(searchParams.token) } : {}),
...(searchParams.group ? { group: String(searchParams.group) } : {}),
...(isAdmin && searchParams.channel
? { channel: Number(searchParams.channel) || 0 }
: {}),
...(isAdmin && searchParams.username
? { username: String(searchParams.username) }
: {}),
...(searchParams.requestId
? { request_id: String(searchParams.requestId) }
: {}),
...buildTimeRangeParams(searchParams, false),
}
// Override with column filters if present
if (columnFilters.length > 0) {
columnFilters.forEach(({ id, value }) => {
if (value === undefined || value === null || value === '') return
switch (id) {
case 'type':
params.type = processType(value)
break
case 'model_name':
params.model_name = String(value)
break
case 'token_name':
params.token_name = String(value)
break
case 'group':
params.group = String(value)
break
case 'channel':
if (isAdmin) params.channel = Number(value) || 0
break
case 'username':
if (isAdmin) params.username = String(value)
break
}
})
}
return params
}
// ============================================================================
// Data Fetching
// ============================================================================
/**
* Fetch logs based on category type
*/
export async function fetchLogsByCategory(
config: FetchLogsConfig
): Promise<GetLogsResponse> {
const { logCategory, isAdmin, page, pageSize, searchParams, columnFilters } =
config
if (logCategory === 'common') {
const params = buildApiParams({
page,
pageSize,
searchParams,
columnFilters,
isAdmin,
})
return isAdmin ? await getAllLogs(params) : await getUserLogs(params)
}
// For drawing and task logs
const baseParams = buildBaseParams({
page,
pageSize,
searchParams,
useMilliseconds: logCategory === 'drawing',
})
const paramsWithFilter = {
...baseParams,
...(logCategory === 'drawing'
? { mj_id: searchParams.filter as string | undefined }
: {}),
...(logCategory === 'task'
? { task_id: searchParams.filter as string | undefined }
: {}),
}
if (logCategory === 'drawing') {
return isAdmin
? await getAllMidjourneyLogs(paramsWithFilter as GetMidjourneyLogsParams)
: await getUserMidjourneyLogs(paramsWithFilter as GetMidjourneyLogsParams)
}
// task logs
return isAdmin
? await getAllTaskLogs(paramsWithFilter as GetTaskLogsParams)
: await getUserTaskLogs(paramsWithFilter as GetTaskLogsParams)
}
@@ -0,0 +1,47 @@
import { createSectionRegistry } from '@/features/system-settings/utils/section-registry'
/**
* Usage logs page section definitions
*/
const USAGE_LOGS_SECTIONS = [
{
id: 'common',
titleKey: 'Common Logs',
descriptionKey: 'View and manage your API usage logs',
build: () => null, // Content is rendered directly in the page component
},
{
id: 'drawing',
titleKey: 'Drawing Logs',
descriptionKey: 'View and manage your drawing logs',
build: () => null, // Content is rendered directly in the page component
},
{
id: 'task',
titleKey: 'Task Logs',
descriptionKey: 'View and manage your task logs',
build: () => null, // Content is rendered directly in the page component
},
] as const
export type UsageLogsSectionId = (typeof USAGE_LOGS_SECTIONS)[number]['id']
const usageLogsRegistry = createSectionRegistry<
UsageLogsSectionId,
Record<string, never>,
[]
>({
sections: USAGE_LOGS_SECTIONS,
defaultSection: 'common',
basePath: '/usage-logs',
urlStyle: 'path',
})
export const USAGE_LOGS_SECTION_IDS = usageLogsRegistry.sectionIds
export const USAGE_LOGS_DEFAULT_SECTION = usageLogsRegistry.defaultSection
/** Type guard for validating section IDs without casting. Use with z.string().refine() or params checks. */
export function isUsageLogsSectionId(s: string): s is UsageLogsSectionId {
return (USAGE_LOGS_SECTION_IDS as readonly string[]).includes(s)
}
export const getUsageLogsSectionNavItems = usageLogsRegistry.getSectionNavItems
+340
View File
@@ -0,0 +1,340 @@
/**
* Type definitions for usage logs
*/
import type { UsageLog } from './data/schema'
// ============================================================================
// Log Category Types
// ============================================================================
/**
* Log category for different log types
*/
export type LogCategory = 'common' | 'drawing' | 'task'
// ============================================================================
// Filter Types
// ============================================================================
/**
* Common filters (shared across all log types)
*/
export interface CommonFilters {
startTime?: Date
endTime?: Date
channel?: string
}
/**
* Common logs specific filters
*/
export interface CommonLogFilters extends CommonFilters {
model?: string
token?: string
group?: string
username?: string
requestId?: string
}
/**
* Drawing logs specific filters
*/
export interface DrawingLogFilters extends CommonFilters {
mjId?: string
}
/**
* Task logs specific filters
*/
export interface TaskLogFilters extends CommonFilters {
taskId?: string
}
/**
* Union type for all log filters
*/
export type LogFilters = CommonLogFilters | DrawingLogFilters | TaskLogFilters
// ============================================================================
// Common Logs Additional Types
// ============================================================================
/**
* Parsed data from the 'other' field in usage logs
*/
export interface ChannelAffinityInfo {
rule_name?: string
selected_group?: string
key_source?: string
key_path?: string
key_key?: string
key_hint?: string
key_fp?: string
using_group?: string
}
export interface LogOtherData {
admin_info?: {
is_multi_key?: boolean
multi_key_index?: number
use_channel?: number[]
local_count_tokens?: boolean
channel_affinity?: ChannelAffinityInfo
// Top-up audit fields (type=1, admin only)
payment_method?: string
callback_payment_method?: string
caller_ip?: string
server_ip?: string
version?: string
node_name?: string
// Manage audit fields (type=3, admin only)
admin_username?: string
admin_id?: number | string
}
request_path?: string
request_conversion?: string[]
ws?: boolean
audio?: boolean
audio_input?: number
audio_output?: number
text_input?: number
text_output?: number
cache_tokens?: number
cache_creation_tokens?: number
cache_creation_tokens_5m?: number
cache_creation_tokens_1h?: number
claude?: boolean
model_ratio?: number
completion_ratio?: number
model_price?: number
group_ratio?: number
user_group_ratio?: number
cache_ratio?: number
cache_creation_ratio?: number
cache_creation_ratio_5m?: number
cache_creation_ratio_1h?: number
is_model_mapped?: boolean
upstream_model_name?: string
audio_ratio?: number
audio_completion_ratio?: number
frt?: number
// Tiered (expression-based) billing fields, set by backend when
// billing_mode === 'tiered_expr'. expr_b64 is the base64-encoded billing
// expression and matched_tier is the label of the tier that fired.
billing_mode?: string
expr_b64?: string
matched_tier?: string
reasoning_effort?: string
image?: boolean
image_ratio?: number
image_output?: number
web_search?: boolean
web_search_call_count?: number
web_search_price?: number
file_search?: boolean
file_search_call_count?: number
file_search_price?: number
audio_input_seperate_price?: boolean
audio_input_token_count?: number
audio_input_price?: number
image_generation_call?: boolean
image_generation_call_price?: number
is_system_prompt_overwritten?: boolean
po?: string[]
billing_source?: string
group?: string
stream_status?: {
status?: string
end_reason?: string
error_count?: number
end_error?: string
errors?: string[]
}
// Violation fee fields
violation_fee?: boolean
violation_fee_code?: string
violation_fee_marker?: string
fee_quota?: number
// Reject / intercept reason (admin)
reject_reason?: string
// Task-related fields (for refund logs, type=6)
is_task?: boolean
task_id?: string
reason?: string
// Subscription billing fields
subscription_plan_id?: string
subscription_plan_title?: string
subscription_id?: string
subscription_pre_consumed?: number
subscription_post_delta?: number
subscription_consumed?: number
subscription_remain?: number
subscription_total?: number
}
/**
* Log statistics data
*/
export interface LogStatistics {
quota: number
rpm: number
tpm: number
}
// ============================================================================
// Drawing Logs (Midjourney) Types
// ============================================================================
export interface MidjourneyLog {
id: number
user_id: number
channel_id: number
code: number
mj_id: string
action: string // IMAGINE, UPSCALE, VARIATION, etc. (backend field name)
submit_time: number // milliseconds
finish_time?: number // milliseconds
start_time?: number // milliseconds
fail_reason?: string
progress: string
prompt: string
prompt_en?: string
description?: string
buttons?: string
properties?: string
image_url?: string
status: string // NOT_START, SUBMITTED, IN_PROGRESS, SUCCESS, FAILURE, MODAL
other?: string
created_at?: number
updated_at?: number
}
// ============================================================================
// Task Logs Types
// ============================================================================
export interface TaskLog {
id: number
user_id: number
platform: string // suno, kling, runway, etc.
task_id: string
action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.
channel_id: number
submit_time: number // seconds
finish_time?: number // seconds
progress?: string
progress_message_en?: string
data?: string // JSON string
fail_reason?: string
status: string // NOT_START, SUBMITTED, IN_PROGRESS, SUCCESS, FAILURE, QUEUED, UNKNOWN
other?: string
created_at?: number
updated_at?: number
}
// ============================================================================
// Common Log Types
// ============================================================================
export interface GetLogsParams {
p?: number
page_size?: number
type?: number
username?: string
token_name?: string
model_name?: string
start_timestamp?: number
end_timestamp?: number
channel?: number
group?: string
request_id?: string
}
export interface GetLogsResponse {
success: boolean
message?: string
data?: {
items: UsageLog[] | MidjourneyLog[] | TaskLog[]
total: number
page: number
page_size: number
}
}
export interface GetLogStatsParams {
type?: number
username?: string
token_name?: string
model_name?: string
start_timestamp?: number
end_timestamp?: number
channel?: number
group?: string
request_id?: string
}
export interface GetLogStatsResponse {
success: boolean
message?: string
data?: LogStatistics
}
// ============================================================================
// Drawing Log Types
// ============================================================================
export interface GetMidjourneyLogsParams {
p?: number
page_size?: number
channel_id?: string
mj_id?: string
start_timestamp?: number
end_timestamp?: number
}
// ============================================================================
// Task Log Types
// ============================================================================
export interface GetTaskLogsParams {
p?: number
page_size?: number
channel_id?: string
task_id?: string
start_timestamp?: number
end_timestamp?: number
}
// ============================================================================
// Fetch Logs Configuration
// ============================================================================
/**
* Configuration for fetching logs by category
*/
export interface FetchLogsConfig {
logCategory: LogCategory
isAdmin: boolean
page: number
pageSize: number
searchParams: Record<string, unknown>
columnFilters: Array<{ id: string; value: unknown }>
}
// ============================================================================
// User Info Types
// ============================================================================
export interface UserInfo {
id: number
username: string
display_name?: string
quota: number
used_quota: number
request_count: number
group?: string
aff_code?: string
aff_count?: number
aff_quota?: number
remark?: string
}